diff --git a/src/lib/settings.ts b/src/lib/settings.ts index faa51f6..3829a67 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -1,8 +1,10 @@ 'use client'; import { useEffect, useState, useCallback } from 'react'; +import { useStorageValue } from './use-storage-value'; const STORAGE_KEY = 'gittensor.settings'; +const EVENT_NAME = 'settings-changed'; export type IssueDefaultState = 'all' | 'open' | 'completed' | 'not_planned' | 'closed_other'; export type ContentDisplayMode = 'modal' | 'accordion' | 'side'; @@ -38,52 +40,53 @@ export const DEFAULT_SETTINGS: AppSettings = { layout: 'sidebar', }; -function readStorage(): AppSettings { +function parse(raw: string | null): AppSettings { + if (!raw) return DEFAULT_SETTINGS; + const parsed = JSON.parse(raw) as Partial; + return { + ...DEFAULT_SETTINGS, + ...parsed, + layout: parsed.layout === 'top-nav' || parsed.layout === 'sidebar' + ? parsed.layout + : DEFAULT_SETTINGS.layout, + }; +} + +function serialize(s: AppSettings): string { + return JSON.stringify(s); +} + +function readFresh(): AppSettings { if (typeof window === 'undefined') return DEFAULT_SETTINGS; try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return DEFAULT_SETTINGS; - const parsed = JSON.parse(raw) as Partial; - return { - ...DEFAULT_SETTINGS, - ...parsed, - layout: parsed.layout === 'top-nav' || parsed.layout === 'sidebar' - ? parsed.layout - : DEFAULT_SETTINGS.layout, - }; + return parse(localStorage.getItem(STORAGE_KEY)); } catch { return DEFAULT_SETTINGS; } } -function writeStorage(s: AppSettings) { - if (typeof window === 'undefined') return; - localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); - window.dispatchEvent(new Event('settings-changed')); -} - export function useSettings() { - const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [settings, setSettings] = useStorageValue( + STORAGE_KEY, + parse, + serialize, + DEFAULT_SETTINGS, + EVENT_NAME, + ); const [hydrated, setHydrated] = useState(false); useEffect(() => { - setSettings(readStorage()); setHydrated(true); - const handler = () => setSettings(readStorage()); - window.addEventListener('settings-changed', handler); - window.addEventListener('storage', handler); - return () => { - window.removeEventListener('settings-changed', handler); - window.removeEventListener('storage', handler); - }; }, []); - const update = useCallback((key: K, value: AppSettings[K]) => { - const next = { ...readStorage(), [key]: value }; - writeStorage(next); - }, []); + const update = useCallback( + (key: K, value: AppSettings[K]) => { + setSettings({ ...readFresh(), [key]: value }); + }, + [setSettings], + ); - const reset = useCallback(() => writeStorage(DEFAULT_SETTINGS), []); + const reset = useCallback(() => setSettings(DEFAULT_SETTINGS), [setSettings]); return { settings, update, reset, hydrated }; } diff --git a/src/lib/tracked-miners.ts b/src/lib/tracked-miners.ts index 17bebda..939d7c5 100644 --- a/src/lib/tracked-miners.ts +++ b/src/lib/tracked-miners.ts @@ -1,47 +1,49 @@ 'use client'; -import { useEffect, useState, useCallback } from 'react'; +import { useCallback } from 'react'; +import { useStorageValue } from './use-storage-value'; const STORAGE_KEY = 'gittensor.trackedMiners'; +const EVENT_NAME = 'tracked-miners-changed'; +const EMPTY: Set = new Set(); -function readStorage(): Set { +function parse(raw: string | null): Set { + if (!raw) return new Set(); + const arr = JSON.parse(raw); + return new Set(Array.isArray(arr) ? arr : []); +} + +function serialize(set: Set): string { + return JSON.stringify(Array.from(set)); +} + +function readFresh(): Set { if (typeof window === 'undefined') return new Set(); try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return new Set(); - const arr = JSON.parse(raw); - return new Set(Array.isArray(arr) ? arr : []); + return parse(localStorage.getItem(STORAGE_KEY)); } catch { return new Set(); } } -function writeStorage(set: Set) { - if (typeof window === 'undefined') return; - localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(set))); - window.dispatchEvent(new Event('tracked-miners-changed')); -} - export function useTrackedMiners() { - const [tracked, setTracked] = useState>(new Set()); - - useEffect(() => { - setTracked(readStorage()); - const handler = () => setTracked(readStorage()); - window.addEventListener('tracked-miners-changed', handler); - window.addEventListener('storage', handler); - return () => { - window.removeEventListener('tracked-miners-changed', handler); - window.removeEventListener('storage', handler); - }; - }, []); - - const toggle = useCallback((id: string) => { - const next = new Set(readStorage()); - if (next.has(id)) next.delete(id); - else next.add(id); - writeStorage(next); - }, []); + const [tracked, setTracked] = useStorageValue>( + STORAGE_KEY, + parse, + serialize, + EMPTY, + EVENT_NAME, + ); + + const toggle = useCallback( + (id: string) => { + const next = readFresh(); + if (next.has(id)) next.delete(id); + else next.add(id); + setTracked(next); + }, + [setTracked], + ); return { tracked, toggle }; } diff --git a/src/lib/tracked-repos.ts b/src/lib/tracked-repos.ts index c588701..87e70d7 100644 --- a/src/lib/tracked-repos.ts +++ b/src/lib/tracked-repos.ts @@ -1,8 +1,11 @@ 'use client'; -import { useEffect, useState, useCallback } from 'react'; +import { useCallback } from 'react'; +import { useStorageValue } from './use-storage-value'; const STORAGE_KEY = 'gittensor.trackedRepos'; +const EVENT_NAME = 'tracked-repos-changed'; +const EMPTY: Set = new Set(); function repoKey(fullName: string): string { return fullName.trim().toLowerCase(); @@ -20,51 +23,53 @@ function dedupe(names: string[]): Set { return new Set(byKey.values()); } -function readStorage(): Set { +function parse(raw: string | null): Set { + if (!raw) return new Set(); + const arr = JSON.parse(raw); + return dedupe(Array.isArray(arr) ? arr : []); +} + +function serialize(set: Set): string { + return JSON.stringify(Array.from(set)); +} + +function readFresh(): Set { if (typeof window === 'undefined') return new Set(); try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return new Set(); - const arr = JSON.parse(raw); - return dedupe(Array.isArray(arr) ? arr : []); + return parse(localStorage.getItem(STORAGE_KEY)); } catch { return new Set(); } } -function writeStorage(set: Set) { - if (typeof window === 'undefined') return; - localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(set))); - window.dispatchEvent(new Event('tracked-repos-changed')); -} - export function useTrackedRepos() { - const [tracked, setTracked] = useState>(new Set()); - - useEffect(() => { - setTracked(readStorage()); - const handler = () => setTracked(readStorage()); - window.addEventListener('tracked-repos-changed', handler); - window.addEventListener('storage', handler); - return () => { - window.removeEventListener('tracked-repos-changed', handler); - window.removeEventListener('storage', handler); - }; - }, []); + const [tracked, setTracked] = useStorageValue>( + STORAGE_KEY, + parse, + serialize, + EMPTY, + EVENT_NAME, + ); - const toggle = useCallback((fullName: string) => { - const key = repoKey(fullName); - if (!key) return; - const next = readStorage(); - const existing = Array.from(next).find((name) => repoKey(name) === key); - if (existing) next.delete(existing); - else next.add(fullName.trim()); - writeStorage(next); - }, []); + const toggle = useCallback( + (fullName: string) => { + const key = repoKey(fullName); + if (!key) return; + const next = readFresh(); + const existing = Array.from(next).find((name) => repoKey(name) === key); + if (existing) next.delete(existing); + else next.add(fullName.trim()); + setTracked(next); + }, + [setTracked], + ); - const clear = useCallback(() => writeStorage(new Set()), []); + const clear = useCallback(() => setTracked(new Set()), [setTracked]); - const setMany = useCallback((names: string[]) => writeStorage(dedupe(names)), []); + const setMany = useCallback( + (names: string[]) => setTracked(dedupe(names)), + [setTracked], + ); return { tracked, toggle, clear, setMany }; } diff --git a/src/lib/use-storage-value.ts b/src/lib/use-storage-value.ts new file mode 100644 index 0000000..4c40ffb --- /dev/null +++ b/src/lib/use-storage-value.ts @@ -0,0 +1,54 @@ +'use client'; + +import { useEffect, useState, useCallback, useRef } from 'react'; + +export function useStorageValue( + key: string, + parser: (raw: string | null) => T, + serializer: (value: T) => string, + defaultValue: T, + eventName?: string, +): [T, (value: T) => void] { + const [value, setValue] = useState(defaultValue); + const parserRef = useRef(parser); + const serializerRef = useRef(serializer); + const defaultRef = useRef(defaultValue); + parserRef.current = parser; + serializerRef.current = serializer; + defaultRef.current = defaultValue; + + useEffect(() => { + const read = (): T => { + if (typeof window === 'undefined') return defaultRef.current; + try { + return parserRef.current(localStorage.getItem(key)); + } catch { + return defaultRef.current; + } + }; + setValue(read()); + const handler = () => setValue(read()); + if (eventName) window.addEventListener(eventName, handler); + window.addEventListener('storage', handler); + return () => { + if (eventName) window.removeEventListener(eventName, handler); + window.removeEventListener('storage', handler); + }; + }, [key, eventName]); + + const write = useCallback( + (next: T) => { + if (typeof window === 'undefined') return; + localStorage.setItem(key, serializerRef.current(next)); + // Local fallback for the no-eventName case (the native `storage` event + // doesn't fire same-tab). When eventName is set, the dispatched event's + // synchronous handler will overwrite this with the parsed disk value — + // matching the original "state = parse(serialize(x))" semantics. + setValue(next); + if (eventName) window.dispatchEvent(new Event(eventName)); + }, + [key, eventName], + ); + + return [value, write]; +}