import React, { useState, useCallback, useEffect } from 'react'; import { useAuth } from './services/authService'; import { CsvInputRow, QueueItem, ResultRow, JobHistoryItem, ProcessedResult, BatchError } from './types'; import FileUpload from './components/FileUpload'; import ResultsTable from './components/ResultsTable'; import SignInPage from './components/SignInPage'; import { runWorkflow } from './services/difyService'; import { CheckCircleIcon, XCircleIcon, ClockIcon, UploadIcon, RocketLaunchIcon, PlusIcon, HistoryIcon, DocumentTextIcon, TrashIcon } from './components/Icons'; import Spinner from './components/Spinner'; // --- UTILS & HOOKS (in-file for simplicity) --- const generateUUID = (): string => { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = (Math.random() * 16) | 0; const v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); }; const delay = (ms: number) => new Promise(res => setTimeout(res, ms)); // Calculate approximate storage size for job results const estimateStorageSize = (results: ResultRow[]): number => { return JSON.stringify(results).length; }; // Maximum storage size per job (500KB) const MAX_JOB_STORAGE_SIZE = 500 * 1024; // Enhanced localStorage hook with better error handling and data validation function useLocalStorage(key: string, initialValue: T): [T, React.Dispatch>] { const [storedValue, setStoredValue] = useState(() => { try { const item = window.localStorage.getItem(key); if (item) { const parsed = JSON.parse(item); // Enhanced validation for different data types if (Array.isArray(parsed)) { // Filter out old items that had the 'results' property which caused storage errors. let cleanHistory = parsed.filter(p => typeof p === 'object' && p !== null && !p.hasOwnProperty('results')); // Remove duplicates based on ID if it's a history array if (key === 'ace-copywriting-history' && cleanHistory.length > 0) { const uniqueHistory = cleanHistory.reduce((acc: any[], current: any) => { const existingIndex = acc.findIndex(item => item.id === current.id); if (existingIndex === -1) { acc.push(current); } return acc; }, []); cleanHistory = uniqueHistory; } return cleanHistory; } return parsed; } return initialValue; } catch (error) { console.error('Error reading from localStorage, resetting to initial value.', error); window.localStorage.removeItem(key); // Clear corrupted item return initialValue; } }); const setValue: React.Dispatch> = useCallback((value) => { try { setStoredValue(prev => { const valueToStore = value instanceof Function ? value(prev) : value; // Check storage quota before saving const serialized = JSON.stringify(valueToStore); if (serialized.length > 5 * 1024 * 1024) { // 5MB limit console.warn('Data too large for localStorage, truncating...'); // For history, keep only the most recent items if (Array.isArray(valueToStore) && key === 'ace-copywriting-history') { const truncated = (valueToStore as any[]).slice(0, 5); // Keep only 5 most recent window.localStorage.setItem(key, JSON.stringify(truncated)); return truncated as T; } } window.localStorage.setItem(key, serialized); return valueToStore; }); } catch (error) { console.error('Error writing to localStorage', error); // If quota exceeded, try to clear old data and retry if (error instanceof Error && error.name === 'QuotaExceededError') { try { // Clear old history items to make space if (key === 'ace-copywriting-history') { const currentHistory = window.localStorage.getItem(key); if (currentHistory) { const parsed = JSON.parse(currentHistory); if (Array.isArray(parsed) && parsed.length > 3) { const truncated = parsed.slice(0, 3); // Keep only 3 most recent window.localStorage.setItem(key, JSON.stringify(truncated)); setStoredValue(truncated as T); } } } } catch (clearError) { console.error('Failed to clear storage for new data', clearError); } } } }, [key]); return [storedValue, setValue]; } // Hook for user preferences function useUserPreferences() { const [preferences, setPreferences] = useLocalStorage('ace-copywriting-preferences', { autoSave: true, maxHistoryItems: 10, showDebugInfo: false, defaultExportFormat: 'csv' as 'csv' | 'pdf' | 'docx' | 'json', theme: 'dark' as 'dark' | 'light' }); return [preferences, setPreferences] as const; } // Hook for session data (cleared on browser close) function useSessionStorage(key: string, initialValue: T): [T, React.Dispatch>] { const [storedValue, setStoredValue] = useState(() => { try { const item = window.sessionStorage.getItem(key); return item ? JSON.parse(item) : initialValue; } catch (error) { console.error('Error reading from sessionStorage', error); return initialValue; } }); const setValue: React.Dispatch> = useCallback((value) => { try { setStoredValue(prev => { const valueToStore = value instanceof Function ? value(prev) : value; window.sessionStorage.setItem(key, JSON.stringify(valueToStore)); return valueToStore; }); } catch (error) { console.error('Error writing to sessionStorage', error); } }, [key]); return [storedValue, setValue]; } // --- TYPES --- type ProcessingStatus = 'idle' | 'processing' | 'completed' | 'error'; type ViewState = | { type: 'welcome' } | { type: 'new_job' } | { type: 'view_history'; jobId: string }; // --- CONSTANTS --- const MAX_HISTORY_ITEMS = 10; // --- MAIN COMPONENT --- const App: React.FC = () => { const { user, logout } = useAuth(); const [history, setHistory] = useLocalStorage('ace-copywriting-history', []); const [preferences, setPreferences] = useUserPreferences(); const [view, setView] = useState(() => history.length > 0 ? { type: 'new_job' } : { type: 'welcome' }); // State for the currently active processing job - persist in sessionStorage const [jobId, setJobId] = useSessionStorage('ace-current-job-id', null); const [filename, setFilename] = useSessionStorage('ace-current-filename', ''); const [queue, setQueue] = useSessionStorage('ace-current-queue', []); const [results, setResults] = useSessionStorage('ace-current-results', []); const [status, setStatus] = useSessionStorage('ace-current-status', 'idle'); const [error, setError] = useSessionStorage('ace-current-error', null); const [deleteConfirmId, setDeleteConfirmId] = useState(null); // New: capture all user-facing errors and show a details modal - persist in sessionStorage const [errors, setErrors] = useSessionStorage('ace-current-errors', []); const [showErrorDetails, setShowErrorDetails] = useState(false); const [expandedDebugIndex, setExpandedDebugIndex] = useState(null); // Auto-save current job state when it changes useEffect(() => { if (preferences.autoSave && jobId) { const jobState = { jobId, filename, queue, results, status, error, errors, timestamp: new Date().toISOString() }; try { window.sessionStorage.setItem('ace-current-job-state', JSON.stringify(jobState)); } catch (error) { console.warn('Failed to auto-save job state:', error); } } }, [preferences.autoSave, jobId, filename, queue, results, status, error, errors]); // Restore job state on app load useEffect(() => { try { const savedState = window.sessionStorage.getItem('ace-current-job-state'); if (savedState) { const jobState = JSON.parse(savedState); const stateAge = Date.now() - new Date(jobState.timestamp).getTime(); const maxAge = 24 * 60 * 60 * 1000; // 24 hours // Only restore if state is recent if (stateAge < maxAge) { setJobId(jobState.jobId); setFilename(jobState.filename); setQueue(jobState.queue || []); setResults(jobState.results || []); setStatus(jobState.status || 'idle'); setError(jobState.error); setErrors(jobState.errors || []); // Set appropriate view based on restored state if (jobState.status === 'completed' && jobState.results?.length > 0) { setView({ type: 'new_job' }); // Show results view } else if (jobState.status === 'processing') { setView({ type: 'new_job' }); // Show processing view } } else { // Clear old state window.sessionStorage.removeItem('ace-current-job-state'); } } } catch (error) { console.warn('Failed to restore job state:', error); // Clear corrupted state window.sessionStorage.removeItem('ace-current-job-state'); } }, []); const resetNewJobState = useCallback(() => { setJobId(generateUUID()); setFilename(''); setQueue([]); setResults([]); setStatus('idle'); setError(null); setErrors([]); setShowErrorDetails(false); // Clear session storage for new job window.sessionStorage.removeItem('ace-current-job-state'); }, [setJobId, setFilename, setQueue, setResults, setStatus, setError, setErrors]); const handleStartNewJob = () => { resetNewJobState(); setView({ type: 'new_job' }); }; const handleDeleteJob = (jobId: string) => { setHistory(prevHistory => prevHistory.filter(h => h.id !== jobId)); setDeleteConfirmId(null); // If we're currently viewing the deleted job, navigate back to new job if (view.type === 'view_history' && view.jobId === jobId) { setView({ type: 'new_job' }); } }; // Enhanced file parsing with better error handling const handleFileParsed = useCallback((data: CsvInputRow[], file: File) => { // Ensure we have a jobId for this processing session if (!jobId) { setJobId(generateUUID()); } setFilename(file.name); const initialQueue = data.map((row, index) => ({ id: index, data: row, status: 'pending' as const, })); setQueue(initialQueue); setError(null); setErrors([]); // Save file info to history for reference const fileInfo = { filename: file.name, size: file.size, lastModified: file.lastModified, rowCount: data.length }; try { const fileHistory = JSON.parse(localStorage.getItem('ace-file-history') || '[]'); const updatedHistory = [fileInfo, ...fileHistory.slice(0, 9)]; // Keep last 10 files localStorage.setItem('ace-file-history', JSON.stringify(updatedHistory)); } catch (error) { console.warn('Failed to save file history:', error); } }, [jobId, setJobId, setFilename, setQueue, setError, setErrors]); const handleStartProcessing = async () => { console.log('Start processing clicked. JobId:', jobId, 'Queue length:', queue.length); if (!jobId || queue.length === 0) { console.log('Processing aborted - missing jobId or empty queue'); return; } console.log('Starting processing...'); setStatus('processing'); setError(null); setErrors([]); setResults([]); // Clear previous results const newResults: ResultRow[] = []; for (const [index, item] of queue.entries()) { setQueue(prev => prev.map(q => q.id === item.id ? { ...q, status: 'processing' } : q)); try { const processedData: ProcessedResult = await runWorkflow(item.data); const resultRow: ResultRow = { ...item.data, ...processedData, id: item.id }; newResults.push(resultRow); setResults(prev => [...prev, resultRow]); // Add one result at a time setQueue(prev => prev.map(q => q.id === item.id ? { ...q, status: 'completed' } : q)); } catch (e) { const msg = e instanceof Error ? e.message : 'An unknown error occurred'; setQueue(prev => prev.map(q => q.id === item.id ? { ...q, status: 'failed', error: msg } : q)); setError('Some rows failed during processing.'); setErrors(prev => ([ ...prev, { row: item.id + 1, code: e instanceof Error ? (e as any).code || 'UNKNOWN' : 'UNKNOWN', message: msg, at: (e as any).at || 'unknown', suggestion: 'Retry later or check input for this row.' } ])); } // Add a delay between requests to avoid overwhelming the API. if (index < queue.length - 1) { await delay(500); // 0.5 second pause } } setStatus('completed'); }; // Effect to save job summary to history when completed useEffect(() => { console.log('History save effect triggered:', { status, jobId, queueLength: queue.length, resultsLength: results.length }); if (status === 'completed' && jobId) { console.log('Saving job to history:', { jobId, filename, queueLength: queue.length, resultsLength: results.length }); const completedCount = queue.filter(q => q.status === 'completed').length; const failedCount = queue.filter(q => q.status === 'failed').length; setHistory(prevHistory => { // Check if this job is already in history to prevent duplicates const existingIndex = prevHistory.findIndex(h => h.id === jobId); if (existingIndex !== -1) { return prevHistory; // Already exists, don't add again } // Check if results are too large for storage const storageSize = estimateStorageSize(results); const shouldStoreResults = storageSize <= MAX_JOB_STORAGE_SIZE; if (!shouldStoreResults) { console.warn(`Job results too large for storage (${Math.round(storageSize / 1024)}KB). Only storing summary.`); } const newHistoryItem: JobHistoryItem = { id: jobId, filename, date: new Date().toISOString(), totalRows: queue.length, completedCount, failedCount, results: shouldStoreResults ? [...results] : undefined, // Store results if not too large }; const updatedHistory = [newHistoryItem, ...prevHistory]; // Prune history to the maximum allowed size from preferences const maxItems = preferences.maxHistoryItems || 10; const prunedHistory = updatedHistory.slice(0, maxItems); console.log('Job saved to history successfully:', newHistoryItem); return prunedHistory; }); // Clear session storage after successful save window.sessionStorage.removeItem('ace-current-job-state'); } }, [status, jobId, filename, queue.length, results, setHistory, preferences.maxHistoryItems]); // Cleanup old history items periodically useEffect(() => { const cleanupHistory = () => { setHistory(prevHistory => { const maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days const now = Date.now(); const filtered = prevHistory.filter(item => { const itemAge = now - new Date(item.date).getTime(); return itemAge < maxAge; }); if (filtered.length !== prevHistory.length) { console.log(`Cleaned up ${prevHistory.length - filtered.length} old history items`); } return filtered; }); }; // Clean up on app start cleanupHistory(); // Set up periodic cleanup (every hour) const interval = setInterval(cleanupHistory, 60 * 60 * 1000); return () => clearInterval(interval); }, [setHistory]); // --- RENDER --- const renderStatusIcon = (itemStatus: QueueItem['status']) => { switch (itemStatus) { case 'pending': return ; case 'processing': return ; case 'completed': return ; case 'failed': return ; } }; const getActiveJobId = () => { if (view.type === 'new_job' && jobId) return jobId; if (view.type === 'view_history') return view.jobId; return null; } const ErrorDetailsModal: React.FC<{ onClose: () => void; items: BatchError[] }> = ({ onClose, items }) => (
e.stopPropagation()}>

