ACE / App.tsx
Severian's picture
Update App.tsx
e1faa07 verified
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;