From 1edb6d7fd1f075d23f4dc172d4e2702855e8f1ab Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Fri, 9 Jan 2026 18:21:24 -0500 Subject: [PATCH 1/4] feat: Add localStorage config persistence Add ability to save, load, and manage multiple named configurations in localStorage. Form state auto-saves as users type and persists across page refreshes. - Add useConfigStorage hook for localStorage operations - Add ConfigPanel component for managing saved configs - Add getConfig/loadConfig methods to useErrorForm and useBatchMode - Integrate config persistence into ErrorGenerator Co-Authored-By: Claude --- app/components/ConfigPanel.tsx | 125 +++++++++++++++++++++ app/components/ErrorGenerator.tsx | 133 ++++++++++++++++++++-- app/hooks/useBatchMode.ts | 17 +++ app/hooks/useConfigStorage.ts | 179 ++++++++++++++++++++++++++++++ app/hooks/useErrorForm.ts | 34 ++++++ 5 files changed, 481 insertions(+), 7 deletions(-) create mode 100644 app/components/ConfigPanel.tsx create mode 100644 app/hooks/useConfigStorage.ts diff --git a/app/components/ConfigPanel.tsx b/app/components/ConfigPanel.tsx new file mode 100644 index 0000000..11b2bb1 --- /dev/null +++ b/app/components/ConfigPanel.tsx @@ -0,0 +1,125 @@ +'use client'; + +import { useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { FaFloppyDisk, FaXmark } from 'react-icons/fa6'; +import { fadeInUp, tagPop } from '@/app/styles/animations'; + +interface ConfigPanelProps { + activeConfigName: string | null; + savedConfigNames: string[]; + onSave: (name: string) => void; + onLoad: (name: string) => void; + onDelete: (name: string) => void; +} + +export const ConfigPanel = ({ + activeConfigName, + savedConfigNames, + onSave, + onLoad, + onDelete, +}: ConfigPanelProps) => { + const [isNaming, setIsNaming] = useState(false); + const [newName, setNewName] = useState(''); + + const handleSave = () => { + if (!newName.trim()) return; + onSave(newName.trim()); + setNewName(''); + setIsNaming(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') handleSave(); + if (e.key === 'Escape') { + setIsNaming(false); + setNewName(''); + } + }; + + return ( + +
+

+ {activeConfigName ? `Config: ${activeConfigName}` : 'Unsaved Config'} +

+
+ {isNaming ? ( +
+ setNewName(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Config name" + className="input-brutal px-2 py-1 text-xs w-32" + autoFocus + /> + + +
+ ) : ( + + )} +
+
+ + {savedConfigNames.length > 0 && ( +
+ + {savedConfigNames.map((name) => ( + onLoad(name)} + className={`tag-brutal cursor-pointer ${ + activeConfigName === name + ? 'bg-hero-purple text-brutal-black' + : '' + }`} + > + {name} + + + ))} + +
+ )} +
+ ); +}; diff --git a/app/components/ErrorGenerator.tsx b/app/components/ErrorGenerator.tsx index 9523c55..0777a74 100644 --- a/app/components/ErrorGenerator.tsx +++ b/app/components/ErrorGenerator.tsx @@ -1,11 +1,12 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { motion } from 'framer-motion'; import { staggerContainer, fadeInUp } from '@/app/styles/animations'; import { ToastContainer, ToastData, useToast } from '@/app/components/Toast'; import { ConfirmModal } from '@/app/components/ConfirmModal'; import { ErrorPreview } from '@/app/components/ErrorPreview'; +import { ConfigPanel } from '@/app/components/ConfigPanel'; import { DsnInput } from '@/app/components/form/DsnInput'; import { FormFields } from '@/app/components/form/FormFields'; import { TagInput } from '@/app/components/form/TagInput'; @@ -13,21 +14,127 @@ import { BatchModePanel } from '@/app/components/form/BatchModePanel'; import { SkipConfirm } from '@/app/components/form/SkipConfirm'; import { useErrorForm } from '@/app/hooks/useErrorForm'; import { useBatchMode } from '@/app/hooks/useBatchMode'; +import { useConfigStorage, ConfigData } from '@/app/hooks/useConfigStorage'; const ErrorGenerator = () => { - const [mounted, setMounted] = useState(false); const [toasts, setToasts] = useState([]); const [isOpen, setIsOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const [skipConfirm, setSkipConfirm] = useState(false); + const [initialLoadDone, setInitialLoadDone] = useState(false); const showToast = useToast(setToasts); const form = useErrorForm(showToast); const batch = useBatchMode(showToast); + const configStorage = useConfigStorage(); + + // Load initial config from localStorage on mount + const { loadConfig: loadFormConfig } = form; + const { loadConfig: loadBatchConfig } = batch; + + useEffect(() => { + if (configStorage.mounted && !initialLoadDone) { + const config = configStorage.currentConfig; + loadFormConfig({ + dsn: config.dsn, + message: config.message, + priority: config.priority, + tags: config.tags, + errorCount: config.errorCount, + errorsToGenerate: config.errorsToGenerate, + fingerprintID: config.fingerprintID, + }); + loadBatchConfig(config.batch); + // Set this after state updates are queued, so auto-save waits for next render + setInitialLoadDone(true); + } + }, [ + configStorage.mounted, + configStorage.currentConfig, + loadFormConfig, + loadBatchConfig, + initialLoadDone, + ]); + + // Auto-save when form or batch config changes (skip first render after load) + const { updateCurrentConfig } = configStorage; + const isFirstRenderAfterLoad = useRef(true); + const skipNextAutoSave = useRef(false); useEffect(() => { - setMounted(true); - }, []); + if (!configStorage.mounted || !initialLoadDone) return; + + // Skip the first render after initial load to avoid overwriting with stale state + if (isFirstRenderAfterLoad.current) { + isFirstRenderAfterLoad.current = false; + return; + } + + // Skip auto-save when loading a saved config + if (skipNextAutoSave.current) { + skipNextAutoSave.current = false; + return; + } + + const newConfig: ConfigData = { + dsn: form.dsn, + message: form.message, + priority: form.priority, + tags: form.tags, + errorCount: form.errorCount, + errorsToGenerate: form.errorsToGenerate, + fingerprintID: form.fingerprintID, + batch: { + enabled: batch.enabled, + frequency: batch.frequency, + repeatCount: batch.repeatCount, + }, + }; + updateCurrentConfig(newConfig); + }, [ + configStorage.mounted, + initialLoadDone, + updateCurrentConfig, + form.dsn, + form.message, + form.priority, + form.tags, + form.errorCount, + form.errorsToGenerate, + form.fingerprintID, + batch.enabled, + batch.frequency, + batch.repeatCount, + ]); + + const handleSaveConfig = (name: string) => { + configStorage.saveConfig(name); + showToast('Saved', `Config "${name}" saved`, 'success'); + }; + + const handleLoadConfig = (name: string) => { + const config = configStorage.savedConfigs[name]; + if (config) { + skipNextAutoSave.current = true; + configStorage.loadConfig(name); + form.loadConfig({ + dsn: config.dsn, + message: config.message, + priority: config.priority, + tags: config.tags, + errorCount: config.errorCount, + errorsToGenerate: config.errorsToGenerate, + fingerprintID: config.fingerprintID, + }); + batch.loadConfig(config.batch); + showToast('Loaded', `Config "${name}" loaded`, 'success'); + } + }; + + const handleDeleteConfig = (name: string) => { + configStorage.deleteConfig(name); + showToast('Deleted', `Config "${name}" deleted`, 'warning'); + }; const sendBatch = async () => { const response = await fetch('/api/generate-errors', { @@ -75,11 +182,14 @@ const ErrorGenerator = () => { ? 'Start Interval' : 'Generate Errors'; - if (!mounted) { + if (!configStorage.mounted) { return (
-
+
+
+
+
); } @@ -128,7 +238,16 @@ const ErrorGenerator = () => { - +
+ + +
0 && !isNaN(repeats) && repeats > 0; }; + const getConfig = () => ({ + enabled: state.enabled, + frequency: state.frequency, + repeatCount: state.repeatCount, + }); + + const loadConfig = (config: { enabled: boolean; frequency: string; repeatCount: string }) => { + setState((s) => ({ + ...s, + enabled: config.enabled, + frequency: config.frequency, + repeatCount: config.repeatCount, + })); + }; + return { ...state, setEnabled, @@ -135,5 +150,7 @@ export const useBatchMode = ( execute, stop, validate, + getConfig, + loadConfig, }; }; diff --git a/app/hooks/useConfigStorage.ts b/app/hooks/useConfigStorage.ts new file mode 100644 index 0000000..e68c13a --- /dev/null +++ b/app/hooks/useConfigStorage.ts @@ -0,0 +1,179 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; + +export interface ConfigData { + dsn: string; + message: string; + priority: 'HIGH' | 'MEDIUM' | 'LOW'; + tags: Array<{ key: string; value: string }>; + errorCount: string; + errorsToGenerate: string; + fingerprintID: string; + batch: { + enabled: boolean; + frequency: string; + repeatCount: string; + }; +} + +interface SavedConfigs { + [name: string]: ConfigData; +} + +const STORAGE_KEY_CURRENT = 'error-generator:current'; +const STORAGE_KEY_SAVED = 'error-generator:saved'; +const STORAGE_KEY_ACTIVE = 'error-generator:active'; + +const defaultConfig: ConfigData = { + dsn: '', + message: '', + priority: 'HIGH', + tags: [], + errorCount: '1', + errorsToGenerate: '1', + fingerprintID: '', + batch: { + enabled: false, + frequency: '30', + repeatCount: '5', + }, +}; + +const isValidConfig = (data: unknown): data is ConfigData => { + if (!data || typeof data !== 'object') return false; + const d = data as Record; + return ( + typeof d.dsn === 'string' && + typeof d.message === 'string' && + ['HIGH', 'MEDIUM', 'LOW'].includes(d.priority as string) && + Array.isArray(d.tags) && + typeof d.errorCount === 'string' && + typeof d.errorsToGenerate === 'string' && + typeof d.fingerprintID === 'string' && + d.batch !== null && + typeof d.batch === 'object' + ); +}; + +export const useConfigStorage = () => { + const [mounted, setMounted] = useState(false); + const [currentConfig, setCurrentConfig] = useState(defaultConfig); + const [savedConfigs, setSavedConfigs] = useState({}); + const [activeConfigName, setActiveConfigName] = useState(null); + const saveTimeoutRef = useRef(null); + + useEffect(() => { + setMounted(true); + try { + const storedCurrent = localStorage.getItem(STORAGE_KEY_CURRENT); + if (storedCurrent) { + const parsed = JSON.parse(storedCurrent); + if (isValidConfig(parsed)) { + setCurrentConfig(parsed); + } + } + const storedSaved = localStorage.getItem(STORAGE_KEY_SAVED); + if (storedSaved) { + const parsed = JSON.parse(storedSaved); + if (parsed && typeof parsed === 'object') { + setSavedConfigs(parsed); + } + } + const storedActive = localStorage.getItem(STORAGE_KEY_ACTIVE); + if (storedActive) { + setActiveConfigName(storedActive); + } + } catch { + // Invalid localStorage data, use defaults + } + }, []); + + const persistCurrent = useCallback((config: ConfigData) => { + if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); + saveTimeoutRef.current = setTimeout(() => { + try { + localStorage.setItem(STORAGE_KEY_CURRENT, JSON.stringify(config)); + } catch { + // localStorage full or unavailable + } + }, 300); + }, []); + + const updateCurrentConfig = useCallback( + (config: ConfigData) => { + setCurrentConfig(config); + setActiveConfigName(null); + if (mounted) { + persistCurrent(config); + try { + localStorage.removeItem(STORAGE_KEY_ACTIVE); + } catch { + // localStorage unavailable + } + } + }, + [mounted, persistCurrent] + ); + + const saveConfig = useCallback( + (name: string) => { + const newSaved = { ...savedConfigs, [name]: currentConfig }; + setSavedConfigs(newSaved); + setActiveConfigName(name); + try { + localStorage.setItem(STORAGE_KEY_SAVED, JSON.stringify(newSaved)); + localStorage.setItem(STORAGE_KEY_ACTIVE, name); + } catch { + // localStorage full or unavailable + } + }, + [savedConfigs, currentConfig] + ); + + const loadConfig = useCallback( + (name: string) => { + const config = savedConfigs[name]; + if (config) { + setCurrentConfig(config); + setActiveConfigName(name); + persistCurrent(config); + try { + localStorage.setItem(STORAGE_KEY_ACTIVE, name); + } catch { + // localStorage full or unavailable + } + } + }, + [savedConfigs, persistCurrent] + ); + + const deleteConfig = useCallback( + (name: string) => { + const { [name]: _deleted, ...rest } = savedConfigs; + void _deleted; + setSavedConfigs(rest); + const clearActive = activeConfigName === name; + if (clearActive) setActiveConfigName(null); + try { + localStorage.setItem(STORAGE_KEY_SAVED, JSON.stringify(rest)); + if (clearActive) localStorage.removeItem(STORAGE_KEY_ACTIVE); + } catch { + // localStorage full or unavailable + } + }, + [savedConfigs, activeConfigName] + ); + + const getConfigNames = useCallback(() => Object.keys(savedConfigs), [savedConfigs]); + + return { + mounted, + currentConfig, + savedConfigs, + activeConfigName, + updateCurrentConfig, + saveConfig, + loadConfig, + deleteConfig, + getConfigNames, + }; +}; diff --git a/app/hooks/useErrorForm.ts b/app/hooks/useErrorForm.ts index ded8e85..d43e5b4 100644 --- a/app/hooks/useErrorForm.ts +++ b/app/hooks/useErrorForm.ts @@ -124,6 +124,38 @@ export const useErrorForm = ( }, }); + const getConfig = () => ({ + dsn: form.dsn, + message: form.message, + priority: form.priority, + tags: form.tags, + errorCount: form.errorCount, + errorsToGenerate: form.errorsToGenerate, + fingerprintID: form.fingerprintID, + }); + + const loadConfig = (config: { + dsn: string; + message: string; + priority: Priority; + tags: CustomTag[]; + errorCount: string; + errorsToGenerate: string; + fingerprintID: string; + }) => { + setForm((f) => ({ + ...f, + dsn: config.dsn, + message: config.message, + priority: config.priority, + tags: config.tags, + errorCount: config.errorCount, + errorsToGenerate: config.errorsToGenerate, + fingerprintID: config.fingerprintID, + dsnError: '', + })); + }; + return { ...form, newTagKey, @@ -137,5 +169,7 @@ export const useErrorForm = ( validate, getPayload, getPreviewPayload, + getConfig, + loadConfig, }; }; From c47325dd675187e06ba03281338a95329b62bee2 Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Fri, 9 Jan 2026 18:21:38 -0500 Subject: [PATCH 2/4] style: Update input backgrounds to purple tint Change input-brutal and select-brutal background color from brutal-black to bg-panel for a softer, more cohesive look with the overall purple theme. Co-Authored-By: Claude --- app/styles/globals.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/styles/globals.css b/app/styles/globals.css index 2ebc151..3bf96c6 100644 --- a/app/styles/globals.css +++ b/app/styles/globals.css @@ -111,7 +111,7 @@ body { } .input-brutal { - background-color: var(--color-brutal-black); + background-color: var(--color-bg-panel); border: 3px solid var(--color-brutal-white); color: var(--color-brutal-white); transition: @@ -135,7 +135,7 @@ body { } .select-brutal { - background-color: var(--color-brutal-black); + background-color: var(--color-bg-panel); border: 3px solid var(--color-brutal-white); color: var(--color-brutal-white); transition: From 267c347c610cba7254b33fe7665dfcc4e784a075 Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Fri, 9 Jan 2026 18:21:44 -0500 Subject: [PATCH 3/4] feat(ui): Add animations to batch mode controls Add staggered slide-in animations when batch mode is enabled. Set minimum height on container to prevent layout shift when toggling the controls. Co-Authored-By: Claude --- app/components/form/BatchModePanel.tsx | 88 ++++++++++++++++++-------- 1 file changed, 60 insertions(+), 28 deletions(-) diff --git a/app/components/form/BatchModePanel.tsx b/app/components/form/BatchModePanel.tsx index 7305bbf..600f2ee 100644 --- a/app/components/form/BatchModePanel.tsx +++ b/app/components/form/BatchModePanel.tsx @@ -1,6 +1,6 @@ 'use client'; -import { motion } from 'framer-motion'; +import { motion, AnimatePresence } from 'framer-motion'; import { fadeInUp } from '@/app/styles/animations'; import { BatchMode } from '@/app/hooks/useBatchMode'; @@ -8,12 +8,30 @@ interface BatchModePanelProps { batch: BatchMode; } +const batchControlsVariants = { + hidden: { opacity: 0, x: 20 }, + visible: { + opacity: 1, + x: 0, + transition: { + staggerChildren: 0.05, + }, + }, + exit: { opacity: 0, x: 20, transition: { duration: 0.15 } }, +}; + +const batchItemVariants = { + hidden: { opacity: 0, x: 10 }, + visible: { opacity: 1, x: 0 }, + exit: { opacity: 0, x: 10 }, +}; + export const BatchModePanel = ({ batch }: BatchModePanelProps) => { const progress = batch.totalRepeats > 0 ? (batch.currentRepeat / batch.totalRepeats) * 100 : 0; return ( - -
+ +
Batch Mode
- {batch.enabled && ( -
-
- Every - batch.setFrequency(e.target.value)} - className="input-brutal w-16 px-2 py-1 text-sm text-center" - /> - s -
-
- x - batch.setRepeatCount(e.target.value)} - className="input-brutal w-16 px-2 py-1 text-sm text-center" - /> -
-
- )} + + {batch.enabled && ( + + + every + batch.setFrequency(e.target.value)} + className="input-brutal w-16 px-2 py-1 text-sm text-center" + /> + sec, + + + batch.setRepeatCount(e.target.value)} + className="input-brutal w-16 px-2 py-1 text-sm text-center" + /> + times + + + )} +
{batch.isRunning && (
From bef607e2124993e2c4dffd2fe94cbddd0ec9ade9 Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Fri, 9 Jan 2026 18:42:43 -0500 Subject: [PATCH 4/4] fix: Address PR review feedback - Move animation variants to animations.ts (slideInRight, slideInRightItem) - Inline handleKeyDown in ConfigPanel - Extract localStorage helpers (safeGetItem, safeSetItem, safeRemoveItem) Co-Authored-By: Claude --- app/components/ConfigPanel.tsx | 10 +-- app/components/form/BatchModePanel.tsx | 26 ++------ app/hooks/useConfigStorage.ts | 90 +++++++++++++------------- app/styles/animations.ts | 16 +++++ 4 files changed, 67 insertions(+), 75 deletions(-) diff --git a/app/components/ConfigPanel.tsx b/app/components/ConfigPanel.tsx index 11b2bb1..249e816 100644 --- a/app/components/ConfigPanel.tsx +++ b/app/components/ConfigPanel.tsx @@ -30,14 +30,6 @@ export const ConfigPanel = ({ setIsNaming(false); }; - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') handleSave(); - if (e.key === 'Escape') { - setIsNaming(false); - setNewName(''); - } - }; - return ( setNewName(e.target.value)} - onKeyDown={handleKeyDown} + onKeyDown={(e) => e.key === 'Enter' && handleSave()} placeholder="Config name" className="input-brutal px-2 py-1 text-xs w-32" autoFocus diff --git a/app/components/form/BatchModePanel.tsx b/app/components/form/BatchModePanel.tsx index 600f2ee..5c499af 100644 --- a/app/components/form/BatchModePanel.tsx +++ b/app/components/form/BatchModePanel.tsx @@ -1,31 +1,13 @@ 'use client'; import { motion, AnimatePresence } from 'framer-motion'; -import { fadeInUp } from '@/app/styles/animations'; +import { fadeInUp, slideInRight, slideInRightItem } from '@/app/styles/animations'; import { BatchMode } from '@/app/hooks/useBatchMode'; interface BatchModePanelProps { batch: BatchMode; } -const batchControlsVariants = { - hidden: { opacity: 0, x: 20 }, - visible: { - opacity: 1, - x: 0, - transition: { - staggerChildren: 0.05, - }, - }, - exit: { opacity: 0, x: 20, transition: { duration: 0.15 } }, -}; - -const batchItemVariants = { - hidden: { opacity: 0, x: 10 }, - visible: { opacity: 1, x: 0 }, - exit: { opacity: 0, x: 10 }, -}; - export const BatchModePanel = ({ batch }: BatchModePanelProps) => { const progress = batch.totalRepeats > 0 ? (batch.currentRepeat / batch.totalRepeats) * 100 : 0; @@ -46,13 +28,13 @@ export const BatchModePanel = ({ batch }: BatchModePanelProps) => { {batch.enabled && ( every @@ -66,7 +48,7 @@ export const BatchModePanel = ({ batch }: BatchModePanelProps) => { sec, { + try { + return localStorage.getItem(key); + } catch { + return null; + } +}; + +const safeSetItem = (key: string, value: string): void => { + try { + localStorage.setItem(key, value); + } catch { + // localStorage full or unavailable + } +}; + +const safeRemoveItem = (key: string): void => { + try { + localStorage.removeItem(key); + } catch { + // localStorage unavailable + } +}; + const defaultConfig: ConfigData = { dsn: '', message: '', @@ -63,38 +87,32 @@ export const useConfigStorage = () => { useEffect(() => { setMounted(true); - try { - const storedCurrent = localStorage.getItem(STORAGE_KEY_CURRENT); - if (storedCurrent) { + const storedCurrent = safeGetItem(STORAGE_KEY_CURRENT); + if (storedCurrent) { + try { const parsed = JSON.parse(storedCurrent); - if (isValidConfig(parsed)) { - setCurrentConfig(parsed); - } + if (isValidConfig(parsed)) setCurrentConfig(parsed); + } catch { + // Invalid JSON } - const storedSaved = localStorage.getItem(STORAGE_KEY_SAVED); - if (storedSaved) { + } + const storedSaved = safeGetItem(STORAGE_KEY_SAVED); + if (storedSaved) { + try { const parsed = JSON.parse(storedSaved); - if (parsed && typeof parsed === 'object') { - setSavedConfigs(parsed); - } - } - const storedActive = localStorage.getItem(STORAGE_KEY_ACTIVE); - if (storedActive) { - setActiveConfigName(storedActive); + if (parsed && typeof parsed === 'object') setSavedConfigs(parsed); + } catch { + // Invalid JSON } - } catch { - // Invalid localStorage data, use defaults } + const storedActive = safeGetItem(STORAGE_KEY_ACTIVE); + if (storedActive) setActiveConfigName(storedActive); }, []); const persistCurrent = useCallback((config: ConfigData) => { if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); saveTimeoutRef.current = setTimeout(() => { - try { - localStorage.setItem(STORAGE_KEY_CURRENT, JSON.stringify(config)); - } catch { - // localStorage full or unavailable - } + safeSetItem(STORAGE_KEY_CURRENT, JSON.stringify(config)); }, 300); }, []); @@ -104,11 +122,7 @@ export const useConfigStorage = () => { setActiveConfigName(null); if (mounted) { persistCurrent(config); - try { - localStorage.removeItem(STORAGE_KEY_ACTIVE); - } catch { - // localStorage unavailable - } + safeRemoveItem(STORAGE_KEY_ACTIVE); } }, [mounted, persistCurrent] @@ -119,12 +133,8 @@ export const useConfigStorage = () => { const newSaved = { ...savedConfigs, [name]: currentConfig }; setSavedConfigs(newSaved); setActiveConfigName(name); - try { - localStorage.setItem(STORAGE_KEY_SAVED, JSON.stringify(newSaved)); - localStorage.setItem(STORAGE_KEY_ACTIVE, name); - } catch { - // localStorage full or unavailable - } + safeSetItem(STORAGE_KEY_SAVED, JSON.stringify(newSaved)); + safeSetItem(STORAGE_KEY_ACTIVE, name); }, [savedConfigs, currentConfig] ); @@ -136,11 +146,7 @@ export const useConfigStorage = () => { setCurrentConfig(config); setActiveConfigName(name); persistCurrent(config); - try { - localStorage.setItem(STORAGE_KEY_ACTIVE, name); - } catch { - // localStorage full or unavailable - } + safeSetItem(STORAGE_KEY_ACTIVE, name); } }, [savedConfigs, persistCurrent] @@ -153,12 +159,8 @@ export const useConfigStorage = () => { setSavedConfigs(rest); const clearActive = activeConfigName === name; if (clearActive) setActiveConfigName(null); - try { - localStorage.setItem(STORAGE_KEY_SAVED, JSON.stringify(rest)); - if (clearActive) localStorage.removeItem(STORAGE_KEY_ACTIVE); - } catch { - // localStorage full or unavailable - } + safeSetItem(STORAGE_KEY_SAVED, JSON.stringify(rest)); + if (clearActive) safeRemoveItem(STORAGE_KEY_ACTIVE); }, [savedConfigs, activeConfigName] ); diff --git a/app/styles/animations.ts b/app/styles/animations.ts index c1818f8..0f67d47 100644 --- a/app/styles/animations.ts +++ b/app/styles/animations.ts @@ -76,3 +76,19 @@ export const shake: Variants = { transition: { duration: 0.4 }, }, }; + +export const slideInRight: Variants = { + hidden: { opacity: 0, x: 20 }, + visible: { + opacity: 1, + x: 0, + transition: { staggerChildren: 0.05 }, + }, + exit: { opacity: 0, x: 20, transition: { duration: 0.15 } }, +}; + +export const slideInRightItem: Variants = { + hidden: { opacity: 0, x: 10 }, + visible: { opacity: 1, x: 0 }, + exit: { opacity: 0, x: 10 }, +};