Spaces:
Sleeping
Sleeping
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<T>(key: string, initialValue: T): [T, React.Dispatch<React.SetStateAction<T>>] { | |
const [storedValue, setStoredValue] = useState<T>(() => { | |
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<React.SetStateAction<T>> = 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<T>(key: string, initialValue: T): [T, React.Dispatch<React.SetStateAction<T>>] { | |
const [storedValue, setStoredValue] = useState<T>(() => { | |
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<React.SetStateAction<T>> = 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<JobHistoryItem[]>('ace-copywriting-history', []); | |
const [preferences, setPreferences] = useUserPreferences(); | |
const [view, setView] = useState<ViewState>(() => history.length > 0 ? { type: 'new_job' } : { type: 'welcome' }); | |
// State for the currently active processing job - persist in sessionStorage | |
const [jobId, setJobId] = useSessionStorage<string | null>('ace-current-job-id', null); | |
const [filename, setFilename] = useSessionStorage<string>('ace-current-filename', ''); | |
const [queue, setQueue] = useSessionStorage<QueueItem[]>('ace-current-queue', []); | |
const [results, setResults] = useSessionStorage<ResultRow[]>('ace-current-results', []); | |
const [status, setStatus] = useSessionStorage<ProcessingStatus>('ace-current-status', 'idle'); | |
const [error, setError] = useSessionStorage<string | null>('ace-current-error', null); | |
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null); | |
// New: capture all user-facing errors and show a details modal - persist in sessionStorage | |
const [errors, setErrors] = useSessionStorage<BatchError[]>('ace-current-errors', []); | |
const [showErrorDetails, setShowErrorDetails] = useState(false); | |
const [expandedDebugIndex, setExpandedDebugIndex] = useState<number | null>(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 <ClockIcon className="w-5 h-5 text-gray-400 flex-shrink-0" />; | |
case 'processing': return <Spinner />; | |
case 'completed': return <CheckCircleIcon className="w-5 h-5 text-green-400 flex-shrink-0" />; | |
case 'failed': return <XCircleIcon className="w-5 h-5 text-red-400 flex-shrink-0" />; | |
} | |
}; | |
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 }) => ( | |
<div className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center p-4 z-50" onClick={onClose}> | |
<div className="bg-gray-800 rounded-lg shadow-xl p-6 max-w-2xl w-full" onClick={e => e.stopPropagation()}> | |
<div className="flex items-center justify-between mb-4"> | |
<h3 className="text-xl font-bold text-white">Processing Errors</h3> | |
<div className="flex items-center gap-2"> | |
<button onClick={() => setErrors([])} className="text-xs px-2 py-1 bg-gray-700 hover:bg-gray-600 rounded text-gray-200">Clear</button> | |
<button onClick={onClose} className="text-gray-300 hover:text-white">Close</button> | |
</div> | |
</div> | |
{items.length === 0 ? ( | |
<p className="text-gray-300">No errors recorded.</p> | |
) : ( | |
<div className="max-h-80 overflow-y-auto divide-y divide-gray-700"> | |
{items.map((e, idx) => ( | |
<div key={idx} className="py-3 text-sm"> | |
<div className="flex items-start justify-between"> | |
<div> | |
<div className="text-white font-medium">{e.row ? `Row ${e.row}` : 'General'} — {e.code}</div> | |
<div className="text-gray-300 mt-1">{e.message}</div> | |
{e.suggestion && <div className="text-gray-400 mt-1">Suggestion: {e.suggestion}</div>} | |
{e.at && <div className="text-gray-500 mt-1 text-xs">Area: {e.at}</div>} | |
</div> | |
<button | |
className="text-xs text-gray-400 hover:text-white" | |
onClick={() => setErrors(prev => prev.filter((_, i) => i !== idx))} | |
title="Dismiss this error" | |
>Dismiss</button> | |
</div> | |
{e.debug && ( | |
<div className="mt-2"> | |
<button | |
className="text-xs text-blue-300 hover:text-blue-200 underline" | |
onClick={() => setExpandedDebugIndex(expandedDebugIndex === idx ? null : idx)} | |
> | |
{expandedDebugIndex === idx ? 'Hide debug' : 'Show debug'} | |
</button> | |
{expandedDebugIndex === idx && ( | |
<div className="mt-2 bg-gray-900 rounded border border-gray-700 p-2"> | |
<pre className="text-xs text-gray-300 whitespace-pre-wrap max-h-48 overflow-auto">{e.debug}</pre> | |
<button | |
className="mt-2 text-xs text-gray-300 hover:text-white underline" | |
onClick={() => { | |
navigator.clipboard?.writeText(e.debug || ''); | |
}} | |
>Copy debug</button> | |
</div> | |
)} | |
</div> | |
)} | |
</div> | |
))} | |
</div> | |
)} | |
</div> | |
</div> | |
); | |
const renderMainContent = () => { | |
if (view.type === 'welcome') { | |
return ( | |
<div className="text-center"> | |
<h1 className="text-4xl font-bold text-white">Welcome to the ACE Copywriting Pipeline</h1> | |
<p className="mt-4 text-lg text-gray-400">Process CSV files, generate optimized copy, and review your job history.</p> | |
<button | |
onClick={handleStartNewJob} | |
className="mt-8 inline-flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 rounded-lg transition-colors duration-200" | |
> | |
<PlusIcon className="w-6 h-6"/> Start Your First Job | |
</button> | |
</div> | |
); | |
} | |
if (view.type === 'view_history' && view.jobId) { | |
const job = history.find(h => h.id === view.jobId); | |
if (!job) return <div className="text-center text-red-400">Job not found in history.</div>; | |
// If we have stored results, show them; otherwise show summary | |
if (job.results && job.results.length > 0) { | |
return <ResultsTable results={job.results} />; | |
} | |
return ( | |
<div className="w-full max-w-4xl mx-auto"> | |
<div className="bg-gray-800 p-8 rounded-xl shadow-2xl"> | |
<h2 className="text-3xl font-bold text-white truncate" title={job.filename}>{job.filename}</h2> | |
<p className="mt-2 text-md text-gray-400 flex items-center gap-2"><HistoryIcon className="w-5 h-5" /><span>Job processed on {new Date(job.date).toLocaleString()}</span></p> | |
<div className="mt-8 grid grid-cols-1 md:grid-cols-3 gap-6 text-center"> | |
<div className="bg-gray-700/50 p-6 rounded-lg"> | |
<p className="text-sm text-gray-400 uppercase tracking-wider">Total Rows</p> | |
<p className="text-4xl font-semibold text-white mt-1">{job.totalRows}</p> | |
</div> | |
<div className="bg-green-900/40 p-6 rounded-lg"> | |
<p className="text-sm text-green-400 uppercase tracking-wider">Succeeded</p> | |
<p className="text-4xl font-semibold text-green-300 mt-1">{job.completedCount}</p> | |
</div> | |
<div className="bg-red-900/40 p-6 rounded-lg"> | |
<p className="text-sm text-red-400 uppercase tracking-wider">Failed</p> | |
<p className="text-4xl font-semibold text-red-300 mt-1">{job.failedCount}</p> | |
</div> | |
</div> | |
<div className="mt-8 bg-gray-900/50 border border-gray-700 rounded-lg p-4 text-center"> | |
<p className="text-gray-300"> | |
Detailed results are not available for this job. | |
</p> | |
<p className="text-gray-400 text-sm mt-1"> | |
This may be due to job size limitations or legacy storage format. | |
</p> | |
</div> | |
</div> | |
</div> | |
) | |
} | |
if (view.type === 'new_job') { | |
if (status === 'completed') { | |
return <ResultsTable results={results} />; | |
} | |
if (queue.length === 0) { | |
return <FileUpload onFileParsed={handleFileParsed} onParseError={(msg) => { | |
setError(msg); | |
setErrors(prev => ([...prev, { code: 'CSV_PARSE_ERROR', message: msg, at: 'csv', suggestion: 'Check required columns and CSV formatting.' }])); | |
}} disabled={false} />; | |
} | |
return ( | |
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 w-full"> | |
<div className="lg:col-span-1 bg-gray-800 p-6 rounded-xl shadow-lg self-start"> | |
<h2 className="text-2xl font-semibold mb-4 text-white">Controls</h2> | |
<div className="space-y-4"> | |
<div className="p-4 bg-gray-700 rounded-lg"> | |
<p className="font-medium text-white truncate" title={filename}>{filename}</p> | |
<p className="text-sm text-gray-300">{queue.length} rows ready for processing.</p> | |
</div> | |
<button | |
onClick={handleStartProcessing} | |
disabled={status === 'processing'} | |
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 text-white font-bold py-3 px-4 rounded-lg transition-colors duration-200" | |
> | |
{status === 'processing' ? <><Spinner /> Processing...</> : <><RocketLaunchIcon className="w-5 h-5"/> Start Processing</>} | |
</button> | |
<button | |
onClick={handleStartNewJob} | |
disabled={status === 'processing'} | |
className="w-full flex items-center justify-center gap-2 bg-gray-600 hover:bg-gray-500 disabled:bg-gray-500/50 text-white font-bold py-3 px-4 rounded-lg transition-colors duration-200" | |
> | |
<UploadIcon className="w-5 h-5"/> Upload New File | |
</button> | |
</div> | |
</div> | |
<div className="lg:col-span-2 bg-gray-800 p-6 rounded-xl shadow-lg"> | |
<h2 className="text-2xl font-semibold mb-4 text-white">Processing Queue</h2> | |
<div className="space-y-1 max-h-96 overflow-y-auto pr-2"> | |
{queue | |
.filter((item, index, arr) => arr.findIndex(q => q.id === item.id) === index) // Remove any runtime duplicates | |
.map(item => ( | |
<div key={item.id} className="flex items-center justify-between p-3 bg-gray-700/50 rounded-lg" title={item.error}> | |
<div className="flex items-center gap-3 overflow-hidden"> | |
{renderStatusIcon(item.status)} | |
<p className="truncate text-gray-300 text-sm">Row {item.id + 1}: <span className="text-gray-400">{item.data.URL}</span></p> | |
</div> | |
</div> | |
))} | |
</div> | |
{status === 'processing' && | |
<div className="mt-4 text-blue-300"> | |
<p className="text-sm">Processed: {results.length}/{queue.length}</p> | |
<div className="w-full bg-gray-700 rounded-full h-2.5 mt-2"> | |
<div className="bg-blue-600 h-2.5 rounded-full" style={{ width: `${(results.length / queue.length) * 100}%` }}></div> | |
</div> | |
</div> | |
} | |
</div> | |
</div> | |
); | |
} | |
return null; | |
}; | |
const DeleteConfirmationDialog: React.FC<{ jobId: string; filename: string; onConfirm: () => void; onCancel: () => void }> = ({ jobId, filename, onConfirm, onCancel }) => ( | |
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-50" onClick={onCancel}> | |
<div className="bg-gray-800 rounded-lg shadow-xl p-6 max-w-md w-full" onClick={e => e.stopPropagation()}> | |
<div className="flex items-center gap-3 mb-4"> | |
<TrashIcon className="w-6 h-6 text-red-400" /> | |
<h3 className="text-xl font-bold text-white">Delete Job</h3> | |
</div> | |
<p className="text-gray-300 mb-6"> | |
Are you sure you want to delete the job for <span className="font-medium text-white">"{filename}"</span>? | |
This action cannot be undone. | |
</p> | |
<div className="flex gap-3 justify-end"> | |
<button | |
onClick={onCancel} | |
className="px-4 py-2 bg-gray-600 hover:bg-gray-500 text-white rounded-lg transition-colors" | |
> | |
Cancel | |
</button> | |
<button | |
onClick={onConfirm} | |
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors" | |
> | |
Delete | |
</button> | |
</div> | |
</div> | |
</div> | |
); | |
// 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 ( | |
<div className="bg-gray-700/80 backdrop-blur-sm rounded-lg p-3 border border-gray-600"> | |
<div className="flex items-center justify-between mb-2"> | |
<h3 className="text-xs font-semibold text-gray-200">Storage</h3> | |
<button | |
onClick={clearAllData} | |
className="text-xs px-2 py-1 bg-red-600 hover:bg-red-700 text-white rounded transition-colors" | |
title="Clear all stored data" | |
> | |
Clear | |
</button> | |
</div> | |
<div className="grid grid-cols-2 gap-3 text-xs"> | |
<div> | |
<div className="text-gray-400 text-xs">Used</div> | |
<div className="text-white font-medium">{storageInfo.usedSpace}KB</div> | |
<div className="w-full bg-gray-600 rounded-full h-1 mt-1"> | |
<div | |
className={`h-1 rounded-full transition-all ${storageInfo.usedPercentage > 80 ? 'bg-red-500' : storageInfo.usedPercentage > 60 ? 'bg-yellow-500' : 'bg-green-500'}`} | |
style={{ width: `${Math.min(storageInfo.usedPercentage, 100)}%` }} | |
></div> | |
</div> | |
</div> | |
<div> | |
<div className="text-gray-400 text-xs">History</div> | |
<div className="text-white font-medium">{storageInfo.historyCount}/{storageInfo.maxHistoryItems}</div> | |
</div> | |
</div> | |
{storageInfo.usedPercentage > 80 && ( | |
<div className="text-xs text-yellow-400 mt-2 flex items-center gap-1"> | |
<span>⚠️</span> | |
<span>Storage full</span> | |
</div> | |
)} | |
</div> | |
); | |
}; | |
return ( | |
<div className="flex flex-col h-screen bg-gray-900 text-gray-100"> | |
{/* --- HEADER --- */} | |
<header className="bg-gray-800/95 backdrop-blur-sm border-b border-gray-700 px-6 py-3 flex-shrink-0"> | |
<div className="flex justify-between items-center"> | |
<div className="flex items-center gap-4"> | |
<h1 className="text-xl font-bold text-white">ACE Pipeline</h1> | |
<div className="text-sm text-gray-400"> | |
{user?.name && `Welcome, ${user.name}`} | |
</div> | |
</div> | |
<div className="flex items-center gap-4"> | |
<StorageInfoComponent /> | |
<button | |
onClick={() => logout()} | |
className="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded text-sm font-medium transition-colors" | |
> | |
Sign Out | |
</button> | |
</div> | |
</div> | |
</header> | |
{/* --- MAIN LAYOUT --- */} | |
<div className="flex flex-1 overflow-hidden"> | |
{/* --- SIDEBAR --- */} | |
<aside className="w-80 bg-gray-800/50 p-4 flex flex-col flex-shrink-0 border-r border-gray-700 overflow-hidden"> | |
<button | |
onClick={handleStartNewJob} | |
className={`w-full flex items-center gap-3 p-3 rounded-lg text-left font-semibold mb-4 transition-colors min-w-0 ${getActiveJobId() === jobId && view.type === 'new_job' ? 'bg-blue-600 text-white' : 'bg-gray-700 hover:bg-gray-600 text-gray-200'}`} | |
> | |
<PlusIcon className="w-6 h-6 flex-shrink-0"/> | |
<span className="truncate">New Job</span> | |
</button> | |
<h2 className="px-2 text-sm font-semibold text-gray-400 uppercase tracking-wider mb-2">History</h2> | |
<div className="flex-grow overflow-y-auto overflow-x-hidden pr-1"> | |
{history.length === 0 ? ( | |
<div className="text-center text-gray-500 p-4">No jobs completed yet.</div> | |
) : ( | |
<ul className="space-y-1"> | |
{history | |
.filter((h, index, arr) => arr.findIndex(item => item.id === h.id) === index) // Remove any runtime duplicates | |
.map(h => ( | |
<li key={h.id} className="group"> | |
<div className="flex items-center min-w-0"> | |
<button | |
onClick={() => setView({ type: 'view_history', jobId: h.id })} | |
className={`flex-1 min-w-0 text-left p-3 rounded-l-lg transition-colors ${getActiveJobId() === h.id ? 'bg-gray-700' : 'hover:bg-gray-700/50'}`} | |
> | |
<div className="flex items-start gap-3 min-w-0"> | |
<DocumentTextIcon className="w-6 h-6 text-gray-400 mt-0.5 flex-shrink-0"/> | |
<div className="flex-1 min-w-0"> | |
<p className="font-medium text-gray-200 truncate" title={h.filename}>{h.filename}</p> | |
<p className="text-xs text-gray-400 truncate">{new Date(h.date).toLocaleDateString()}</p> | |
<div className="text-xs mt-1"> | |
<span className="text-green-400">{h.completedCount}</span> | |
<span className="text-gray-500"> / </span> | |
<span className="text-red-400">{h.failedCount}</span> | |
{h.results && h.results.length > 0 && ( | |
<span className="text-blue-400 ml-2">• Full Results</span> | |
)} | |
</div> | |
</div> | |
</div> | |
</button> | |
<button | |
onClick={(e) => { | |
e.stopPropagation(); | |
setDeleteConfirmId(h.id); | |
}} | |
className="p-3 text-gray-500 hover:text-red-400 hover:bg-red-900/20 rounded-r-lg transition-colors opacity-0 group-hover:opacity-100 flex-shrink-0" | |
title="Delete job" | |
> | |
<TrashIcon className="w-4 h-4" /> | |
</button> | |
</div> | |
</li> | |
))} | |
</ul> | |
)} | |
</div> | |
</aside> | |
{/* --- MAIN CONTENT --- */} | |
<main className="flex-grow p-8 flex items-center justify-center overflow-auto"> | |
{error && view.type === 'new_job' && ( | |
<div className="fixed top-20 right-4 max-w-sm text-left text-red-200 bg-red-900/70 p-4 rounded-lg shadow-lg border border-red-700 z-50"> | |
<div className="flex items-start justify-between gap-3"> | |
<div> | |
<div className="font-semibold">Issues detected during processing</div> | |
<div className="text-sm mt-1">{error}</div> | |
{errors.length > 0 && ( | |
<button | |
className="mt-3 text-xs underline text-red-200 hover:text-white" | |
onClick={() => setShowErrorDetails(true)} | |
> | |
View details ({errors.length}) | |
</button> | |
)} | |
</div> | |
<button | |
onClick={() => { setError(null); }} | |
className="text-xs px-2 py-1 bg-red-800 hover:bg-red-700 rounded text-red-100" | |
title="Dismiss" | |
>Dismiss</button> | |
</div> | |
</div> | |
)} | |
{renderMainContent()} | |
</main> | |
</div> | |
{/* --- DELETE CONFIRMATION DIALOG --- */} | |
{deleteConfirmId && ( | |
<DeleteConfirmationDialog | |
jobId={deleteConfirmId} | |
filename={history.find(h => h.id === deleteConfirmId)?.filename || 'Unknown'} | |
onConfirm={() => handleDeleteJob(deleteConfirmId)} | |
onCancel={() => setDeleteConfirmId(null)} | |
/> | |
)} | |
{/* --- ERROR DETAILS MODAL --- */} | |
{showErrorDetails && <ErrorDetailsModal onClose={() => setShowErrorDetails(false)} items={errors} />} | |
</div> | |
); | |
}; | |
// 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 <SignInPage />; | |
} | |
// Default to main authenticated app | |
return <AuthenticatedApp />; | |
}; | |
// Main App component wrapped with authentication | |
const AuthenticatedApp: React.FC = () => { | |
const { user, isAuthenticated, isLoading, logout } = useAuth(); | |
if (isLoading) { | |
return ( | |
<div className="min-h-screen flex items-center justify-center bg-gray-900"> | |
<Spinner /> | |
</div> | |
); | |
} | |
if (!isAuthenticated || !user) { | |
// Show signin page instead of redirecting | |
return <SignInPage />; | |
} | |
return <App />; | |
}; | |
// Root component with routing | |
const AppWithAuth: React.FC = () => { | |
return <AppRouter />; | |
}; | |
export default AppWithAuth; |