diff --git a/app/components/ConfigPanel.tsx b/app/components/ConfigPanel.tsx new file mode 100644 index 0000000..249e816 --- /dev/null +++ b/app/components/ConfigPanel.tsx @@ -0,0 +1,117 @@ +'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); + }; + + return ( + +
+

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

+
+ {isNaming ? ( +
+ setNewName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSave()} + 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 = () => { - +
+ + +
{ 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 && (
diff --git a/app/hooks/useBatchMode.ts b/app/hooks/useBatchMode.ts index df9c3ef..9a43794 100644 --- a/app/hooks/useBatchMode.ts +++ b/app/hooks/useBatchMode.ts @@ -126,6 +126,21 @@ export const useBatchMode = ( return !isNaN(frequency) && frequency > 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..0897e83 --- /dev/null +++ b/app/hooks/useConfigStorage.ts @@ -0,0 +1,181 @@ +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 safeGetItem = (key: string): string | null => { + 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: '', + 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); + const storedCurrent = safeGetItem(STORAGE_KEY_CURRENT); + if (storedCurrent) { + try { + const parsed = JSON.parse(storedCurrent); + if (isValidConfig(parsed)) setCurrentConfig(parsed); + } catch { + // Invalid JSON + } + } + const storedSaved = safeGetItem(STORAGE_KEY_SAVED); + if (storedSaved) { + try { + const parsed = JSON.parse(storedSaved); + if (parsed && typeof parsed === 'object') setSavedConfigs(parsed); + } catch { + // Invalid JSON + } + } + const storedActive = safeGetItem(STORAGE_KEY_ACTIVE); + if (storedActive) setActiveConfigName(storedActive); + }, []); + + const persistCurrent = useCallback((config: ConfigData) => { + if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); + saveTimeoutRef.current = setTimeout(() => { + safeSetItem(STORAGE_KEY_CURRENT, JSON.stringify(config)); + }, 300); + }, []); + + const updateCurrentConfig = useCallback( + (config: ConfigData) => { + setCurrentConfig(config); + setActiveConfigName(null); + if (mounted) { + persistCurrent(config); + safeRemoveItem(STORAGE_KEY_ACTIVE); + } + }, + [mounted, persistCurrent] + ); + + const saveConfig = useCallback( + (name: string) => { + const newSaved = { ...savedConfigs, [name]: currentConfig }; + setSavedConfigs(newSaved); + setActiveConfigName(name); + safeSetItem(STORAGE_KEY_SAVED, JSON.stringify(newSaved)); + safeSetItem(STORAGE_KEY_ACTIVE, name); + }, + [savedConfigs, currentConfig] + ); + + const loadConfig = useCallback( + (name: string) => { + const config = savedConfigs[name]; + if (config) { + setCurrentConfig(config); + setActiveConfigName(name); + persistCurrent(config); + safeSetItem(STORAGE_KEY_ACTIVE, name); + } + }, + [savedConfigs, persistCurrent] + ); + + const deleteConfig = useCallback( + (name: string) => { + const { [name]: _deleted, ...rest } = savedConfigs; + void _deleted; + setSavedConfigs(rest); + const clearActive = activeConfigName === name; + if (clearActive) setActiveConfigName(null); + safeSetItem(STORAGE_KEY_SAVED, JSON.stringify(rest)); + if (clearActive) safeRemoveItem(STORAGE_KEY_ACTIVE); + }, + [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, }; }; 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 }, +}; 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: