feat: zustand store for overlay state

This commit is contained in:
Michael Chihlas
2026-05-08 17:49:34 -04:00
parent 7bfc09207d
commit ce88841209
2 changed files with 106 additions and 0 deletions

60
src/lib/store.test.ts Normal file
View File

@@ -0,0 +1,60 @@
import { describe, it, expect, beforeEach } from "vitest";
import { useOverlayStore } from "./store";
const initial = useOverlayStore.getState();
describe("useOverlayStore", () => {
beforeEach(() => useOverlayStore.setState(initial, true));
it("starts with sensible defaults", () => {
const s = useOverlayStore.getState();
expect(s.url).toBe("");
expect(s.opacity).toBe(1.0);
expect(s.alwaysOnTop).toBe(true);
expect(s.clickThrough).toBe(false);
expect(s.isOpen).toBe(false);
expect(s.urlError).toBe(null);
expect(s.hotkeyError).toBe(null);
});
it("setUrl validates and stores both url and urlError", () => {
useOverlayStore.getState().setUrl("twitch.tv");
expect(useOverlayStore.getState().url).toBe("twitch.tv");
expect(useOverlayStore.getState().urlError).toBe(null);
useOverlayStore.getState().setUrl("javascript:alert(1)");
expect(useOverlayStore.getState().urlError).toMatch(/http and https/);
});
it("setOpacity clamps to 0.1..1.0", () => {
useOverlayStore.getState().setOpacity(0.05);
expect(useOverlayStore.getState().opacity).toBe(0.1);
useOverlayStore.getState().setOpacity(2);
expect(useOverlayStore.getState().opacity).toBe(1.0);
useOverlayStore.getState().setOpacity(0.5);
expect(useOverlayStore.getState().opacity).toBe(0.5);
});
it("setAlwaysOnTop and setClickThrough mutate flags", () => {
useOverlayStore.getState().setAlwaysOnTop(false);
expect(useOverlayStore.getState().alwaysOnTop).toBe(false);
useOverlayStore.getState().setClickThrough(true);
expect(useOverlayStore.getState().clickThrough).toBe(true);
});
it("hydrate replaces persisted fields and clears errors", () => {
useOverlayStore.getState().setUrl("javascript:alert(1)");
useOverlayStore.getState().hydrate({
url: "https://example.com",
opacity: 0.6,
alwaysOnTop: false,
clickThrough: true,
});
const s = useOverlayStore.getState();
expect(s.url).toBe("https://example.com");
expect(s.opacity).toBe(0.6);
expect(s.alwaysOnTop).toBe(false);
expect(s.clickThrough).toBe(true);
expect(s.urlError).toBe(null);
});
});

46
src/lib/store.ts Normal file
View File

@@ -0,0 +1,46 @@
import { create } from "zustand";
import type { OverlayActions, OverlayPersistedState, OverlayState } from "../types/overlay";
import { validateUrl } from "./url";
const clamp = (v: number, min: number, max: number): number =>
Math.min(max, Math.max(min, v));
export const useOverlayStore = create<OverlayState & OverlayActions>()((set) => ({
url: "",
opacity: 1.0,
alwaysOnTop: true,
clickThrough: false,
isOpen: false,
urlError: null,
hotkeyError: null,
setUrl: (raw) => {
const result = validateUrl(raw);
set({
url: raw,
urlError: result.ok ? null : raw.trim() === "" ? null : result.error,
});
},
setOpacity: (v) => set({ opacity: clamp(v, 0.1, 1.0) }),
setAlwaysOnTop: (v) => set({ alwaysOnTop: v }),
setClickThrough: (v) => set({ clickThrough: v }),
_setClickThroughFromBackend: (v) =>
set((s) => (s.clickThrough === v ? s : { clickThrough: v })),
setHotkeyError: (msg) => set({ hotkeyError: msg }),
setIsOpen: (v) => set({ isOpen: v }),
hydrate: (p: OverlayPersistedState) =>
set({
url: p.url,
opacity: clamp(p.opacity, 0.1, 1.0),
alwaysOnTop: p.alwaysOnTop,
clickThrough: p.clickThrough,
urlError: null,
}),
}));