Processing Errors

{items.length === 0 ? (

No errors recorded.

) : (
{items.map((e, idx) => (
{e.row ? `Row ${e.row}` : 'General'} — {e.code}
{e.message}
{e.suggestion &&
Suggestion: {e.suggestion}
} {e.at &&
Area: {e.at}
}
{e.debug && (
{expandedDebugIndex === idx && (
{e.debug}
)}
)}
))}
)}
); const renderMainContent = () => { if (view.type === 'welcome') { return (

Welcome to the ACE Copywriting Pipeline

Process CSV files, generate optimized copy, and review your job history.

); } if (view.type === 'view_history' && view.jobId) { const job = history.find(h => h.id === view.jobId); if (!job) return
Job not found in history.
; // If we have stored results, show them; otherwise show summary if (job.results && job.results.length > 0) { return ; } return (

{job.filename}

Job processed on {new Date(job.date).toLocaleString()}

Total Rows

{job.totalRows}

Succeeded

{job.completedCount}

Failed

{job.failedCount}

Detailed results are not available for this job.

This may be due to job size limitations or legacy storage format.

) } if (view.type === 'new_job') { if (status === 'completed') { return ; } if (queue.length === 0) { return { setError(msg); setErrors(prev => ([...prev, { code: 'CSV_PARSE_ERROR', message: msg, at: 'csv', suggestion: 'Check required columns and CSV formatting.' }])); }} disabled={false} />; } return (

Controls

{filename}

{queue.length} rows ready for processing.

Processing Queue

{queue .filter((item, index, arr) => arr.findIndex(q => q.id === item.id) === index) // Remove any runtime duplicates .map(item => (
{renderStatusIcon(item.status)}

Row {item.id + 1}: {item.data.URL}

))}
{status === 'processing' &&

Processed: {results.length}/{queue.length}

}
); } return null; }; const DeleteConfirmationDialog: React.FC<{ jobId: string; filename: string; onConfirm: () => void; onCancel: () => void }> = ({ jobId, filename, onConfirm, onCancel }) => (
e.stopPropagation()}>

Delete Job

Are you sure you want to delete the job for "{filename}"? This action cannot be undone.

); // Storage management utilities const getStorageInfo = () => { try { const totalSpace = 5 * 1024 * 1024; // 5MB limit const usedSpace = new Blob([JSON.stringify(history)]).size; const usedPercentage = (usedSpace / totalSpace) * 100; return { usedSpace: Math.round(usedSpace / 1024), // KB totalSpace: Math.round(totalSpace / 1024), // KB usedPercentage: Math.round(usedPercentage), historyCount: history.length, maxHistoryItems: preferences.maxHistoryItems }; } catch (error) { console.error('Error calculating storage info:', error); return null; } }; const clearAllData = () => { if (window.confirm('Are you sure you want to clear all stored data? This will remove all job history and cannot be undone.')) { try { // Clear localStorage window.localStorage.removeItem('ace-copywriting-history'); window.localStorage.removeItem('ace-copywriting-preferences'); window.localStorage.removeItem('ace-file-history'); // Clear sessionStorage window.sessionStorage.clear(); // Reset state setHistory([]); setPreferences({ autoSave: true, maxHistoryItems: 10, showDebugInfo: false, defaultExportFormat: 'csv', theme: 'dark' }); // Reset current job state resetNewJobState(); setView({ type: 'welcome' }); alert('All data has been cleared successfully.'); } catch (error) { console.error('Error clearing data:', error); alert('Error clearing data. Please try refreshing the page.'); } } }; const StorageInfoComponent: React.FC = () => { const storageInfo = getStorageInfo(); if (!storageInfo) return null; return (

Storage

Used
{storageInfo.usedSpace}KB
80 ? 'bg-red-500' : storageInfo.usedPercentage > 60 ? 'bg-yellow-500' : 'bg-green-500'}`} style={{ width: `${Math.min(storageInfo.usedPercentage, 100)}%` }} >
History
{storageInfo.historyCount}/{storageInfo.maxHistoryItems}
{storageInfo.usedPercentage > 80 && (
⚠️ Storage full
)}
); }; return (
{/* --- HEADER --- */}

ACE Pipeline

{user?.name && `Welcome, ${user.name}`}
{/* --- MAIN LAYOUT --- */}
{/* --- SIDEBAR --- */} {/* --- MAIN CONTENT --- */}
{error && view.type === 'new_job' && (
Issues detected during processing
{error}
{errors.length > 0 && ( )}
)} {renderMainContent()}
{/* --- DELETE CONFIRMATION DIALOG --- */} {deleteConfirmId && ( h.id === deleteConfirmId)?.filename || 'Unknown'} onConfirm={() => handleDeleteJob(deleteConfirmId)} onCancel={() => setDeleteConfirmId(null)} /> )} {/* --- ERROR DETAILS MODAL --- */} {showErrorDetails && setShowErrorDetails(false)} items={errors} />}
); }; // Simple router to handle signin vs main app const AppRouter: React.FC = () => { const [currentPath, setCurrentPath] = useState(window.location.pathname); useEffect(() => { const handlePopState = () => { setCurrentPath(window.location.pathname); }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); }, []); // Route to signin page if (currentPath === '/auth/signin' || currentPath.startsWith('/auth/signin')) { return ; } // Default to main authenticated app return ; }; // Main App component wrapped with authentication const AuthenticatedApp: React.FC = () => { const { user, isAuthenticated, isLoading, logout } = useAuth(); if (isLoading) { return (
); } if (!isAuthenticated || !user) { // Show signin page instead of redirecting return ; } return ; }; // Root component with routing const AppWithAuth: React.FC = () => { return ; }; export default AppWithAuth;