Add control center settings page
This commit is contained in:
parent
dd7ee24e73
commit
3c119e209d
6 changed files with 351 additions and 18 deletions
|
|
@ -2,6 +2,15 @@ const { contextBridge, ipcRenderer } = require("electron");
|
|||
|
||||
const api = {
|
||||
getPetsState: () => ipcRenderer.invoke("openpets:get-pets-state"),
|
||||
getSettingsState: () => ipcRenderer.invoke("openpets:get-settings-state"),
|
||||
updatePreferences: (patch) => ipcRenderer.invoke("openpets:update-preferences", patch),
|
||||
getReactionAnimationSettings: () => ipcRenderer.invoke("openpets:get-reaction-animation-settings"),
|
||||
getLaunchAtLogin: () => ipcRenderer.invoke("openpets:get-launch-at-login"),
|
||||
setLaunchAtLogin: (enabled) => ipcRenderer.invoke("openpets:set-launch-at-login", enabled),
|
||||
getUpdateStatus: () => ipcRenderer.invoke("openpets:get-update-status"),
|
||||
checkForUpdates: () => ipcRenderer.invoke("openpets:check-for-updates"),
|
||||
openUpdateReleasePage: () => ipcRenderer.invoke("openpets:open-update-release-page"),
|
||||
resetDefaultPetPosition: () => ipcRenderer.invoke("openpets:reset-default-pet-position"),
|
||||
getCatalog: () => ipcRenderer.invoke("openpets:get-catalog"),
|
||||
getCatalogPage: (page) => ipcRenderer.invoke("openpets:get-catalog-page", page),
|
||||
getCatalogSearch: () => ipcRenderer.invoke("openpets:get-catalog-search"),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data: https://openpets.dev openpets-codex:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-src 'none'" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data: https://openpets.dev openpets-codex: openpets-pet-preview:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-src 'none'" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OpenPets Control Center</title>
|
||||
</head>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./styles.css";
|
||||
import openPetsLogoUrl from "../../../assets/openpets.webp";
|
||||
import defaultThumbUrl from "../../../assets/default-pet-thumbnail.png";
|
||||
|
||||
const api = (window as unknown as { openPetsControlCenter: { getPetsState(): Promise<StateSnapshot>; getCatalog(): Promise<CatalogState>; getCatalogPage(page: number): Promise<CatalogState>; getCatalogSearch(): Promise<{ pets: SearchPetEntry[]; error?: string }>; getCodexPets(): Promise<CodexState>; setDefaultPet(petId: string): Promise<StateSnapshot>; installPet(petId: string): Promise<unknown>; importCodexPet(petId: string): Promise<unknown>; removePet(petId: string): Promise<StateSnapshot> } }).openPetsControlCenter;
|
||||
type Filter = "all" | "installed" | "featured" | "originals" | "western" | "asian" | "codex";
|
||||
type InstalledPet = { id: string; displayName: string; description?: string; builtIn: boolean; protected: boolean; installed: boolean; broken?: boolean; brokenReason?: string; source?: { kind?: "catalog"; preview?: string } | { kind: "codex"; path: string } };
|
||||
type PetEntry = { id: string; displayName: string; description?: string; searchText?: string; preview?: string; thumbnail?: string; spritesheet?: string; category?: "western" | "asian"; original?: boolean; featured?: boolean; catalogPage?: number; sourceKind?: "installed" | "catalog" | "codex"; installed?: boolean; builtIn?: boolean; protected?: boolean; broken?: boolean; brokenReason?: string };
|
||||
|
|
@ -12,6 +11,35 @@ type SearchPetEntry = Pick<PetEntry, "id" | "displayName" | "category" | "origin
|
|||
type StateSnapshot = { preferences: { defaultPetId: string }; pets: { installed: InstalledPet[] } };
|
||||
type CatalogState = { pets: PetEntry[]; source: string; error?: string; page?: number; pageCount?: number; total?: number; categories?: { id: "western" | "asian"; label: string; count: number }[]; originalsCount?: number; featuredCount?: number };
|
||||
type CodexState = { pets: PetEntry[]; error?: string };
|
||||
type PetScaleOption = { label: string; value: number };
|
||||
type UserSelectableAnimationState = "idle" | "review" | "running" | "waiting" | "waving" | "jumping" | "failed";
|
||||
type ReactionAnimationOverrides = Record<string, UserSelectableAnimationState>;
|
||||
type SettingsState = { preferences: { openDefaultPetOnLaunch: boolean; petScale: number; reactionAnimationOverrides?: ReactionAnimationOverrides }; petScaleOptions: PetScaleOption[] };
|
||||
type LaunchAtLoginState = { supported: boolean; enabled: boolean };
|
||||
type UpdateStatus = { state: "idle" | "checking" | "available" | "current" | "error"; currentVersion: string; latestVersion?: string; releaseUrl?: string; checkedAt?: number; error?: string };
|
||||
type ReactionAnimationSettings = { reactions: { id: string; label: string; description: string; defaultAnimation: UserSelectableAnimationState }[]; animations: { id: UserSelectableAnimationState; label: string; description: string }[]; sprite: { frameWidth: number; frameHeight: number; columns: number; rows: number; states: Record<UserSelectableAnimationState, { row: number; frames: number; durationMs: number; iterations?: number | "infinite" }> }; overrides: ReactionAnimationOverrides; previewSpriteUrl: string };
|
||||
type ControlCenterApi = {
|
||||
getPetsState(): Promise<StateSnapshot>;
|
||||
getSettingsState(): Promise<SettingsState>;
|
||||
updatePreferences(patch: Partial<SettingsState["preferences"]>): Promise<SettingsState>;
|
||||
getReactionAnimationSettings(): Promise<ReactionAnimationSettings>;
|
||||
getLaunchAtLogin(): Promise<LaunchAtLoginState>;
|
||||
setLaunchAtLogin(enabled: boolean): Promise<LaunchAtLoginState>;
|
||||
getUpdateStatus(): Promise<UpdateStatus>;
|
||||
checkForUpdates(): Promise<UpdateStatus>;
|
||||
openUpdateReleasePage(): Promise<void>;
|
||||
resetDefaultPetPosition(): Promise<SettingsState>;
|
||||
getCatalog(): Promise<CatalogState>;
|
||||
getCatalogPage(page: number): Promise<CatalogState>;
|
||||
getCatalogSearch(): Promise<{ pets: SearchPetEntry[]; error?: string }>;
|
||||
getCodexPets(): Promise<CodexState>;
|
||||
setDefaultPet(petId: string): Promise<StateSnapshot>;
|
||||
installPet(petId: string): Promise<unknown>;
|
||||
importCodexPet(petId: string): Promise<unknown>;
|
||||
removePet(petId: string): Promise<StateSnapshot>;
|
||||
};
|
||||
|
||||
const api = (window as unknown as { openPetsControlCenter: ControlCenterApi }).openPetsControlCenter;
|
||||
|
||||
|
||||
// Inline SVG Icons for actions, pagination, and filters
|
||||
|
|
@ -387,6 +415,231 @@ function PetImage({ src, alt = "", debugLabel }: { src?: string; alt?: string; d
|
|||
return <img src={safeSrc} alt={alt} draggable="false" onError={() => logPetsError("image-failed", { label: debugLabel, src: imageDebug(safeSrc) })} />;
|
||||
}
|
||||
|
||||
function ToggleRow({ title, description, checked, disabled, onChange }: { title: string; description: string; checked: boolean; disabled?: boolean; onChange: (checked: boolean) => void }) {
|
||||
return <label className={`settings-row ${disabled ? "opacity-60" : ""}`}>
|
||||
<div className="settings-row-info"><strong>{title}</strong><small>{description}</small></div>
|
||||
<input className="settings-toggle" type="checkbox" checked={checked} disabled={disabled} onChange={(event) => onChange(event.target.checked)} />
|
||||
</label>;
|
||||
}
|
||||
|
||||
function formatUpdateStatus(status: UpdateStatus | null): string {
|
||||
if (!status) return "Update status has not loaded yet.";
|
||||
if (status.state === "checking") return "Checking for updates…";
|
||||
if (status.state === "available") return `Version ${status.latestVersion ?? "latest"} is available.`;
|
||||
if (status.state === "current") return `Up to date.`;
|
||||
if (status.state === "error") return status.error || "Update check failed.";
|
||||
return `Version: ${status.currentVersion}.`;
|
||||
}
|
||||
|
||||
function ReactionPreviewSprite({ settings, state }: { settings: ReactionAnimationSettings; state: UserSelectableAnimationState }) {
|
||||
const frame = { width: settings.sprite.frameWidth, height: settings.sprite.frameHeight };
|
||||
const sprite = settings.sprite.states[state] ?? settings.sprite.states.idle;
|
||||
const xValues = Array.from({ length: sprite.frames }, (_, index) => String(-index * frame.width)).join(";");
|
||||
const y = -sprite.row * frame.height;
|
||||
|
||||
return (
|
||||
<div className="reaction-preview-sprite-shell">
|
||||
<svg className="reaction-preview-sprite" width={frame.width} height={frame.height} viewBox={`0 0 ${frame.width} ${frame.height}`} role="img" aria-label={`Animation: ${state}`}>
|
||||
<image href={settings.previewSpriteUrl} x="0" y={y} width={frame.width * settings.sprite.columns} height={frame.height * settings.sprite.rows} preserveAspectRatio="none">
|
||||
<animate attributeName="x" values={xValues} dur={`${sprite.durationMs}ms`} repeatCount="indefinite" calcMode="discrete" />
|
||||
</image>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsView() {
|
||||
const [settings, setSettings] = useState<SettingsState | null>(null);
|
||||
const [reactionSettings, setReactionSettings] = useState<ReactionAnimationSettings | null>(null);
|
||||
const [launchAtLogin, setLaunchAtLogin] = useState<LaunchAtLoginState | null>(null);
|
||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<"general" | "reactions">("general");
|
||||
const [busy, setBusy] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const reactionSaveQueue = useRef(Promise.resolve());
|
||||
|
||||
async function loadSettings() {
|
||||
setError("");
|
||||
const [nextSettings, nextReactions, nextLaunch, nextUpdate] = await Promise.all([
|
||||
api.getSettingsState(),
|
||||
api.getReactionAnimationSettings(),
|
||||
api.getLaunchAtLogin(),
|
||||
api.getUpdateStatus(),
|
||||
]);
|
||||
setSettings(nextSettings);
|
||||
setReactionSettings(nextReactions);
|
||||
setLaunchAtLogin(nextLaunch);
|
||||
setUpdateStatus(nextUpdate);
|
||||
if (nextUpdate.state === "checking") {
|
||||
void api.checkForUpdates().then(setUpdateStatus).catch((err) => setError(String(err?.message ?? err)));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void loadSettings().catch((err) => setError(String(err?.message ?? err))); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!message) return;
|
||||
const timeout = window.setTimeout(() => setMessage(""), 2200);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [message]);
|
||||
|
||||
async function run(label: string, fn: () => Promise<void>) {
|
||||
try { setBusy(label); setError(""); setMessage(""); await fn(); }
|
||||
catch (err) { setError(String((err as Error)?.message ?? err)); }
|
||||
finally { setBusy(""); }
|
||||
}
|
||||
|
||||
function patchPreferences(patch: Partial<SettingsState["preferences"]>, success: string) {
|
||||
void run("Saving", async () => {
|
||||
const next = await api.updatePreferences(patch);
|
||||
setSettings(next);
|
||||
if ("reactionAnimationOverrides" in patch) {
|
||||
setReactionSettings((current) => current ? { ...current, overrides: next.preferences.reactionAnimationOverrides ?? {} } : current);
|
||||
}
|
||||
setMessage(success);
|
||||
});
|
||||
}
|
||||
|
||||
function updateReactionOverride(reaction: ReactionAnimationSettings["reactions"][number], value: UserSelectableAnimationState) {
|
||||
const queuedSave = reactionSaveQueue.current.catch(() => undefined).then(() => run("Saving", async () => {
|
||||
const latestReactions = await api.getReactionAnimationSettings();
|
||||
const nextOverrides = { ...(latestReactions.overrides ?? {}) };
|
||||
if (value === reaction.defaultAnimation) delete nextOverrides[reaction.id];
|
||||
else nextOverrides[reaction.id] = value;
|
||||
const nextSettings = await api.updatePreferences({ reactionAnimationOverrides: nextOverrides });
|
||||
setSettings(nextSettings);
|
||||
setReactionSettings({ ...latestReactions, overrides: nextSettings.preferences.reactionAnimationOverrides ?? {} });
|
||||
setMessage("Reaction animation saved.");
|
||||
}));
|
||||
reactionSaveQueue.current = queuedSave;
|
||||
void queuedSave;
|
||||
}
|
||||
|
||||
const overrides = settings?.preferences.reactionAnimationOverrides ?? {};
|
||||
|
||||
return <div className="settings-layout">
|
||||
{error && <div className="error settings-message">{error}</div>}
|
||||
{message && <div className="settings-success settings-message">{message}</div>}
|
||||
|
||||
<div className="settings-container">
|
||||
<aside className="settings-sidebar">
|
||||
<button className={`settings-nav-item ${activeTab === "general" ? "active" : ""}`} onClick={() => setActiveTab("general")}>
|
||||
<SettingsIcon />
|
||||
<span>General</span>
|
||||
</button>
|
||||
<button className={`settings-nav-item ${activeTab === "reactions" ? "active" : ""}`} onClick={() => setActiveTab("reactions")}>
|
||||
<PetsIcon />
|
||||
<span>Reaction Mapping</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main className="settings-content">
|
||||
{activeTab === "general" && (
|
||||
<div className="settings-section">
|
||||
<p className="eyebrow">Environment</p>
|
||||
<h2 className="settings-section-title">General Settings</h2>
|
||||
|
||||
<div className="settings-group">
|
||||
<ToggleRow
|
||||
title="Show pet on launch"
|
||||
description="Keep OpenPets in the tray but hide the pet until requested."
|
||||
checked={settings?.preferences.openDefaultPetOnLaunch ?? false}
|
||||
disabled={!settings || !!busy}
|
||||
onChange={(checked) => patchPreferences({ openDefaultPetOnLaunch: checked }, "Startup preference saved.")}
|
||||
/>
|
||||
<ToggleRow
|
||||
title="Launch at login"
|
||||
description={launchAtLogin?.supported ? "Start OpenPets automatically when your computer starts." : "Not supported on this platform."}
|
||||
checked={launchAtLogin?.enabled ?? false}
|
||||
disabled={!launchAtLogin?.supported || !!busy}
|
||||
onChange={(checked) => void run("Saving", async () => { setLaunchAtLogin(await api.setLaunchAtLogin(checked)); setMessage("Login startup preference saved."); })}
|
||||
/>
|
||||
<div className="settings-row">
|
||||
<div className="settings-row-info">
|
||||
<strong>Pet scale</strong>
|
||||
<small>Adjust how large the default desktop pet appears.</small>
|
||||
</div>
|
||||
<select className="settings-select" value={settings?.preferences.petScale ?? ""} disabled={!settings || !!busy} onChange={(event) => patchPreferences({ petScale: Number(event.target.value) }, "Pet scale saved.")}>
|
||||
{(settings?.petScaleOptions ?? []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-actions">
|
||||
<Button variant="secondary" size="compact" disabled={!!busy} onClick={() => void run("Resetting", async () => { setSettings(await api.resetDefaultPetPosition()); setMessage("Default pet position reset."); })}>Reset Pet Position</Button>
|
||||
</div>
|
||||
|
||||
<div className="settings-system-footer">
|
||||
<div className="settings-system-info">
|
||||
<RefreshIcon />
|
||||
<span>System Status</span>
|
||||
<span className="settings-system-version">{updateStatus?.currentVersion}</span>
|
||||
<span className="opacity-60">{formatUpdateStatus(updateStatus)}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{updateStatus?.state === "available" && (
|
||||
<Button variant="primary" size="compact" disabled={!!busy} onClick={() => void run("Opening", async () => { await api.openUpdateReleasePage(); })}>Update Available</Button>
|
||||
)}
|
||||
<Button variant="secondary" size="compact" disabled={!!busy || updateStatus?.state === "checking"} onClick={() => void run("Checking", async () => { setUpdateStatus(await api.checkForUpdates()); })}>
|
||||
{busy === "Checking" ? "Checking…" : "Check for Updates"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "reactions" && (
|
||||
<div className="settings-section">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="eyebrow">Behavior</p>
|
||||
<h2 className="settings-section-title">Reaction Mapping</h2>
|
||||
</div>
|
||||
<Button variant="secondary" size="compact" disabled={!settings || !!busy || !Object.keys(overrides).length} onClick={() => patchPreferences({ reactionAnimationOverrides: {} }, "Reaction animations reset.")}>Reset to Defaults</Button>
|
||||
</div>
|
||||
<p className="text-sm text-slatecopy -mt-2 mb-2">Customize which animation plays for each agent reaction. Previews use the default pet.</p>
|
||||
|
||||
<div className="settings-group">
|
||||
<div className="reaction-grid">
|
||||
{(reactionSettings?.reactions ?? []).map((reaction) => {
|
||||
const currentAnimation = overrides[reaction.id] ?? reaction.defaultAnimation;
|
||||
return (
|
||||
<div className="reaction-row" key={reaction.id}>
|
||||
<div className="reaction-preview-box">
|
||||
{reactionSettings?.previewSpriteUrl && (
|
||||
<ReactionPreviewSprite settings={reactionSettings} state={currentAnimation} />
|
||||
)}
|
||||
</div>
|
||||
<div className="reaction-info">
|
||||
<strong>{reaction.label}</strong>
|
||||
<small>{reaction.description}</small>
|
||||
</div>
|
||||
<select
|
||||
className="settings-select"
|
||||
value={currentAnimation}
|
||||
disabled={!reactionSettings || !settings || !!busy}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value as UserSelectableAnimationState;
|
||||
updateReactionOverride(reaction, value);
|
||||
}}
|
||||
>
|
||||
{(reactionSettings?.animations ?? []).map((animation) => (
|
||||
<option key={animation.id} value={animation.id}>{animation.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [currentRoute, setCurrentRoute] = useState<Route>("pets");
|
||||
const [state, setState] = useState<StateSnapshot | null>(null);
|
||||
|
|
@ -595,7 +848,9 @@ function App() {
|
|||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
{currentRoute !== "pets" ? (
|
||||
{currentRoute === "settings" ? (
|
||||
<SettingsView />
|
||||
) : currentRoute !== "pets" ? (
|
||||
<PlaceholderView route={currentRoute} />
|
||||
) : (
|
||||
<div className="layout">
|
||||
|
|
|
|||
|
|
@ -72,12 +72,12 @@
|
|||
.btn-success:hover:not(:disabled) { background: linear-gradient(180deg, #6ee7b7, #059669); }
|
||||
.btn-warning { border: 1px solid rgba(217, 119, 6, 0.32); background: linear-gradient(180deg, #fbbf24, #d97706); color: #fff; box-shadow: inset 0 1px 0 rgba(255,255,255,0.38), 0 2px 4px rgba(61,99,160,0.10); }
|
||||
.btn-warning:hover:not(:disabled) { background: linear-gradient(180deg, #fcd34d, #d97706); }
|
||||
|
||||
|
||||
.btn-icon-wrapper { @apply flex items-center justify-center shrink-0; }
|
||||
.btn-icon { @apply h-4 w-4 stroke-[2.5]; }
|
||||
.pager-text { @apply text-xs font-bold uppercase tracking-wider text-slatecopy/80; }
|
||||
.error { @apply mb-4 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-red-700; }
|
||||
|
||||
|
||||
.nav-bar { @apply mb-5 flex items-center gap-1.5 border-b border-blue-200/30 pb-3.5 flex-wrap; }
|
||||
.nav-tab { @apply rounded-xl border px-3.5 py-2 font-monoDisplay text-xs font-black uppercase tracking-wider text-slate-600 active:scale-[0.96] transition-[transform,background-color,border-color,box-shadow] duration-150 flex items-center gap-2 cursor-pointer select-none h-9 shadow-sm; border-color: rgba(126, 161, 210, 0.25); background: linear-gradient(180deg, rgba(255, 255, 255, 0.8) 0%, rgba(241, 245, 249, 0.8) 100%); box-shadow: inset 0 1px 0 rgba(255,255,255,1), 0 2px 4px rgba(61,99,160,0.04); }
|
||||
.nav-tab:hover:not(.active) { @apply text-brand; border-color: rgba(37, 99, 235, 0.3); background: linear-gradient(180deg, rgba(255, 255, 255, 0.95) 0%, rgba(239, 246, 255, 0.8) 100%); box-shadow: inset 0 1px 0 rgba(255,255,255,1), 0 2px 4px rgba(61,99,160,0.06); }
|
||||
|
|
@ -85,4 +85,53 @@
|
|||
.nav-tab.active:hover { background: linear-gradient(180deg, #55a6ff 0%, #176df2 100%); }
|
||||
.nav-icon { @apply h-3.5 w-3.5 stroke-[2.5] opacity-75 shrink-0; }
|
||||
.nav-tab.active .nav-icon { @apply opacity-100; }
|
||||
|
||||
.settings-layout { @apply relative flex flex-col gap-6 overflow-y-auto pr-2 h-full; overscroll-behavior: contain; }
|
||||
.settings-container { @apply grid grid-cols-[280px_1fr] gap-6 min-h-0 flex-1; }
|
||||
.settings-sidebar { @apply flex flex-col gap-2; }
|
||||
.settings-content { @apply flex flex-col gap-6 overflow-y-auto pr-2 pb-8; overscroll-behavior: contain; }
|
||||
.settings-nav-item { @apply flex items-center gap-3 px-4 py-3 rounded-2xl border border-transparent text-sm font-bold text-slatecopy transition-all duration-150 cursor-pointer select-none; }
|
||||
.settings-nav-item:hover { @apply bg-white/50 text-brand border-blue-100/50; }
|
||||
.settings-nav-item.active { @apply bg-white text-brand border-blue-200 shadow-sm ring-4 ring-blue-100/30; }
|
||||
.settings-nav-icon { @apply w-4 h-4 opacity-70; }
|
||||
.settings-nav-item.active .settings-nav-icon { @apply opacity-100; }
|
||||
|
||||
.settings-section { @apply flex flex-col gap-4; }
|
||||
.settings-section-title { @apply m-0 font-monoDisplay text-2xl font-black text-navy; }
|
||||
|
||||
.settings-group { @apply flex flex-col overflow-hidden rounded-[24px] border border-blue-100/70 bg-white/55 shadow-sm; }
|
||||
.settings-row { @apply flex items-center justify-between gap-4 p-5 transition-colors hover:bg-white/80 border-b border-blue-50 last:border-b-0; }
|
||||
.settings-row-info { @apply flex-1 min-w-0; }
|
||||
.settings-row-info strong { @apply block text-sm font-bold text-navy; }
|
||||
.settings-row-info small { @apply mt-0.5 block text-xs leading-relaxed text-slatecopy; }
|
||||
|
||||
.settings-toggle { @apply h-6 w-11 shrink-0 cursor-pointer accent-brand disabled:cursor-not-allowed; }
|
||||
.settings-select { @apply min-h-10 min-w-[140px] rounded-xl border border-blue-200/80 bg-white/80 px-3 py-2 text-sm font-semibold text-navy outline-none focus:border-brand focus:ring-4 focus:ring-brand/15 disabled:cursor-not-allowed disabled:opacity-60; }
|
||||
|
||||
.settings-system-footer { @apply mt-2 flex items-center justify-between gap-4 p-4 rounded-2xl bg-blue-50/40 border border-blue-100/50; }
|
||||
.settings-system-info { @apply flex items-center gap-3 text-xs font-bold text-slatecopy; }
|
||||
.settings-system-version { @apply px-2 py-0.5 rounded-md bg-blue-100/50 text-brand font-mono; }
|
||||
|
||||
.reaction-grid { @apply grid grid-cols-1 gap-px bg-blue-50; }
|
||||
.reaction-row { @apply flex items-center gap-4 bg-white/80 p-4 transition-colors hover:bg-white; }
|
||||
.reaction-preview-box { @apply flex h-14 w-14 shrink-0 items-center justify-center rounded-xl bg-blue-50/50 border border-blue-100/50 overflow-hidden; }
|
||||
.reaction-preview-sprite-shell { @apply flex h-12 w-12 items-center justify-center; }
|
||||
.reaction-preview-sprite { display: block; width: 36px; height: 36px; flex: 0 0 auto; overflow: hidden; image-rendering: pixelated; }
|
||||
.reaction-preview-sprite image { pointer-events: none; user-select: none; }
|
||||
.reaction-info { @apply flex-1 min-w-0; }
|
||||
.reaction-info strong { @apply block text-sm font-bold text-navy; }
|
||||
.reaction-info small { @apply mt-0.5 block text-xs leading-relaxed text-slatecopy; }
|
||||
|
||||
.settings-panel { @apply flex flex-col gap-4 p-6; }
|
||||
.settings-panel h2 { @apply m-0 font-monoDisplay text-2xl font-black text-navy; }
|
||||
.settings-wide { @apply w-full; }
|
||||
.settings-actions { @apply flex flex-wrap gap-3 mt-2; }
|
||||
.settings-message { @apply fixed bottom-8 left-1/2 z-20 w-[min(360px,calc(100vw-3rem))] -translate-x-1/2 text-center shadow-lg; }
|
||||
.settings-success { @apply mb-4 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-emerald-700; }
|
||||
.update-card-content { @apply p-4 rounded-2xl bg-brand/5 border border-brand/10; }
|
||||
.update-card-eyebrow { @apply text-xs text-brand font-bold uppercase tracking-wider mb-2; }
|
||||
.update-card-text { @apply text-sm text-navy mb-4; }
|
||||
.reaction-header { @apply flex items-center justify-between mb-2; }
|
||||
.reaction-desc { @apply desc mb-2; }
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,6 +87,21 @@ function getPetsStateSnapshot(): { preferences: { defaultPetId: string }; pets:
|
|||
return { preferences: { defaultPetId: state.preferences.defaultPetId }, pets: state.pets };
|
||||
}
|
||||
|
||||
function getSettingsStateSnapshot(): {
|
||||
preferences: Pick<ReturnType<typeof getAppStateSnapshot>["preferences"], "openDefaultPetOnLaunch" | "petScale" | "reactionAnimationOverrides">;
|
||||
petScaleOptions: typeof petScaleOptions;
|
||||
} {
|
||||
const state = getAppStateSnapshot();
|
||||
return {
|
||||
preferences: {
|
||||
openDefaultPetOnLaunch: state.preferences.openDefaultPetOnLaunch,
|
||||
petScale: state.preferences.petScale,
|
||||
reactionAnimationOverrides: state.preferences.reactionAnimationOverrides,
|
||||
},
|
||||
petScaleOptions,
|
||||
};
|
||||
}
|
||||
|
||||
export function installInternalUiHandlers(): void {
|
||||
if (internalUiHandlersInstalled) {
|
||||
return;
|
||||
|
|
@ -104,8 +119,13 @@ export function installInternalUiHandlers(): void {
|
|||
return getPetsStateSnapshot();
|
||||
});
|
||||
|
||||
ipcMain.handle("openpets:get-settings-state", (event) => {
|
||||
assertAllowedSender(event, ["control-center"]);
|
||||
return getSettingsStateSnapshot();
|
||||
});
|
||||
|
||||
ipcMain.handle("openpets:get-reaction-animation-settings", async (event) => {
|
||||
assertAllowedSender(event, ["settings"]);
|
||||
assertAllowedSender(event, ["settings", "control-center"]);
|
||||
return getReactionAnimationSettingsSnapshot();
|
||||
});
|
||||
|
||||
|
|
@ -217,7 +237,7 @@ export function installInternalUiHandlers(): void {
|
|||
});
|
||||
|
||||
ipcMain.handle("openpets:update-preferences", (event, patch: unknown) => {
|
||||
assertAllowedSender(event, ["settings"]);
|
||||
assertAllowedSender(event, ["settings", "control-center"]);
|
||||
const previousScale = getAppStateSnapshot().preferences.petScale;
|
||||
const previousOverrides = JSON.stringify(getAppStateSnapshot().preferences.reactionAnimationOverrides ?? {});
|
||||
const state = updatePreferences(validatePreferencePatch(patch));
|
||||
|
|
@ -226,16 +246,16 @@ export function installInternalUiHandlers(): void {
|
|||
refreshDefaultPetContent();
|
||||
refreshAgentPetContent();
|
||||
}
|
||||
return state;
|
||||
return getInternalUiWindowKindForWebContents(event.sender.id) === "control-center" ? getSettingsStateSnapshot() : state;
|
||||
});
|
||||
|
||||
ipcMain.handle("openpets:get-launch-at-login", (event) => {
|
||||
assertAllowedSender(event, ["settings"]);
|
||||
assertAllowedSender(event, ["settings", "control-center"]);
|
||||
return getLaunchAtLoginState();
|
||||
});
|
||||
|
||||
ipcMain.handle("openpets:set-launch-at-login", (event, enabled: unknown) => {
|
||||
assertAllowedSender(event, ["settings"]);
|
||||
assertAllowedSender(event, ["settings", "control-center"]);
|
||||
if (typeof enabled !== "boolean") throw new Error("Invalid launch-at-login value.");
|
||||
if (!isLaunchAtLoginSupported()) return getLaunchAtLoginState();
|
||||
app.setLoginItemSettings({ openAtLogin: enabled, openAsHidden: true });
|
||||
|
|
@ -243,12 +263,12 @@ export function installInternalUiHandlers(): void {
|
|||
});
|
||||
|
||||
ipcMain.handle("openpets:get-update-status", (event) => {
|
||||
assertAllowedSender(event, ["settings"]);
|
||||
assertAllowedSender(event, ["settings", "control-center"]);
|
||||
return getUpdateStatus();
|
||||
});
|
||||
|
||||
ipcMain.handle("openpets:check-for-updates", async (event) => {
|
||||
assertAllowedSender(event, ["settings"]);
|
||||
assertAllowedSender(event, ["settings", "control-center"]);
|
||||
const status = await checkForGitHubReleaseUpdate();
|
||||
const { refreshTrayMenu } = await import("./tray.js");
|
||||
refreshTrayMenu();
|
||||
|
|
@ -256,7 +276,7 @@ export function installInternalUiHandlers(): void {
|
|||
});
|
||||
|
||||
ipcMain.handle("openpets:open-update-release-page", async (event) => {
|
||||
assertAllowedSender(event, ["settings"]);
|
||||
assertAllowedSender(event, ["settings", "control-center"]);
|
||||
await openUpdateReleasePage();
|
||||
});
|
||||
|
||||
|
|
@ -305,9 +325,9 @@ export function installInternalUiHandlers(): void {
|
|||
});
|
||||
|
||||
ipcMain.handle("openpets:reset-default-pet-position", (event) => {
|
||||
assertAllowedSender(event, ["settings"]);
|
||||
assertAllowedSender(event, ["settings", "control-center"]);
|
||||
resetDefaultPetToInitialPosition();
|
||||
return getAppStateSnapshot();
|
||||
return getInternalUiWindowKindForWebContents(event.sender.id) === "control-center" ? getSettingsStateSnapshot() : getAppStateSnapshot();
|
||||
});
|
||||
|
||||
ipcMain.handle("openpets:agent-setup-snapshot", async (event, selectedPetId: unknown, commandMode: unknown) => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
const productionCsp = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data: https://openpets.dev openpets-codex:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-src 'none'";
|
||||
const devCsp = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://openpets.dev openpets-codex:; connect-src 'self' http://127.0.0.1:5173 ws://127.0.0.1:5173; base-uri 'none'; form-action 'none'; frame-src 'none'";
|
||||
const productionCsp = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data: https://openpets.dev openpets-codex: openpets-pet-preview:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-src 'none'";
|
||||
const devCsp = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://openpets.dev openpets-codex: openpets-pet-preview:; connect-src 'self' http://127.0.0.1:5173 ws://127.0.0.1:5173; base-uri 'none'; form-action 'none'; frame-src 'none'";
|
||||
|
||||
export default defineConfig(({ command }) => ({
|
||||
root: "src/renderer",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue