feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
78
frontend/src/hooks/useAnimatedCounter.ts
Normal file
78
frontend/src/hooks/useAnimatedCounter.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Animates a number from 0 to a target value using requestAnimationFrame.
|
||||
* Respects prefers-reduced-motion by displaying the value instantly.
|
||||
*
|
||||
* @param target - The target number to animate to
|
||||
* @param duration - Animation duration in milliseconds (default 800)
|
||||
* @param decimals - Number of decimal places to round to (default 0)
|
||||
* @returns The current animated display value
|
||||
*/
|
||||
export function useAnimatedCounter(
|
||||
target: number,
|
||||
duration = 800,
|
||||
decimals = 0,
|
||||
): number {
|
||||
const [value, setValue] = useState(0)
|
||||
const frameRef = useRef<number>(0)
|
||||
const startTimeRef = useRef<number>(0)
|
||||
const startValueRef = useRef<number>(0)
|
||||
|
||||
useEffect(() => {
|
||||
// Check reduced motion preference
|
||||
const prefersReduced =
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
if (prefersReduced) {
|
||||
setValue(round(target, decimals))
|
||||
return
|
||||
}
|
||||
|
||||
// Cancel any in-progress animation
|
||||
if (frameRef.current) {
|
||||
cancelAnimationFrame(frameRef.current)
|
||||
}
|
||||
|
||||
startValueRef.current = value
|
||||
startTimeRef.current = 0
|
||||
|
||||
const animate = (timestamp: number) => {
|
||||
if (!startTimeRef.current) {
|
||||
startTimeRef.current = timestamp
|
||||
}
|
||||
|
||||
const elapsed = timestamp - startTimeRef.current
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
|
||||
// Ease-out cubic: t => 1 - (1 - t)^3
|
||||
const eased = 1 - Math.pow(1 - progress, 3)
|
||||
|
||||
const current =
|
||||
startValueRef.current + (target - startValueRef.current) * eased
|
||||
setValue(round(current, decimals))
|
||||
|
||||
if (progress < 1) {
|
||||
frameRef.current = requestAnimationFrame(animate)
|
||||
}
|
||||
}
|
||||
|
||||
frameRef.current = requestAnimationFrame(animate)
|
||||
|
||||
return () => {
|
||||
if (frameRef.current) {
|
||||
cancelAnimationFrame(frameRef.current)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [target, duration, decimals])
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function round(value: number, decimals: number): number {
|
||||
if (decimals === 0) return Math.round(value)
|
||||
const factor = Math.pow(10, decimals)
|
||||
return Math.round(value * factor) / factor
|
||||
}
|
||||
161
frontend/src/hooks/useConfigPanel.ts
Normal file
161
frontend/src/hooks/useConfigPanel.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Reusable hooks for config panel browse/apply operations.
|
||||
*
|
||||
* useConfigBrowse — wraps configEditorApi.browse with TanStack Query caching
|
||||
* useConfigPanel — manages pending changes, apply mode, and execution
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { configEditorApi } from '@/lib/configEditorApi'
|
||||
import {
|
||||
type ApplyMode,
|
||||
type ConfigChange,
|
||||
DEFAULT_APPLY_MODES,
|
||||
} from '@/lib/configPanelTypes'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useConfigBrowse
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface UseConfigBrowseOptions {
|
||||
/** Only fetch when true (tab active guard) */
|
||||
enabled?: boolean
|
||||
/** Refetch interval in ms (0 = disabled) */
|
||||
refetchInterval?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps configEditorApi.browse with TanStack Query for automatic caching,
|
||||
* refetching, and loading/error state management.
|
||||
*/
|
||||
export function useConfigBrowse(
|
||||
tenantId: string,
|
||||
deviceId: string,
|
||||
path: string,
|
||||
options: UseConfigBrowseOptions = {},
|
||||
) {
|
||||
const { enabled = true, refetchInterval = 0 } = options
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['config-browse', tenantId, deviceId, path],
|
||||
queryFn: () => configEditorApi.browse(tenantId, deviceId, path),
|
||||
enabled: enabled && !!tenantId && !!deviceId && !!path,
|
||||
refetchInterval: refetchInterval || undefined,
|
||||
staleTime: 30_000, // 30s — config doesn't change often
|
||||
})
|
||||
|
||||
return {
|
||||
entries: query.data?.entries ?? [],
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch: query.refetch,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useConfigPanel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Manages the full config apply workflow: pending changes collection,
|
||||
* apply mode toggling, and execution via the config editor API.
|
||||
*/
|
||||
export function useConfigPanel(
|
||||
tenantId: string,
|
||||
deviceId: string,
|
||||
panelType: string,
|
||||
) {
|
||||
const queryClient = useQueryClient()
|
||||
const [pendingChanges, setPendingChanges] = useState<ConfigChange[]>([])
|
||||
const [applyMode, setApplyMode] = useState<ApplyMode>(
|
||||
DEFAULT_APPLY_MODES[panelType] ?? 'quick',
|
||||
)
|
||||
|
||||
const addChange = useCallback((change: ConfigChange) => {
|
||||
setPendingChanges((prev) => [...prev, change])
|
||||
}, [])
|
||||
|
||||
const removeChange = useCallback((index: number) => {
|
||||
setPendingChanges((prev) => prev.filter((_, i) => i !== index))
|
||||
}, [])
|
||||
|
||||
const clearChanges = useCallback(() => {
|
||||
setPendingChanges([])
|
||||
}, [])
|
||||
|
||||
const applyMutation = useMutation({
|
||||
mutationFn: async (changes: ConfigChange[]) => {
|
||||
// Both modes execute changes sequentially via the binary API.
|
||||
// "Safe" mode defaults are set per-panel to trigger the review/confirm
|
||||
// dialog before execution — the safety is in the UI review step.
|
||||
for (const change of changes) {
|
||||
let result
|
||||
switch (change.operation) {
|
||||
case 'add':
|
||||
result = await configEditorApi.addEntry(
|
||||
tenantId,
|
||||
deviceId,
|
||||
change.path,
|
||||
change.properties,
|
||||
)
|
||||
break
|
||||
case 'set':
|
||||
result = await configEditorApi.setEntry(
|
||||
tenantId,
|
||||
deviceId,
|
||||
change.path,
|
||||
change.entryId,
|
||||
change.properties,
|
||||
)
|
||||
break
|
||||
case 'remove':
|
||||
if (!change.entryId) {
|
||||
throw new Error(`Remove operation requires entryId: ${change.description}`)
|
||||
}
|
||||
result = await configEditorApi.removeEntry(
|
||||
tenantId,
|
||||
deviceId,
|
||||
change.path,
|
||||
change.entryId,
|
||||
)
|
||||
break
|
||||
}
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? `Failed: ${change.description}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
const count = pendingChanges.length
|
||||
setPendingChanges([])
|
||||
toast.success(`${count} change${count !== 1 ? 's' : ''} applied successfully`)
|
||||
// Invalidate all config-browse queries for this device to refresh data
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['config-browse', tenantId, deviceId],
|
||||
})
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error('Configuration failed', {
|
||||
description: error.message,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const applyChanges = useCallback(() => {
|
||||
if (pendingChanges.length === 0) return
|
||||
applyMutation.mutate(pendingChanges)
|
||||
}, [pendingChanges, applyMutation])
|
||||
|
||||
return {
|
||||
pendingChanges,
|
||||
applyMode,
|
||||
setApplyMode,
|
||||
addChange,
|
||||
removeChange,
|
||||
clearChanges,
|
||||
applyChanges,
|
||||
isApplying: applyMutation.isPending,
|
||||
}
|
||||
}
|
||||
208
frontend/src/hooks/useEventStream.ts
Normal file
208
frontend/src/hooks/useEventStream.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ConnectionState = 'connecting' | 'connected' | 'reconnecting' | 'disconnected'
|
||||
|
||||
export interface SSEEvent {
|
||||
type: string // device_status, alert_fired, alert_resolved, config_push, firmware_progress, metric_update
|
||||
data: unknown // parsed JSON payload
|
||||
id: string // NATS sequence number
|
||||
}
|
||||
|
||||
type EventCallback = (event: SSEEvent) => void
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const EVENT_TYPES = [
|
||||
'device_status',
|
||||
'alert_fired',
|
||||
'alert_resolved',
|
||||
'config_push',
|
||||
'firmware_progress',
|
||||
'metric_update',
|
||||
] as const
|
||||
|
||||
const INITIAL_RETRY_DELAY_MS = 1000
|
||||
const MAX_RETRY_DELAY_MS = 30000
|
||||
const RETRY_MULTIPLIER = 2
|
||||
const MAX_RETRIES = 5
|
||||
|
||||
// SSE exchange tokens are valid for 30 seconds, so reconnect before expiry.
|
||||
// Using 25 seconds gives a comfortable margin.
|
||||
const TOKEN_REFRESH_INTERVAL_MS = 25 * 1000
|
||||
|
||||
// ─── SSE Token Exchange ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Exchange the current session (httpOnly cookie) for a short-lived,
|
||||
* single-use SSE token via POST /api/auth/sse-token.
|
||||
*
|
||||
* This avoids exposing the full JWT in the EventSource URL query parameter.
|
||||
*/
|
||||
async function getSSEToken(): Promise<string> {
|
||||
const { data } = await api.post<{ token: string }>('/api/auth/sse-token')
|
||||
return data.token
|
||||
}
|
||||
|
||||
// ─── Hook ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useEventStream(
|
||||
tenantId: string | null,
|
||||
onEvent: EventCallback,
|
||||
): {
|
||||
connectionState: ConnectionState
|
||||
lastConnectedAt: Date | null
|
||||
reconnect: () => void
|
||||
} {
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected')
|
||||
const [lastConnectedAt, setLastConnectedAt] = useState<Date | null>(null)
|
||||
|
||||
// Refs to persist across renders without causing re-renders
|
||||
const eventSourceRef = useRef<EventSource | null>(null)
|
||||
const retryCountRef = useRef(0)
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const tokenRefreshTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const onEventRef = useRef<EventCallback>(onEvent)
|
||||
const isUnmountedRef = useRef(false)
|
||||
|
||||
// Keep onEvent ref current without triggering reconnection
|
||||
useEffect(() => {
|
||||
onEventRef.current = onEvent
|
||||
}, [onEvent])
|
||||
|
||||
// Close existing EventSource and clear timers
|
||||
const cleanup = useCallback(() => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
eventSourceRef.current = null
|
||||
}
|
||||
if (reconnectTimerRef.current !== null) {
|
||||
clearTimeout(reconnectTimerRef.current)
|
||||
reconnectTimerRef.current = null
|
||||
}
|
||||
if (tokenRefreshTimerRef.current !== null) {
|
||||
clearInterval(tokenRefreshTimerRef.current)
|
||||
tokenRefreshTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Core connection function
|
||||
const connect = useCallback(async () => {
|
||||
if (!tenantId || isUnmountedRef.current) return
|
||||
|
||||
// Clean up any existing connection
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
eventSourceRef.current = null
|
||||
}
|
||||
|
||||
setConnectionState('connecting')
|
||||
|
||||
// Exchange session cookie for a short-lived SSE token
|
||||
let sseToken: string
|
||||
try {
|
||||
sseToken = await getSSEToken()
|
||||
} catch {
|
||||
// Token exchange failed -- go to reconnect flow
|
||||
if (isUnmountedRef.current) return
|
||||
handleReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
if (isUnmountedRef.current) return
|
||||
|
||||
const baseUrl = import.meta.env.VITE_API_URL ?? ''
|
||||
const url = `${baseUrl}/api/tenants/${tenantId}/events/stream?token=${encodeURIComponent(sseToken)}`
|
||||
const es = new EventSource(url)
|
||||
eventSourceRef.current = es
|
||||
|
||||
es.onopen = () => {
|
||||
if (isUnmountedRef.current) return
|
||||
setConnectionState('connected')
|
||||
setLastConnectedAt(new Date())
|
||||
retryCountRef.current = 0
|
||||
}
|
||||
|
||||
// Register named event listeners for each SSE event type
|
||||
EVENT_TYPES.forEach((type) => {
|
||||
es.addEventListener(type, (e: MessageEvent) => {
|
||||
if (isUnmountedRef.current) return
|
||||
try {
|
||||
const data: unknown = JSON.parse(e.data as string)
|
||||
onEventRef.current({ type, data, id: e.lastEventId })
|
||||
} catch {
|
||||
// Malformed JSON -- skip event
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
es.onerror = () => {
|
||||
if (isUnmountedRef.current) return
|
||||
es.close()
|
||||
eventSourceRef.current = null
|
||||
handleReconnect()
|
||||
}
|
||||
|
||||
// Set up token refresh interval — SSE tokens are 30s, reconnect at 25s
|
||||
if (tokenRefreshTimerRef.current !== null) {
|
||||
clearInterval(tokenRefreshTimerRef.current)
|
||||
}
|
||||
tokenRefreshTimerRef.current = setInterval(() => {
|
||||
if (isUnmountedRef.current) return
|
||||
// Silently reconnect with a fresh SSE token
|
||||
void connect()
|
||||
}, TOKEN_REFRESH_INTERVAL_MS)
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tenantId, cleanup])
|
||||
|
||||
// Reconnection with exponential backoff
|
||||
const handleReconnect = useCallback(() => {
|
||||
if (isUnmountedRef.current) return
|
||||
|
||||
if (retryCountRef.current >= MAX_RETRIES) {
|
||||
setConnectionState('disconnected')
|
||||
return
|
||||
}
|
||||
|
||||
setConnectionState('reconnecting')
|
||||
const delay = Math.min(
|
||||
INITIAL_RETRY_DELAY_MS * Math.pow(RETRY_MULTIPLIER, retryCountRef.current),
|
||||
MAX_RETRY_DELAY_MS,
|
||||
)
|
||||
retryCountRef.current += 1
|
||||
|
||||
reconnectTimerRef.current = setTimeout(() => {
|
||||
reconnectTimerRef.current = null
|
||||
void connect()
|
||||
}, delay)
|
||||
}, [connect])
|
||||
|
||||
// Manual reconnect: reset retry count, start fresh
|
||||
const reconnect = useCallback(() => {
|
||||
retryCountRef.current = 0
|
||||
cleanup()
|
||||
void connect()
|
||||
}, [cleanup, connect])
|
||||
|
||||
// Main effect: connect on mount / tenantId change, cleanup on unmount
|
||||
useEffect(() => {
|
||||
isUnmountedRef.current = false
|
||||
|
||||
if (tenantId) {
|
||||
void connect()
|
||||
} else {
|
||||
cleanup()
|
||||
setConnectionState('disconnected')
|
||||
}
|
||||
|
||||
return () => {
|
||||
isUnmountedRef.current = true
|
||||
cleanup()
|
||||
}
|
||||
}, [tenantId, connect, cleanup])
|
||||
|
||||
return { connectionState, lastConnectedAt, reconnect }
|
||||
}
|
||||
51
frontend/src/hooks/usePageTitle.ts
Normal file
51
frontend/src/hooks/usePageTitle.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useRouterState } from '@tanstack/react-router'
|
||||
|
||||
const ROUTE_TITLES: Record<string, string> = {
|
||||
'/': 'Dashboard',
|
||||
'/alerts': 'Alerts',
|
||||
'/alert-rules': 'Alert Rules',
|
||||
'/audit': 'Audit Log',
|
||||
'/batch-config': 'Batch Config',
|
||||
'/bulk-commands': 'Bulk Commands',
|
||||
'/certificates': 'Certificates',
|
||||
'/config-editor': 'Config Editor',
|
||||
'/firmware': 'Firmware',
|
||||
'/maintenance': 'Maintenance',
|
||||
'/map': 'Map',
|
||||
'/reports': 'Reports',
|
||||
'/settings': 'Settings',
|
||||
'/settings/api-keys': 'API Keys',
|
||||
'/setup': 'Setup',
|
||||
'/templates': 'Templates',
|
||||
'/tenants': 'Organizations',
|
||||
'/topology': 'Topology',
|
||||
'/transparency': 'Transparency',
|
||||
'/vpn': 'VPN',
|
||||
|
||||
'/about': 'About',
|
||||
}
|
||||
|
||||
export function usePageTitle() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
|
||||
useEffect(() => {
|
||||
// Match exact path or fall back to first segment
|
||||
let title = ROUTE_TITLES[pathname]
|
||||
|
||||
if (!title) {
|
||||
// Handle device-specific routes like /devices/:id/...
|
||||
if (pathname.startsWith('/devices/')) {
|
||||
title = 'Device'
|
||||
} else {
|
||||
// Capitalize the first path segment
|
||||
const segment = pathname.split('/').filter(Boolean)[0]
|
||||
title = segment
|
||||
? segment.charAt(0).toUpperCase() + segment.slice(1).replace(/-/g, ' ')
|
||||
: 'Dashboard'
|
||||
}
|
||||
}
|
||||
|
||||
document.title = `${title} | TOD`
|
||||
}, [pathname])
|
||||
}
|
||||
84
frontend/src/hooks/useShortcut.ts
Normal file
84
frontend/src/hooks/useShortcut.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Hook to register a single-key keyboard shortcut.
|
||||
* Skips when focus is in INPUT, TEXTAREA, or contentEditable elements.
|
||||
*/
|
||||
export function useShortcut(key: string, callback: () => void, enabled = true) {
|
||||
const callbackRef = useRef(callback)
|
||||
callbackRef.current = callback
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
// Skip if user is typing in an input/textarea/contenteditable
|
||||
const target = e.target as HTMLElement
|
||||
if (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable
|
||||
)
|
||||
return
|
||||
|
||||
if (e.key === key) {
|
||||
e.preventDefault()
|
||||
callbackRef.current()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [key, enabled])
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to register a two-key sequence shortcut (e.g., "g" then "d").
|
||||
* The second key must be pressed within 1 second of the first.
|
||||
* Skips when focus is in INPUT, TEXTAREA, or contentEditable elements.
|
||||
*/
|
||||
export function useSequenceShortcut(
|
||||
keys: [string, string],
|
||||
callback: () => void,
|
||||
enabled = true,
|
||||
) {
|
||||
const callbackRef = useRef(callback)
|
||||
callbackRef.current = callback
|
||||
const pendingRef = useRef<string | null>(null)
|
||||
const timeoutRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable
|
||||
)
|
||||
return
|
||||
|
||||
if (pendingRef.current === keys[0] && e.key === keys[1]) {
|
||||
e.preventDefault()
|
||||
pendingRef.current = null
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
||||
callbackRef.current()
|
||||
} else if (e.key === keys[0]) {
|
||||
pendingRef.current = keys[0]
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
pendingRef.current = null
|
||||
}, 1000)
|
||||
} else {
|
||||
pendingRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handler)
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
||||
}
|
||||
}, [keys[0], keys[1], enabled])
|
||||
}
|
||||
39
frontend/src/hooks/useSimpleConfig.ts
Normal file
39
frontend/src/hooks/useSimpleConfig.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* useSimpleConfigMode -- Per-device Simple/Standard mode toggle with localStorage persistence.
|
||||
*
|
||||
* Each device independently stores its mode preference. Default is 'standard' (opt-in to Simple).
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
const STORAGE_KEY = 'mikrotik-simple-mode'
|
||||
|
||||
type ConfigMode = 'simple' | 'standard'
|
||||
|
||||
function readPrefs(): Record<string, ConfigMode> {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
return stored ? (JSON.parse(stored) as Record<string, ConfigMode>) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function useSimpleConfigMode(deviceId: string) {
|
||||
const [mode, setMode] = useState<ConfigMode>(() => {
|
||||
const prefs = readPrefs()
|
||||
return prefs[deviceId] ?? 'standard'
|
||||
})
|
||||
|
||||
const toggleMode = useCallback(
|
||||
(newMode: ConfigMode) => {
|
||||
setMode(newMode)
|
||||
const prefs = readPrefs()
|
||||
prefs[deviceId] = newMode
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs))
|
||||
},
|
||||
[deviceId],
|
||||
)
|
||||
|
||||
return { mode, toggleMode }
|
||||
}
|
||||
Reference in New Issue
Block a user