mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 04:16:06 +00:00
refactor(ui): Phase 5 — Zustand persist middleware + constants split
Stores: - use-auth-store: manual localStorage → persist() with partialize (token, userId, senderID) - use-ui-store: manual localStorage → persist() with partialize (theme, language, timezone, sidebarCollapsed) - use-team-event-store: manual persist helpers → persist() with partialize (last 20 events), onRehydrateStorage seeds counter Constants: - Split lib/constants.ts (144 lines) into: - lib/routes.ts (46 lines) — route paths - lib/timezone-utils.ts (74 lines) — IANA timezone data - lib/constants.ts (30 lines) — barrel re-export ForwardRef: skipped — all UI primitives already use React 19 ref-as-prop pattern. No behavior changes. Build passes.
This commit is contained in:
1 parent
2ed810b854
commit
e962a8ec7a
6 files changed
+282
-264
No files matched your search
+8
-121
@@ -1,49 +1,11 @@
|
||||
export const ROUTES = {
|
||||
LOGIN: "/login",
|
||||
OVERVIEW: "/overview",
|
||||
CHAT: "/chat",
|
||||
CHAT_PATTERN: "/chat/:sessionKey?",
|
||||
AGENTS: "/agents",
|
||||
AGENT_DETAIL: "/agents/:id",
|
||||
AGENT_CODEX_POOL: "/agents/:id/codex-pool",
|
||||
IMPORT_EXPORT: "/import-export",
|
||||
SESSIONS: "/sessions",
|
||||
SESSION_DETAIL: "/sessions/:key",
|
||||
SKILLS: "/skills",
|
||||
SKILL_DETAIL: "/skills/:id",
|
||||
CRON: "/cron",
|
||||
CRON_DETAIL: "/cron/:id",
|
||||
CONFIG: "/config",
|
||||
TRACES: "/traces",
|
||||
TRACE_DETAIL: "/traces/:id",
|
||||
EVENTS: "/events",
|
||||
USAGE: "/usage",
|
||||
CHANNELS: "/channels",
|
||||
CHANNEL_DETAIL: "/channels/:id",
|
||||
CONTACTS: "/contacts",
|
||||
APPROVALS: "/approvals",
|
||||
NODES: "/nodes",
|
||||
LOGS: "/logs",
|
||||
PROVIDERS: "/providers",
|
||||
PROVIDER_DETAIL: "/providers/:id",
|
||||
TEAMS: "/teams",
|
||||
TEAM_DETAIL: "/teams/:id",
|
||||
BUILTIN_TOOLS: "/builtin-tools",
|
||||
CLI_CREDENTIALS: "/cli-credentials",
|
||||
MCP: "/mcp",
|
||||
TTS: "/tts",
|
||||
STORAGE: "/storage",
|
||||
PENDING_MESSAGES: "/pending-messages",
|
||||
MEMORY: "/memory",
|
||||
KNOWLEDGE_GRAPH: "/knowledge-graph",
|
||||
ACTIVITY: "/activity",
|
||||
API_KEYS: "/api-keys",
|
||||
PACKAGES: "/packages",
|
||||
TENANTS: "/admin/tenants",
|
||||
TENANT_DETAIL: "/admin/tenants/:id",
|
||||
SETUP: "/setup",
|
||||
SELECT_TENANT: "/select-tenant",
|
||||
} as const;
|
||||
// Barrel re-exports for backward compatibility.
|
||||
// Import directly from sub-modules for new code.
|
||||
export { ROUTES } from "./routes";
|
||||
export {
|
||||
TIMEZONE_OPTIONS,
|
||||
getAllIanaTimezones,
|
||||
isValidIanaTimezone,
|
||||
} from "./timezone-utils";
|
||||
|
||||
export const LOCAL_STORAGE_KEYS = {
|
||||
TOKEN: "goclaw:token",
|
||||
@@ -66,78 +28,3 @@ export const LANGUAGE_LABELS: Record<Language, string> = {
|
||||
vi: "Tiếng Việt",
|
||||
zh: "中文",
|
||||
};
|
||||
|
||||
/** "auto" = browser's local timezone. */
|
||||
export const TIMEZONE_OPTIONS = [
|
||||
{ value: "auto", label: "Auto (Local)" },
|
||||
{ value: "UTC", label: "UTC" },
|
||||
{ value: "America/New_York", label: "New York (ET)" },
|
||||
{ value: "America/Chicago", label: "Chicago (CT)" },
|
||||
{ value: "America/Los_Angeles", label: "Los Angeles (PT)" },
|
||||
{ value: "Europe/London", label: "London (GMT/BST)" },
|
||||
{ value: "Europe/Paris", label: "Paris (CET)" },
|
||||
{ value: "Asia/Tokyo", label: "Tokyo (JST)" },
|
||||
{ value: "Asia/Shanghai", label: "Shanghai (CST)" },
|
||||
{ value: "Asia/Ho_Chi_Minh", label: "Ho Chi Minh (ICT)" },
|
||||
{ value: "Asia/Singapore", label: "Singapore (SGT)" },
|
||||
{ value: "Australia/Sydney", label: "Sydney (AEST)" },
|
||||
] as const;
|
||||
|
||||
/** Compute UTC offset string for a timezone, e.g. "UTC+7", "UTC-5:30" */
|
||||
function formatUtcOffset(tz: string): string {
|
||||
const parts = new Intl.DateTimeFormat("en-US", { timeZone: tz, timeZoneName: "shortOffset" }).formatToParts(new Date());
|
||||
const raw = parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT";
|
||||
return raw.replace("GMT", "UTC").replace(/^UTC$/, "UTC+0");
|
||||
}
|
||||
|
||||
function parseOffsetMinutes(offset: string): number {
|
||||
const m = offset.match(/UTC([+-]?)(\d+)(?::(\d+))?/);
|
||||
if (!m) return 0;
|
||||
const sign = m[1] === "-" ? -1 : 1;
|
||||
return sign * (parseInt(m[2]!, 10) * 60 + parseInt(m[3] ?? "0", 10));
|
||||
}
|
||||
|
||||
type TzOption = { value: string; label: string };
|
||||
let _cachedTimezones: TzOption[] | undefined;
|
||||
|
||||
/** Minimal fallback when Intl.supportedValuesOf is unavailable. */
|
||||
const FALLBACK_TIMEZONES: string[] = [
|
||||
"UTC", "America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles",
|
||||
"America/Halifax", "America/Sao_Paulo", "Europe/London", "Europe/Paris", "Europe/Berlin",
|
||||
"Europe/Moscow", "Asia/Dubai", "Asia/Kolkata", "Asia/Bangkok", "Asia/Ho_Chi_Minh",
|
||||
"Asia/Shanghai", "Asia/Tokyo", "Asia/Seoul", "Asia/Singapore", "Australia/Sydney",
|
||||
"Pacific/Auckland",
|
||||
];
|
||||
|
||||
/** All IANA timezones with dynamic UTC offsets, sorted by offset then name. */
|
||||
export function getAllIanaTimezones(): TzOption[] {
|
||||
if (_cachedTimezones) return _cachedTimezones;
|
||||
let tzNames: string[];
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
tzNames = (Intl as any).supportedValuesOf("timeZone") as string[];
|
||||
} catch {
|
||||
tzNames = FALLBACK_TIMEZONES;
|
||||
}
|
||||
// Chrome may omit "UTC" from the list
|
||||
if (!tzNames.includes("UTC")) tzNames = ["UTC", ...tzNames];
|
||||
const entries = tzNames.map((tz: string) => {
|
||||
const offset = formatUtcOffset(tz);
|
||||
return { value: tz, label: `${tz} (${offset})`, _offset: parseOffsetMinutes(offset) };
|
||||
});
|
||||
entries.sort((a: { _offset: number; value: string }, b: { _offset: number; value: string }) =>
|
||||
a._offset !== b._offset ? a._offset - b._offset : a.value.localeCompare(b.value),
|
||||
);
|
||||
_cachedTimezones = entries.map(({ value, label }: { value: string; label: string }) => ({ value, label }));
|
||||
return _cachedTimezones;
|
||||
}
|
||||
|
||||
let _cachedTzSet: Set<string> | undefined;
|
||||
|
||||
/** Check if a value is a valid IANA timezone from the dynamic list. */
|
||||
export function isValidIanaTimezone(tz: string): boolean {
|
||||
if (!_cachedTzSet) {
|
||||
_cachedTzSet = new Set(getAllIanaTimezones().map((t) => t.value));
|
||||
}
|
||||
return _cachedTzSet.has(tz);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
export const ROUTES = {
|
||||
LOGIN: "/login",
|
||||
OVERVIEW: "/overview",
|
||||
CHAT: "/chat",
|
||||
CHAT_PATTERN: "/chat/:sessionKey?",
|
||||
AGENTS: "/agents",
|
||||
AGENT_DETAIL: "/agents/:id",
|
||||
AGENT_CODEX_POOL: "/agents/:id/codex-pool",
|
||||
IMPORT_EXPORT: "/import-export",
|
||||
SESSIONS: "/sessions",
|
||||
SESSION_DETAIL: "/sessions/:key",
|
||||
SKILLS: "/skills",
|
||||
SKILL_DETAIL: "/skills/:id",
|
||||
CRON: "/cron",
|
||||
CRON_DETAIL: "/cron/:id",
|
||||
CONFIG: "/config",
|
||||
TRACES: "/traces",
|
||||
TRACE_DETAIL: "/traces/:id",
|
||||
EVENTS: "/events",
|
||||
USAGE: "/usage",
|
||||
CHANNELS: "/channels",
|
||||
CHANNEL_DETAIL: "/channels/:id",
|
||||
CONTACTS: "/contacts",
|
||||
APPROVALS: "/approvals",
|
||||
NODES: "/nodes",
|
||||
LOGS: "/logs",
|
||||
PROVIDERS: "/providers",
|
||||
PROVIDER_DETAIL: "/providers/:id",
|
||||
TEAMS: "/teams",
|
||||
TEAM_DETAIL: "/teams/:id",
|
||||
BUILTIN_TOOLS: "/builtin-tools",
|
||||
CLI_CREDENTIALS: "/cli-credentials",
|
||||
MCP: "/mcp",
|
||||
TTS: "/tts",
|
||||
STORAGE: "/storage",
|
||||
PENDING_MESSAGES: "/pending-messages",
|
||||
MEMORY: "/memory",
|
||||
KNOWLEDGE_GRAPH: "/knowledge-graph",
|
||||
ACTIVITY: "/activity",
|
||||
API_KEYS: "/api-keys",
|
||||
PACKAGES: "/packages",
|
||||
TENANTS: "/admin/tenants",
|
||||
TENANT_DETAIL: "/admin/tenants/:id",
|
||||
SETUP: "/setup",
|
||||
SELECT_TENANT: "/select-tenant",
|
||||
} as const;
|
||||
@@ -0,0 +1,74 @@
|
||||
/** "auto" = browser's local timezone. */
|
||||
export const TIMEZONE_OPTIONS = [
|
||||
{ value: "auto", label: "Auto (Local)" },
|
||||
{ value: "UTC", label: "UTC" },
|
||||
{ value: "America/New_York", label: "New York (ET)" },
|
||||
{ value: "America/Chicago", label: "Chicago (CT)" },
|
||||
{ value: "America/Los_Angeles", label: "Los Angeles (PT)" },
|
||||
{ value: "Europe/London", label: "London (GMT/BST)" },
|
||||
{ value: "Europe/Paris", label: "Paris (CET)" },
|
||||
{ value: "Asia/Tokyo", label: "Tokyo (JST)" },
|
||||
{ value: "Asia/Shanghai", label: "Shanghai (CST)" },
|
||||
{ value: "Asia/Ho_Chi_Minh", label: "Ho Chi Minh (ICT)" },
|
||||
{ value: "Asia/Singapore", label: "Singapore (SGT)" },
|
||||
{ value: "Australia/Sydney", label: "Sydney (AEST)" },
|
||||
] as const;
|
||||
|
||||
/** Compute UTC offset string for a timezone, e.g. "UTC+7", "UTC-5:30" */
|
||||
function formatUtcOffset(tz: string): string {
|
||||
const parts = new Intl.DateTimeFormat("en-US", { timeZone: tz, timeZoneName: "shortOffset" }).formatToParts(new Date());
|
||||
const raw = parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT";
|
||||
return raw.replace("GMT", "UTC").replace(/^UTC$/, "UTC+0");
|
||||
}
|
||||
|
||||
function parseOffsetMinutes(offset: string): number {
|
||||
const m = offset.match(/UTC([+-]?)(\d+)(?::(\d+))?/);
|
||||
if (!m) return 0;
|
||||
const sign = m[1] === "-" ? -1 : 1;
|
||||
return sign * (parseInt(m[2]!, 10) * 60 + parseInt(m[3] ?? "0", 10));
|
||||
}
|
||||
|
||||
type TzOption = { value: string; label: string };
|
||||
let _cachedTimezones: TzOption[] | undefined;
|
||||
|
||||
/** Minimal fallback when Intl.supportedValuesOf is unavailable. */
|
||||
const FALLBACK_TIMEZONES: string[] = [
|
||||
"UTC", "America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles",
|
||||
"America/Halifax", "America/Sao_Paulo", "Europe/London", "Europe/Paris", "Europe/Berlin",
|
||||
"Europe/Moscow", "Asia/Dubai", "Asia/Kolkata", "Asia/Bangkok", "Asia/Ho_Chi_Minh",
|
||||
"Asia/Shanghai", "Asia/Tokyo", "Asia/Seoul", "Asia/Singapore", "Australia/Sydney",
|
||||
"Pacific/Auckland",
|
||||
];
|
||||
|
||||
/** All IANA timezones with dynamic UTC offsets, sorted by offset then name. */
|
||||
export function getAllIanaTimezones(): TzOption[] {
|
||||
if (_cachedTimezones) return _cachedTimezones;
|
||||
let tzNames: string[];
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
tzNames = (Intl as any).supportedValuesOf("timeZone") as string[];
|
||||
} catch {
|
||||
tzNames = FALLBACK_TIMEZONES;
|
||||
}
|
||||
// Chrome may omit "UTC" from the list
|
||||
if (!tzNames.includes("UTC")) tzNames = ["UTC", ...tzNames];
|
||||
const entries = tzNames.map((tz: string) => {
|
||||
const offset = formatUtcOffset(tz);
|
||||
return { value: tz, label: `${tz} (${offset})`, _offset: parseOffsetMinutes(offset) };
|
||||
});
|
||||
entries.sort((a: { _offset: number; value: string }, b: { _offset: number; value: string }) =>
|
||||
a._offset !== b._offset ? a._offset - b._offset : a.value.localeCompare(b.value),
|
||||
);
|
||||
_cachedTimezones = entries.map(({ value, label }: { value: string; label: string }) => ({ value, label }));
|
||||
return _cachedTimezones;
|
||||
}
|
||||
|
||||
let _cachedTzSet: Set<string> | undefined;
|
||||
|
||||
/** Check if a value is a valid IANA timezone from the dynamic list. */
|
||||
export function isValidIanaTimezone(tz: string): boolean {
|
||||
if (!_cachedTzSet) {
|
||||
_cachedTzSet = new Set(getAllIanaTimezones().map((t) => t.value));
|
||||
}
|
||||
return _cachedTzSet.has(tz);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import { LOCAL_STORAGE_KEYS } from "@/lib/constants";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { clearSetupSkippedState } from "@/lib/setup-skip";
|
||||
import type { TenantMembership } from "@/types/tenant";
|
||||
|
||||
@@ -29,63 +29,70 @@ interface AuthState {
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
token: localStorage.getItem(LOCAL_STORAGE_KEYS.TOKEN) ?? "",
|
||||
userId: localStorage.getItem(LOCAL_STORAGE_KEYS.USER_ID) ?? "",
|
||||
senderID: localStorage.getItem(LOCAL_STORAGE_KEYS.SENDER_ID) ?? "",
|
||||
connected: false,
|
||||
role: "" as UserRole,
|
||||
serverInfo: null,
|
||||
tenantId: "",
|
||||
tenantName: "",
|
||||
tenantSlug: "",
|
||||
isOwner: false,
|
||||
availableTenants: [],
|
||||
tenantSelected: !!localStorage.getItem(LOCAL_STORAGE_KEYS.TENANT_ID),
|
||||
|
||||
setCredentials: (token, userId) => {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEYS.TOKEN, token);
|
||||
localStorage.setItem(LOCAL_STORAGE_KEYS.USER_ID, userId);
|
||||
set({ token, userId });
|
||||
},
|
||||
|
||||
setPairing: (senderID, userId) => {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEYS.SENDER_ID, senderID);
|
||||
localStorage.setItem(LOCAL_STORAGE_KEYS.USER_ID, userId);
|
||||
set({ senderID, userId });
|
||||
},
|
||||
|
||||
setConnected: (connected, serverInfo) => {
|
||||
set({ connected, serverInfo: serverInfo ?? null });
|
||||
},
|
||||
|
||||
setRole: (role) => {
|
||||
set({ role });
|
||||
},
|
||||
|
||||
setTenant: (id, name, slug, isOwner) => {
|
||||
set({ tenantId: id, tenantName: name, tenantSlug: slug, isOwner });
|
||||
},
|
||||
|
||||
setAvailableTenants: (tenants) => {
|
||||
set({ availableTenants: tenants });
|
||||
},
|
||||
|
||||
setTenantSelected: (selected) => {
|
||||
set({ tenantSelected: selected });
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEYS.TOKEN);
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEYS.USER_ID);
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEYS.SENDER_ID);
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEYS.TENANT_ID);
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEYS.TENANT_HINT);
|
||||
clearSetupSkippedState();
|
||||
set({
|
||||
token: "", userId: "", senderID: "", connected: false, role: "", serverInfo: null,
|
||||
tenantId: "", tenantName: "", tenantSlug: "", isOwner: false, availableTenants: [],
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: "",
|
||||
userId: "",
|
||||
senderID: "",
|
||||
connected: false,
|
||||
role: "" as UserRole,
|
||||
serverInfo: null,
|
||||
tenantId: "",
|
||||
tenantName: "",
|
||||
tenantSlug: "",
|
||||
isOwner: false,
|
||||
availableTenants: [],
|
||||
tenantSelected: false,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
setCredentials: (token, userId) => {
|
||||
set({ token, userId });
|
||||
},
|
||||
|
||||
setPairing: (senderID, userId) => {
|
||||
set({ senderID, userId });
|
||||
},
|
||||
|
||||
setConnected: (connected, serverInfo) => {
|
||||
set({ connected, serverInfo: serverInfo ?? null });
|
||||
},
|
||||
|
||||
setRole: (role) => {
|
||||
set({ role });
|
||||
},
|
||||
|
||||
setTenant: (id, name, slug, isOwner) => {
|
||||
set({ tenantId: id, tenantName: name, tenantSlug: slug, isOwner });
|
||||
},
|
||||
|
||||
setAvailableTenants: (tenants) => {
|
||||
set({ availableTenants: tenants });
|
||||
},
|
||||
|
||||
setTenantSelected: (selected) => {
|
||||
set({ tenantSelected: selected });
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
// Remove tenant scope keys that are still managed outside persist
|
||||
localStorage.removeItem("goclaw:tenant_id");
|
||||
localStorage.removeItem("goclaw:tenant_hint");
|
||||
clearSetupSkippedState();
|
||||
set({
|
||||
token: "", userId: "", senderID: "", connected: false, role: "", serverInfo: null,
|
||||
tenantId: "", tenantName: "", tenantSlug: "", isOwner: false, availableTenants: [],
|
||||
tenantSelected: false,
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "goclaw:auth", // localStorage key
|
||||
partialize: (state) => ({
|
||||
// Only persist credentials — not transient runtime state
|
||||
token: state.token,
|
||||
userId: state.userId,
|
||||
senderID: state.senderID,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
const MAX_EVENTS = 500;
|
||||
const PERSIST_KEY = "goclaw:recentEvents";
|
||||
const PERSIST_MAX = 20;
|
||||
|
||||
/** A single captured WS event entry */
|
||||
@@ -52,56 +52,52 @@ function extractChatId(payload: unknown): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadPersistedEvents(): TeamEventEntry[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(PERSIST_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as TeamEventEntry[];
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.slice(-PERSIST_MAX);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
// Counter seeded from persisted state after hydration
|
||||
let counter = 0;
|
||||
|
||||
function persistEvents(events: TeamEventEntry[]) {
|
||||
try {
|
||||
const recent = events.slice(-PERSIST_MAX);
|
||||
localStorage.setItem(PERSIST_KEY, JSON.stringify(recent));
|
||||
} catch {
|
||||
// storage full or unavailable — ignore
|
||||
}
|
||||
}
|
||||
export const useTeamEventStore = create<TeamEventState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
events: [],
|
||||
paused: false,
|
||||
|
||||
const initialEvents = loadPersistedEvents();
|
||||
let counter = initialEvents.length > 0 ? initialEvents[initialEvents.length - 1]!.id : 0;
|
||||
addEvent: (event, payload) => {
|
||||
set((s) => {
|
||||
if (s.paused) return s;
|
||||
const entry: TeamEventEntry = {
|
||||
id: ++counter,
|
||||
event,
|
||||
payload,
|
||||
timestamp: Date.now(),
|
||||
teamId: extractTeamId(payload),
|
||||
userId: extractUserId(payload),
|
||||
chatId: extractChatId(payload),
|
||||
};
|
||||
const next = [...s.events, entry];
|
||||
const trimmed = next.length > MAX_EVENTS ? next.slice(next.length - MAX_EVENTS) : next;
|
||||
return { events: trimmed };
|
||||
});
|
||||
},
|
||||
|
||||
export const useTeamEventStore = create<TeamEventState>((set) => ({
|
||||
events: initialEvents,
|
||||
paused: false,
|
||||
clear: () => {
|
||||
set({ events: [] });
|
||||
},
|
||||
|
||||
addEvent: (event, payload) => {
|
||||
set((s) => {
|
||||
if (s.paused) return s;
|
||||
const entry: TeamEventEntry = {
|
||||
id: ++counter,
|
||||
event,
|
||||
payload,
|
||||
timestamp: Date.now(),
|
||||
teamId: extractTeamId(payload),
|
||||
userId: extractUserId(payload),
|
||||
chatId: extractChatId(payload),
|
||||
};
|
||||
const next = [...s.events, entry];
|
||||
const trimmed = next.length > MAX_EVENTS ? next.slice(next.length - MAX_EVENTS) : next;
|
||||
persistEvents(trimmed);
|
||||
return { events: trimmed };
|
||||
});
|
||||
},
|
||||
|
||||
clear: () => {
|
||||
localStorage.removeItem(PERSIST_KEY);
|
||||
set({ events: [] });
|
||||
},
|
||||
setPaused: (paused) => set({ paused }),
|
||||
}));
|
||||
setPaused: (paused) => set({ paused }),
|
||||
}),
|
||||
{
|
||||
name: "goclaw:recentEvents", // keep existing localStorage key for backward compat
|
||||
partialize: (state) => ({
|
||||
// Only persist the most recent events — not transient paused flag
|
||||
events: state.events.slice(-PERSIST_MAX),
|
||||
}),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
// Seed the counter from the highest persisted event id
|
||||
if (state?.events && state.events.length > 0) {
|
||||
const lastEvent = state.events[state.events.length - 1];
|
||||
if (lastEvent) counter = lastEvent.id;
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import i18n from "@/i18n";
|
||||
import { LOCAL_STORAGE_KEYS, type Language } from "@/lib/constants";
|
||||
import { type Language } from "@/lib/constants";
|
||||
|
||||
export type Theme = "light" | "dark" | "system";
|
||||
|
||||
@@ -19,40 +20,47 @@ interface UiState {
|
||||
setMobileSidebarOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const useUiStore = create<UiState>((set) => ({
|
||||
theme: (localStorage.getItem(LOCAL_STORAGE_KEYS.THEME) as Theme) ?? "dark",
|
||||
language: (i18n.language as Language) ?? "en",
|
||||
timezone: localStorage.getItem(LOCAL_STORAGE_KEYS.TIMEZONE) ?? "auto",
|
||||
sidebarCollapsed:
|
||||
localStorage.getItem(LOCAL_STORAGE_KEYS.SIDEBAR_COLLAPSED) === "true",
|
||||
mobileSidebarOpen: false,
|
||||
export const useUiStore = create<UiState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
theme: "dark" as Theme,
|
||||
language: (i18n.language as Language) ?? "en",
|
||||
timezone: "auto",
|
||||
sidebarCollapsed: false,
|
||||
mobileSidebarOpen: false,
|
||||
|
||||
setTheme: (theme) => {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEYS.THEME, theme);
|
||||
set({ theme });
|
||||
},
|
||||
setTheme: (theme) => {
|
||||
set({ theme });
|
||||
},
|
||||
|
||||
setLanguage: (language) => {
|
||||
i18n.changeLanguage(language);
|
||||
set({ language });
|
||||
},
|
||||
setLanguage: (language) => {
|
||||
i18n.changeLanguage(language);
|
||||
set({ language });
|
||||
},
|
||||
|
||||
setTimezone: (tz) => {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEYS.TIMEZONE, tz);
|
||||
set({ timezone: tz });
|
||||
},
|
||||
setTimezone: (tz) => {
|
||||
set({ timezone: tz });
|
||||
},
|
||||
|
||||
toggleSidebar: () =>
|
||||
set((state) => {
|
||||
const next = !state.sidebarCollapsed;
|
||||
localStorage.setItem(LOCAL_STORAGE_KEYS.SIDEBAR_COLLAPSED, String(next));
|
||||
return { sidebarCollapsed: next };
|
||||
toggleSidebar: () => {
|
||||
set({ sidebarCollapsed: !get().sidebarCollapsed });
|
||||
},
|
||||
|
||||
setSidebarCollapsed: (collapsed) => {
|
||||
set({ sidebarCollapsed: collapsed });
|
||||
},
|
||||
|
||||
setMobileSidebarOpen: (open) => set({ mobileSidebarOpen: open }),
|
||||
}),
|
||||
|
||||
setSidebarCollapsed: (collapsed) => {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEYS.SIDEBAR_COLLAPSED, String(collapsed));
|
||||
set({ sidebarCollapsed: collapsed });
|
||||
},
|
||||
|
||||
setMobileSidebarOpen: (open) => set({ mobileSidebarOpen: open }),
|
||||
}));
|
||||
{
|
||||
name: "goclaw:ui", // localStorage key
|
||||
partialize: (state) => ({
|
||||
// Persist user preferences — not transient UI state
|
||||
theme: state.theme,
|
||||
language: state.language,
|
||||
timezone: state.timezone,
|
||||
sidebarCollapsed: state.sidebarCollapsed,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
Reference in new issue
Block a user