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" />
|
||||
|
||||
@@ -295,7 +295,7 @@ export function Sagi() {
|
||||
onClose={() => setMenu(false)}
|
||||
onSettings={() => {
|
||||
setMenu(false);
|
||||
navigate("/settings/general#sagi");
|
||||
navigate("/settings/pet");
|
||||
}}
|
||||
/>
|
||||
</SagiFlyout>
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* row of Sagi's mood and steps through its frames (a still image bobs instead),
|
||||
* and `PetMenu` hides the mascot, sizes it, picks Sagi / a featured pet
|
||||
* (downloading it on the first pick) / an own pet, deletes an own pet (falling
|
||||
* back to Sagi when it was the one worn), and uploads an image.
|
||||
* back to Sagi when it was the one worn), and uploads an image; Settings →
|
||||
* Pet (`PetSettings`) does the same from a grid, plus show / bubbles / size.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
@@ -60,6 +61,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
import { api } from "../../../lib/api";
|
||||
import { PetSprite } from "../PetSprite";
|
||||
import { PetMenu } from "../PetMenu";
|
||||
import { PetSettings } from "../../settings/PetSettings";
|
||||
import { sagiPrefs } from "../prefs";
|
||||
|
||||
describe("PetSprite", () => {
|
||||
@@ -175,3 +177,46 @@ describe("PetMenu", () => {
|
||||
expect(api.pets.upload).toHaveBeenCalledWith(file);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PetSettings", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows, mutes and sizes the mascot, following the prefs live", async () => {
|
||||
render(<PetSettings />);
|
||||
fireEvent.change(screen.getByTestId("pet-settings-size"), { target: { value: "120" } });
|
||||
expect(sagiPrefs.getSize()).toBe(120);
|
||||
expect(screen.getByText("120px")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("switch", { name: /Speech bubbles/ }));
|
||||
expect(sagiPrefs.getMuted()).toBe(true);
|
||||
fireEvent.click(screen.getByRole("switch", { name: /Show Sagi/ }));
|
||||
expect(sagiPrefs.getEnabled()).toBe(false);
|
||||
// A change made elsewhere (the right-click menu) shows here at once.
|
||||
act(() => sagiPrefs.setSize(200));
|
||||
expect(screen.getByText("200px")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("downloads a featured pet on its first pick, wears an own one, deletes it back to Sagi", async () => {
|
||||
render(<PetSettings />);
|
||||
fireEvent.click(await screen.findByTestId("pet-card-featured-goku"));
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex" }));
|
||||
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
|
||||
|
||||
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa"));
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-aaaaaaaaaaaa", kind: "image" });
|
||||
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa-delete"));
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "sagi" }));
|
||||
expect(api.pets.remove).toHaveBeenCalledWith("p-aaaaaaaaaaaa");
|
||||
});
|
||||
|
||||
it("uploads an image and wears it", async () => {
|
||||
render(<PetSettings />);
|
||||
const file = new File([new Uint8Array([0x89, 0x50])], "Dog.png", { type: "image/png" });
|
||||
fireEvent.change(screen.getByTestId("pet-settings-image-input"), { target: { files: [file] } });
|
||||
await waitFor(() =>
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image" })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @file usePets — the pet list and its actions, shared by the mascot's
|
||||
* right-click menu (`PetMenu.tsx`) and Settings → Pet (`settings/PetSettings`):
|
||||
* `GET /api/pets`, wearing a pet (a viewer preference, `prefs.ts`), installing
|
||||
* a featured pet (downloaded on its first pick), uploading an image or a
|
||||
* `.codex-pet.zip` (worn at once), and deleting your own (Sagi comes back
|
||||
* when it was the one worn). Plus `useWornPet`, the live worn pet / size /
|
||||
* shown / speech-bubble prefs.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api, type PetList, type PetView } from "../../lib/api";
|
||||
import { sagiPrefs, type PetChoice } from "./prefs";
|
||||
|
||||
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
|
||||
|
||||
export function usePets() {
|
||||
const [list, setList] = useState<PetList | null>(null);
|
||||
// Which action is running: a featured id, a pet id, "image" or "bundle".
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(
|
||||
() =>
|
||||
api.pets
|
||||
.list()
|
||||
.then(setList)
|
||||
.catch((err: unknown) => setError(message(err))),
|
||||
[]
|
||||
);
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const run = async (key: string, work: () => Promise<void>) => {
|
||||
setBusy(key);
|
||||
setError(null);
|
||||
try {
|
||||
await work();
|
||||
} catch (err) {
|
||||
setError(message(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const wear = (p: PetView) => sagiPrefs.setPet({ id: p.id, kind: p.kind });
|
||||
const install = (featuredId: string) =>
|
||||
run(featuredId, async () => {
|
||||
wear((await api.pets.installFeatured(featuredId)).pet);
|
||||
await reload();
|
||||
});
|
||||
const add = (file: File, bundle: boolean) =>
|
||||
run(bundle ? "bundle" : "image", async () => {
|
||||
const { pet } = bundle ? await api.pets.importBundle(file) : await api.pets.upload(file);
|
||||
wear(pet);
|
||||
await reload();
|
||||
});
|
||||
const remove = (p: PetView) =>
|
||||
run(p.id, async () => {
|
||||
await api.pets.remove(p.id);
|
||||
if (sagiPrefs.getPet().id === p.id) sagiPrefs.setPet({ id: "sagi" });
|
||||
await reload();
|
||||
});
|
||||
|
||||
const own = list?.pets.filter((p) => p.source === "upload") ?? [];
|
||||
// Featured id → the installed copy, once downloaded.
|
||||
const installed = new Map(
|
||||
(list?.pets ?? []).filter((p) => p.featuredId).map((p) => [p.featuredId as string, p])
|
||||
);
|
||||
return { list, own, installed, busy, error, wear, install, add, remove };
|
||||
}
|
||||
|
||||
type Worn = { pet: PetChoice; size: number; shown: boolean; bubbles: boolean };
|
||||
const readWorn = (): Worn => ({
|
||||
pet: sagiPrefs.getPet(),
|
||||
size: sagiPrefs.getSize(),
|
||||
shown: sagiPrefs.getEnabled(),
|
||||
bubbles: !sagiPrefs.getMuted(),
|
||||
});
|
||||
|
||||
/** The worn pet and the mascot's prefs, following every change (any tab). */
|
||||
export function useWornPet(): Worn {
|
||||
const [worn, setWorn] = useState(readWorn);
|
||||
useEffect(() => sagiPrefs.subscribe(() => setWorn(readWorn())), []);
|
||||
return worn;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,11 @@
|
||||
"browse": "Find more on codex-pets.net",
|
||||
"settings": "Pet settings…"
|
||||
},
|
||||
"petHint": "Right-click the mascot to choose its pet — Sagi, a featured or imported Codex pet, or your own image — and its size."
|
||||
"bubbles": "Speech bubbles",
|
||||
"bubblesDesc": "Let the pet comment on your sessions in a bubble",
|
||||
"sizeDesc": "{{min}}–{{max}} px; the preview is capped at 120 px",
|
||||
"chooseDesc": "Featured pets come from codex-pets.net and are downloaded into this install the first time you pick one. Your choice and size are saved in this browser.",
|
||||
"uploadHint": "An image (PNG, JPEG, GIF, WebP — no SVG) is shown still; a .codex-pet.zip (pet.json + a 1536×1872 spritesheet.webp) is animated by mood."
|
||||
},
|
||||
"pricing": {
|
||||
"vendor": {
|
||||
@@ -676,6 +680,10 @@
|
||||
"title": "Appearance",
|
||||
"description": "Colour mode and theme. Saved in this browser only."
|
||||
},
|
||||
"pet": {
|
||||
"title": "Pet",
|
||||
"description": "The corner mascot: show it, its size, speech bubbles, and which pet it wears."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"description": "Browser notifications, alert rules and webhooks."
|
||||
|
||||
@@ -22,7 +22,11 @@
|
||||
"browse": "Tìm thêm trên codex-pets.net",
|
||||
"settings": "Cài đặt pet…"
|
||||
},
|
||||
"petHint": "Nhấp chuột phải vào mascot để chọn pet — Sagi, pet Codex có sẵn hoặc tự nhập, hay ảnh của bạn — và kích thước."
|
||||
"bubbles": "Bong bóng lời thoại",
|
||||
"bubblesDesc": "Cho pet bình luận về các session của bạn trong bong bóng",
|
||||
"sizeDesc": "{{min}}–{{max}} px; bản xem trước tối đa 120 px",
|
||||
"chooseDesc": "Pet nổi bật lấy từ codex-pets.net và được tải về máy ở lần đầu bạn chọn. Lựa chọn và kích thước được lưu trong trình duyệt này.",
|
||||
"uploadHint": "Ảnh (PNG, JPEG, GIF, WebP — không nhận SVG) hiển thị tĩnh; file .codex-pet.zip (pet.json + spritesheet.webp 1536×1872) chuyển động theo tâm trạng."
|
||||
},
|
||||
"pricing": {
|
||||
"vendor": {
|
||||
@@ -676,6 +680,10 @@
|
||||
"title": "Giao diện",
|
||||
"description": "Chế độ màu và theme. Chỉ lưu trong trình duyệt này."
|
||||
},
|
||||
"pet": {
|
||||
"title": "Pet",
|
||||
"description": "Linh vật ở góc màn hình: hiện/ẩn, kích thước, bong bóng lời thoại và pet đang dùng."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Thông báo",
|
||||
"description": "Thông báo trình duyệt, quy tắc cảnh báo và webhook."
|
||||
|
||||
@@ -34,7 +34,15 @@ export const SETTINGS_INDEX: {
|
||||
{ section: "general", titleKey: "lanesSection.runHousekeeping" },
|
||||
{ section: "general", titleKey: "import.title", keywords: ["history", "transcripts"] },
|
||||
{ section: "general", titleKey: "remoteSources.title", keywords: ["ssh", "machines"] },
|
||||
{ section: "general", titleKey: "sagi.title", descKey: "sagi.description" },
|
||||
{
|
||||
section: "pet",
|
||||
titleKey: "sagi.title",
|
||||
descKey: "sagi.description",
|
||||
keywords: ["sagi", "mascot", "pet"],
|
||||
},
|
||||
{ section: "pet", titleKey: "sagi.bubbles", descKey: "sagi.bubblesDesc" },
|
||||
{ section: "pet", titleKey: "sagi.pet.size" },
|
||||
{ section: "pet", titleKey: "sagi.pet.choose", keywords: ["codex-pets", "upload", "import"] },
|
||||
{ section: "general", titleKey: "data.title", descKey: "data.description" },
|
||||
{ section: "general", titleKey: "danger.title", keywords: ["clear", "delete", "export"] },
|
||||
{ section: "general", titleKey: "about.title", keywords: ["version", "server", "database"] },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Settings.tsx
|
||||
* @description The install-wide settings, as two pages of the Settings nav (`SettingsHub.tsx`): `section="general"` — total cost, workspaces housekeeping, import, remote sources, Sagi, data management and system information — and `section="notifications"` — browser notifications and alerts. Appearance is its own page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
|
||||
* @description The install-wide settings, as two pages of the Settings nav (`SettingsHub.tsx`): `section="general"` — total cost, workspaces housekeeping, import, remote sources, data management and system information (Sagi has its own page, `components/settings/PetSettings.tsx`) — and `section="notifications"` — browser notifications and alerts. Appearance is its own page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -23,7 +23,6 @@
|
||||
* ## Internal dependencies
|
||||
* - `../lib/api`
|
||||
* - `../lib/eventBus`
|
||||
* - `../components/Sagi/prefs`
|
||||
* - `../lib/format`
|
||||
* - `../lib/push`
|
||||
* - `../components/Tip`
|
||||
@@ -90,7 +89,6 @@ import {
|
||||
import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||||
import { sagiPrefs } from "../components/Sagi/prefs";
|
||||
import { fmt, fmtCost } from "../lib/format";
|
||||
import { subscribeToPush, unsubscribeFromPush } from "../lib/push";
|
||||
import {
|
||||
@@ -209,11 +207,6 @@ export function Settings({
|
||||
} | null>(null);
|
||||
const [confirmAction, setConfirmAction] = useState<string | null>(null);
|
||||
const [notifPrefs, setNotifPrefs] = useState<NotifPrefs>(loadNotifPrefs);
|
||||
const [sagiEnabled, setSagiEnabled] = useState(() => sagiPrefs.getEnabled());
|
||||
const setSagi = useCallback((v: boolean) => {
|
||||
sagiPrefs.setEnabled(v);
|
||||
setSagiEnabled(v);
|
||||
}, []);
|
||||
const [abandonHours, setAbandonHours] = useState("24");
|
||||
const [purgeDays, setPurgeDays] = useState("90");
|
||||
const [gcDryRun, setGcDryRun] = useState(false);
|
||||
@@ -458,49 +451,6 @@ export function Settings({
|
||||
<section id="remote-sources" className="scroll-mt-24">
|
||||
<RemoteSources />
|
||||
</section>
|
||||
|
||||
{/* ─── SAGI COMPANION ─── */}
|
||||
<section id="sagi" className="scroll-mt-24">
|
||||
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
||||
<span className="text-base leading-none" aria-hidden>
|
||||
🦸
|
||||
</span>
|
||||
{t("sagi.title", "Sagi companion")}
|
||||
</h3>
|
||||
<p className="text-xs text-fg-muted mb-4">
|
||||
{t("sagi.description", "A floating mascot that reacts to your live sessions.")}
|
||||
</p>
|
||||
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-xl flex items-center justify-center transition-colors ${
|
||||
sagiEnabled
|
||||
? "bg-blue-600/10 border border-blue-600/20"
|
||||
: "bg-surface-2 border border-border"
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg leading-none" aria-hidden>
|
||||
🦸
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<SettingsSwitchRow
|
||||
checked={sagiEnabled}
|
||||
onChange={setSagi}
|
||||
label={t("sagi.enable", "Show Sagi")}
|
||||
description={t(
|
||||
"sagi.enableDesc",
|
||||
"Display the corner companion across the dashboard (⌘B to open)"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p data-testid="sagi-pet-hint" className="mt-3 text-xs text-fg-muted">
|
||||
{t("sagi.petHint")}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
KeyRound,
|
||||
Orbit,
|
||||
Palette,
|
||||
PawPrint,
|
||||
Puzzle,
|
||||
Router,
|
||||
Search,
|
||||
@@ -37,6 +38,7 @@ import { AiToolConfig } from "./AiToolConfig";
|
||||
import { AiRouterConfig } from "./AiRouterConfig";
|
||||
import { GitCredentialsConfig } from "./GitCredentialsConfig";
|
||||
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
|
||||
import { PetSettings } from "../components/settings/PetSettings";
|
||||
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
||||
@@ -106,6 +108,13 @@ export function SettingsHub() {
|
||||
...s("appearance"),
|
||||
render: () => <AppearanceSettings />,
|
||||
},
|
||||
{
|
||||
id: "pet",
|
||||
group: "general",
|
||||
Icon: PawPrint,
|
||||
...s("pet"),
|
||||
render: () => <PetSettings />,
|
||||
},
|
||||
{
|
||||
id: "notifications",
|
||||
group: "general",
|
||||
|
||||
@@ -7678,89 +7678,6 @@ exports[`screen snapshots > Settings 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="scroll-mt-24"
|
||||
id="sagi"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="text-base leading-none"
|
||||
>
|
||||
🦸
|
||||
</span>
|
||||
Sagi companion
|
||||
</h3>
|
||||
<p
|
||||
class="text-xs text-fg-muted mb-4"
|
||||
>
|
||||
A floating mascot that reacts to your live sessions.
|
||||
</p>
|
||||
<div
|
||||
class="card p-5"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl flex items-center justify-center transition-colors bg-blue-600/10 border border-blue-600/20"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="text-lg leading-none"
|
||||
>
|
||||
🦸
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
Show Sagi
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
Display the corner companion across the dashboard (⌘B to open)
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
<button
|
||||
aria-checked="true"
|
||||
aria-label="Show Sagi"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50 bg-accent"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-[18px]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
class="mt-3 text-xs text-fg-muted"
|
||||
data-testid="sagi-pet-hint"
|
||||
>
|
||||
Right-click the mascot to choose its pet — Sagi, a featured or imported Codex pet, or your own image — and its size.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="scroll-mt-24"
|
||||
id="data"
|
||||
|
||||
+1
-1
@@ -2290,7 +2290,7 @@ POST /api/pets/featured/:id Download a FEATURED pet from codex-pets.ne
|
||||
DELETE /api/pets/:id Delete a pet (its folder under the data dir)
|
||||
```
|
||||
|
||||
The mascot (Sagi) can wear a pet: right-click it for **Hide pet**, **Size** (48–250 px), **Choose pet** (Sagi, the featured pets, your own — deletable), **Upload your own…**, **Import .codex-pet bundle…**, **Find more on codex-pets.net** and **Pet settings…** (Settings → General → Sagi). Which pet is worn and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); the pets live on the server, one folder each under `<data dir>/pets/<id>/` with a `meta.json` AgentHub writes.
|
||||
The mascot (Sagi) can wear a pet: right-click it for **Hide pet**, **Size** (48–250 px), **Choose pet** (Sagi, the featured pets, your own — deletable), **Upload your own…**, **Import .codex-pet bundle…**, **Find more on codex-pets.net** and **Pet settings…**, which opens **Settings → Pet** (`/settings/pet`, `client/src/components/settings/PetSettings.tsx`): show/hide, speech bubbles, size with a live preview, and the same choose / download / upload / import / delete as a grid. The menu and the page share `components/Sagi/usePets.ts`. Which pet is worn and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); the pets live on the server, one folder each under `<data dir>/pets/<id>/` with a `meta.json` AgentHub writes.
|
||||
|
||||
A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles shared on codex-pets.net and in `mileson/codex-pets`): `pet.json` (`id`, `displayName`, `description`, `spritesheetPath`) and `spritesheet.webp`, a 1536×1872 atlas of 8 columns × 9 rows of 192×208 frames — idle, run right, run left, waving, jumping, failed, waiting, running, review, with 6/8/8/4/5/8/6/6/6 frames. The mascot plays the row of its mood (an agent working → running, waiting for you → waiting, an error → failed, a finished turn → jumping, idle/asleep → idle). Only the two files are read, at the archive root or one folder deep; an entry's size is checked from the zip's directory before it is inflated; the sheet must be a WebP of exactly that size (`400 EBADPET` otherwise). An uploaded image is shown still and bobs; SVG is refused because an SVG served from the dashboard's origin runs script.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user