Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions app/components/ConfigPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<motion.div
variants={fadeInUp}
initial="initial"
animate="animate"
className="border-3 border-hero-violet bg-bg-panel p-4 mb-4"
>
<div className="flex items-center justify-between">
<h3
className={`font-black uppercase tracking-wider text-xl ${activeConfigName ? 'text-hero-lavender' : 'text-hero-coral'}`}
>
{activeConfigName ? `Config: ${activeConfigName}` : 'Unsaved Config'}
</h3>
<div className="flex gap-2">
{isNaming ? (
<div className="flex gap-2">
<input
type="text"
value={newName}
onChange={(e) => 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
/>
<button onClick={handleSave} className="btn-purple px-2 py-1 text-xs">
Save
</button>
<button
onClick={() => {
setIsNaming(false);
setNewName('');
}}
className="btn-outline px-2 py-1 text-xs"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setIsNaming(true)}
className="btn-purple px-2 py-1 text-xs flex items-center gap-1"
>
<FaFloppyDisk /> Save As
</button>
)}
</div>
</div>

{savedConfigNames.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3 pt-3 border-t-2 border-dashed border-hero-violet">
<AnimatePresence mode="popLayout">
{savedConfigNames.map((name) => (
<motion.span
key={name}
layout
variants={tagPop}
initial="initial"
animate="animate"
exit="exit"
onClick={() => onLoad(name)}
className={`tag-brutal cursor-pointer ${
activeConfigName === name
? 'bg-hero-purple text-brutal-black'
: ''
}`}
>
{name}
<button
onClick={(e) => {
e.stopPropagation();
onDelete(name);
}}
className="hover:text-hero-sunset"
>
<FaXmark />
</button>
</motion.span>
))}
</AnimatePresence>
</div>
)}
</motion.div>
);
};
133 changes: 126 additions & 7 deletions app/components/ErrorGenerator.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,140 @@
'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';
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<ToastData[]>([]);
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', {
Expand Down Expand Up @@ -75,11 +182,14 @@ const ErrorGenerator = () => {
? 'Start Interval'
: 'Generate Errors';

if (!mounted) {
if (!configStorage.mounted) {
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 opacity-0">
<div className="flex flex-col gap-4" />
<div className="border-3 border-hero-violet bg-bg-panel p-5" />
<div className="flex flex-col gap-4">
<div className="border-3 border-hero-violet bg-bg-panel p-4" />
<div className="border-3 border-hero-violet bg-bg-panel p-5 flex-1" />
</div>
</div>
);
}
Expand Down Expand Up @@ -128,7 +238,16 @@ const ErrorGenerator = () => {
</motion.div>
</motion.div>

<ErrorPreview payload={form.getPreviewPayload()} />
<div className="flex flex-col">
<ConfigPanel
activeConfigName={configStorage.activeConfigName}
savedConfigNames={configStorage.getConfigNames()}
onSave={handleSaveConfig}
onLoad={handleLoadConfig}
onDelete={handleDeleteConfig}
/>
<ErrorPreview payload={form.getPreviewPayload()} />
</div>
</div>

<ConfirmModal
Expand Down
72 changes: 43 additions & 29 deletions app/components/form/BatchModePanel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { motion } from 'framer-motion';
import { fadeInUp } from '@/app/styles/animations';
import { motion, AnimatePresence } from 'framer-motion';
import { fadeInUp, slideInRight, slideInRightItem } from '@/app/styles/animations';
import { BatchMode } from '@/app/hooks/useBatchMode';

interface BatchModePanelProps {
Expand All @@ -12,8 +12,8 @@ export const BatchModePanel = ({ batch }: BatchModePanelProps) => {
const progress = batch.totalRepeats > 0 ? (batch.currentRepeat / batch.totalRepeats) * 100 : 0;

return (
<motion.div variants={fadeInUp} className="border-2 border-hero-violet p-3">
<div className="flex items-center justify-between">
<motion.div variants={fadeInUp} className="border-3 border-hero-violet p-3">
<div className="flex items-center justify-between min-h-[2.25rem]">
<div className="flex items-center gap-2">
<button
onClick={() => batch.setEnabled(!batch.enabled)}
Expand All @@ -24,31 +24,45 @@ export const BatchModePanel = ({ batch }: BatchModePanelProps) => {
</button>
<span className="label-brutal !mb-0">Batch Mode</span>
</div>
{batch.enabled && (
<div className="flex gap-3">
<div className="flex items-center gap-2">
<span className="text-sm text-brutal-white/70">Every</span>
<input
type="number"
min={1}
value={batch.frequency}
onChange={(e) => batch.setFrequency(e.target.value)}
className="input-brutal w-16 px-2 py-1 text-sm text-center"
/>
<span className="text-sm text-brutal-white/70">s</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-brutal-white/70">x</span>
<input
type="number"
min={1}
value={batch.repeatCount}
onChange={(e) => batch.setRepeatCount(e.target.value)}
className="input-brutal w-16 px-2 py-1 text-sm text-center"
/>
</div>
</div>
)}
<AnimatePresence mode="wait">
{batch.enabled && (
<motion.div
className="flex gap-3"
variants={slideInRight}
initial="hidden"
animate="visible"
exit="exit"
>
<motion.div
variants={slideInRightItem}
className="flex items-center gap-2"
>
<span className="text-sm text-brutal-white/70">every</span>
<input
type="number"
min={1}
value={batch.frequency}
onChange={(e) => batch.setFrequency(e.target.value)}
className="input-brutal w-16 px-2 py-1 text-sm text-center"
/>
<span className="text-sm text-brutal-white/70">sec,</span>
</motion.div>
<motion.div
variants={slideInRightItem}
className="flex items-center gap-2"
>
<input
type="number"
min={1}
value={batch.repeatCount}
onChange={(e) => batch.setRepeatCount(e.target.value)}
className="input-brutal w-16 px-2 py-1 text-sm text-center"
/>
<span className="text-sm text-brutal-white/70">times</span>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
{batch.isRunning && (
<div className="mt-3">
Expand Down
Loading