Spaces:
Running
Running
Upload 27 files
Browse files- App.tsx +638 -0
- Dockerfile +47 -0
- app/.DS_Store +0 -0
- components/FileUpload.tsx +123 -0
- components/Icons.tsx +72 -0
- components/ResultsTable.tsx +535 -0
- components/SignInPage.tsx +102 -0
- components/Spinner.tsx +10 -0
- constants.ts +4 -0
- dist/.DS_Store +0 -0
- dist/assets/html2canvas.esm-CBrSDip1.js +0 -0
- dist/assets/index-DQmHHp3O.js +0 -0
- dist/assets/index.es-B33Y2F_W.js +0 -0
- dist/assets/purify.es-C_uT9hQ1.js +3 -0
- dist/index.html +42 -0
- index.html +42 -0
- index.tsx +16 -0
- metadata.json +21 -0
- package.json +22 -0
- requirements.txt +5 -0
- services/authService.ts +251 -0
- services/difyService.ts +782 -0
- src/.DS_Store +0 -0
- src/config/difyChatbotConfig.ts +28 -0
- tsconfig.json +26 -0
- types.ts +101 -0
- vite.config.ts +17 -0
App.tsx
ADDED
@@ -0,0 +1,638 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useState, useCallback, useEffect } from 'react';
|
2 |
+
import { useAuth } from './services/authService';
|
3 |
+
import { CsvInputRow, QueueItem, ResultRow, JobHistoryItem, ProcessedResult, BatchError } from './types';
|
4 |
+
import FileUpload from './components/FileUpload';
|
5 |
+
import ResultsTable from './components/ResultsTable';
|
6 |
+
import SignInPage from './components/SignInPage';
|
7 |
+
import { runWorkflow } from './services/difyService';
|
8 |
+
import { CheckCircleIcon, XCircleIcon, ClockIcon, UploadIcon, RocketLaunchIcon, PlusIcon, HistoryIcon, DocumentTextIcon, TrashIcon } from './components/Icons';
|
9 |
+
import Spinner from './components/Spinner';
|
10 |
+
|
11 |
+
// --- UTILS & HOOKS (in-file for simplicity) ---
|
12 |
+
|
13 |
+
const generateUUID = (): string => {
|
14 |
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
15 |
+
const r = (Math.random() * 16) | 0;
|
16 |
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
17 |
+
return v.toString(16);
|
18 |
+
});
|
19 |
+
};
|
20 |
+
|
21 |
+
const delay = (ms: number) => new Promise(res => setTimeout(res, ms));
|
22 |
+
|
23 |
+
// Calculate approximate storage size for job results
|
24 |
+
const estimateStorageSize = (results: ResultRow[]): number => {
|
25 |
+
return JSON.stringify(results).length;
|
26 |
+
};
|
27 |
+
|
28 |
+
// Maximum storage size per job (500KB)
|
29 |
+
const MAX_JOB_STORAGE_SIZE = 500 * 1024;
|
30 |
+
|
31 |
+
function useLocalStorage<T>(key: string, initialValue: T): [T, React.Dispatch<React.SetStateAction<T>>] {
|
32 |
+
const [storedValue, setStoredValue] = useState<T>(() => {
|
33 |
+
try {
|
34 |
+
const item = window.localStorage.getItem(key);
|
35 |
+
if (item) {
|
36 |
+
// Basic validation to help clear out old, problematic data.
|
37 |
+
const parsed = JSON.parse(item);
|
38 |
+
if (Array.isArray(parsed)) {
|
39 |
+
// Filter out old items that had the 'results' property which caused storage errors.
|
40 |
+
let cleanHistory = parsed.filter(p => typeof p === 'object' && p !== null && !p.hasOwnProperty('results'));
|
41 |
+
|
42 |
+
// Remove duplicates based on ID if it's a history array
|
43 |
+
if (key === 'ace-copywriting-history' && cleanHistory.length > 0) {
|
44 |
+
const uniqueHistory = cleanHistory.reduce((acc: any[], current: any) => {
|
45 |
+
const existingIndex = acc.findIndex(item => item.id === current.id);
|
46 |
+
if (existingIndex === -1) {
|
47 |
+
acc.push(current);
|
48 |
+
}
|
49 |
+
return acc;
|
50 |
+
}, []);
|
51 |
+
cleanHistory = uniqueHistory;
|
52 |
+
}
|
53 |
+
|
54 |
+
return cleanHistory;
|
55 |
+
}
|
56 |
+
return parsed;
|
57 |
+
}
|
58 |
+
return initialValue;
|
59 |
+
} catch (error) {
|
60 |
+
console.error('Error reading from localStorage, resetting to initial value.', error);
|
61 |
+
window.localStorage.removeItem(key); // Clear corrupted item
|
62 |
+
return initialValue;
|
63 |
+
}
|
64 |
+
});
|
65 |
+
|
66 |
+
const setValue: React.Dispatch<React.SetStateAction<T>> = useCallback((value) => {
|
67 |
+
try {
|
68 |
+
setStoredValue(prev => {
|
69 |
+
const valueToStore = value instanceof Function ? value(prev) : value;
|
70 |
+
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
71 |
+
return valueToStore;
|
72 |
+
});
|
73 |
+
} catch (error) {
|
74 |
+
console.error('Error writing to localStorage', error);
|
75 |
+
}
|
76 |
+
}, [key]);
|
77 |
+
return [storedValue, setValue];
|
78 |
+
}
|
79 |
+
|
80 |
+
|
81 |
+
// --- TYPES ---
|
82 |
+
|
83 |
+
type ProcessingStatus = 'idle' | 'processing' | 'completed' | 'error';
|
84 |
+
type ViewState =
|
85 |
+
| { type: 'welcome' }
|
86 |
+
| { type: 'new_job' }
|
87 |
+
| { type: 'view_history'; jobId: string };
|
88 |
+
|
89 |
+
// --- CONSTANTS ---
|
90 |
+
const MAX_HISTORY_ITEMS = 10;
|
91 |
+
|
92 |
+
// --- MAIN COMPONENT ---
|
93 |
+
|
94 |
+
const App: React.FC = () => {
|
95 |
+
const [history, setHistory] = useLocalStorage<JobHistoryItem[]>('ace-copywriting-history', []);
|
96 |
+
const [view, setView] = useState<ViewState>(() => history.length > 0 ? { type: 'new_job' } : { type: 'welcome' });
|
97 |
+
|
98 |
+
// State for the currently active processing job
|
99 |
+
const [jobId, setJobId] = useState<string | null>(null);
|
100 |
+
const [filename, setFilename] = useState<string>('');
|
101 |
+
const [queue, setQueue] = useState<QueueItem[]>([]);
|
102 |
+
const [results, setResults] = useState<ResultRow[]>([]);
|
103 |
+
const [status, setStatus] = useState<ProcessingStatus>('idle');
|
104 |
+
const [error, setError] = useState<string | null>(null);
|
105 |
+
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
106 |
+
|
107 |
+
// New: capture all user-facing errors and show a details modal
|
108 |
+
const [errors, setErrors] = useState<BatchError[]>([]);
|
109 |
+
const [showErrorDetails, setShowErrorDetails] = useState(false);
|
110 |
+
const [expandedDebugIndex, setExpandedDebugIndex] = useState<number | null>(null);
|
111 |
+
|
112 |
+
const resetNewJobState = useCallback(() => {
|
113 |
+
setJobId(generateUUID());
|
114 |
+
setFilename('');
|
115 |
+
setQueue([]);
|
116 |
+
setResults([]);
|
117 |
+
setStatus('idle');
|
118 |
+
setError(null);
|
119 |
+
setErrors([]);
|
120 |
+
setShowErrorDetails(false);
|
121 |
+
}, []);
|
122 |
+
|
123 |
+
const handleStartNewJob = () => {
|
124 |
+
resetNewJobState();
|
125 |
+
setView({ type: 'new_job' });
|
126 |
+
};
|
127 |
+
|
128 |
+
const handleDeleteJob = (jobId: string) => {
|
129 |
+
setHistory(prevHistory => prevHistory.filter(h => h.id !== jobId));
|
130 |
+
setDeleteConfirmId(null);
|
131 |
+
|
132 |
+
// If we're currently viewing the deleted job, navigate back to new job
|
133 |
+
if (view.type === 'view_history' && view.jobId === jobId) {
|
134 |
+
setView({ type: 'new_job' });
|
135 |
+
}
|
136 |
+
};
|
137 |
+
|
138 |
+
const handleFileParsed = useCallback((data: CsvInputRow[], file: File) => {
|
139 |
+
// Ensure we have a jobId for this processing session
|
140 |
+
if (!jobId) {
|
141 |
+
setJobId(generateUUID());
|
142 |
+
}
|
143 |
+
setFilename(file.name);
|
144 |
+
const initialQueue = data.map((row, index) => ({
|
145 |
+
id: index,
|
146 |
+
data: row,
|
147 |
+
status: 'pending' as const,
|
148 |
+
}));
|
149 |
+
setQueue(initialQueue);
|
150 |
+
setError(null);
|
151 |
+
setErrors([]);
|
152 |
+
}, [jobId]);
|
153 |
+
|
154 |
+
const handleStartProcessing = async () => {
|
155 |
+
console.log('Start processing clicked. JobId:', jobId, 'Queue length:', queue.length);
|
156 |
+
if (!jobId || queue.length === 0) {
|
157 |
+
console.log('Processing aborted - missing jobId or empty queue');
|
158 |
+
return;
|
159 |
+
}
|
160 |
+
console.log('Starting processing...');
|
161 |
+
setStatus('processing');
|
162 |
+
setError(null);
|
163 |
+
setErrors([]);
|
164 |
+
setResults([]); // Clear previous results
|
165 |
+
|
166 |
+
const newResults: ResultRow[] = [];
|
167 |
+
|
168 |
+
for (const [index, item] of queue.entries()) {
|
169 |
+
setQueue(prev => prev.map(q => q.id === item.id ? { ...q, status: 'processing' } : q));
|
170 |
+
try {
|
171 |
+
const processedData: ProcessedResult = await runWorkflow(item.data);
|
172 |
+
const resultRow: ResultRow = { ...item.data, ...processedData, id: item.id };
|
173 |
+
newResults.push(resultRow);
|
174 |
+
setResults(prev => [...prev, resultRow]); // Add one result at a time
|
175 |
+
setQueue(prev => prev.map(q => q.id === item.id ? { ...q, status: 'completed' } : q));
|
176 |
+
} catch (e) {
|
177 |
+
const msg = e instanceof Error ? e.message : 'An unknown error occurred';
|
178 |
+
setQueue(prev => prev.map(q => q.id === item.id ? { ...q, status: 'failed', error: msg } : q));
|
179 |
+
setError('Some rows failed during processing.');
|
180 |
+
setErrors(prev => ([
|
181 |
+
...prev,
|
182 |
+
{ 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.' }
|
183 |
+
]));
|
184 |
+
}
|
185 |
+
|
186 |
+
// Add a delay between requests to avoid overwhelming the API.
|
187 |
+
if (index < queue.length - 1) {
|
188 |
+
await delay(500); // 0.5 second pause
|
189 |
+
}
|
190 |
+
}
|
191 |
+
setStatus('completed');
|
192 |
+
};
|
193 |
+
|
194 |
+
// Effect to save job summary to history when completed
|
195 |
+
useEffect(() => {
|
196 |
+
console.log('History save effect triggered:', { status, jobId, queueLength: queue.length, resultsLength: results.length });
|
197 |
+
if (status === 'completed' && jobId) {
|
198 |
+
console.log('Saving job to history:', { jobId, filename, queueLength: queue.length, resultsLength: results.length });
|
199 |
+
const completedCount = queue.filter(q => q.status === 'completed').length;
|
200 |
+
const failedCount = queue.filter(q => q.status === 'failed').length;
|
201 |
+
|
202 |
+
setHistory(prevHistory => {
|
203 |
+
// Check if this job is already in history to prevent duplicates
|
204 |
+
const existingIndex = prevHistory.findIndex(h => h.id === jobId);
|
205 |
+
if (existingIndex !== -1) {
|
206 |
+
return prevHistory; // Already exists, don't add again
|
207 |
+
}
|
208 |
+
|
209 |
+
// Check if results are too large for storage
|
210 |
+
const storageSize = estimateStorageSize(results);
|
211 |
+
const shouldStoreResults = storageSize <= MAX_JOB_STORAGE_SIZE;
|
212 |
+
|
213 |
+
if (!shouldStoreResults) {
|
214 |
+
console.warn(`Job results too large for storage (${Math.round(storageSize / 1024)}KB). Only storing summary.`);
|
215 |
+
}
|
216 |
+
|
217 |
+
const newHistoryItem: JobHistoryItem = {
|
218 |
+
id: jobId,
|
219 |
+
filename,
|
220 |
+
date: new Date().toISOString(),
|
221 |
+
totalRows: queue.length,
|
222 |
+
completedCount,
|
223 |
+
failedCount,
|
224 |
+
results: shouldStoreResults ? [...results] : undefined, // Store results if not too large
|
225 |
+
};
|
226 |
+
|
227 |
+
const updatedHistory = [newHistoryItem, ...prevHistory];
|
228 |
+
// Prune history to the maximum allowed size
|
229 |
+
console.log('Job saved to history successfully:', newHistoryItem);
|
230 |
+
return updatedHistory.slice(0, MAX_HISTORY_ITEMS);
|
231 |
+
});
|
232 |
+
// Stay on the results view for the current job. The user can navigate to history manually.
|
233 |
+
}
|
234 |
+
}, [status, jobId, filename, queue.length, results, setHistory]);
|
235 |
+
|
236 |
+
// --- RENDER ---
|
237 |
+
|
238 |
+
const renderStatusIcon = (itemStatus: QueueItem['status']) => {
|
239 |
+
switch (itemStatus) {
|
240 |
+
case 'pending': return <ClockIcon className="w-5 h-5 text-gray-400 flex-shrink-0" />;
|
241 |
+
case 'processing': return <Spinner />;
|
242 |
+
case 'completed': return <CheckCircleIcon className="w-5 h-5 text-green-400 flex-shrink-0" />;
|
243 |
+
case 'failed': return <XCircleIcon className="w-5 h-5 text-red-400 flex-shrink-0" />;
|
244 |
+
}
|
245 |
+
};
|
246 |
+
|
247 |
+
const getActiveJobId = () => {
|
248 |
+
if (view.type === 'new_job' && jobId) return jobId;
|
249 |
+
if (view.type === 'view_history') return view.jobId;
|
250 |
+
return null;
|
251 |
+
}
|
252 |
+
|
253 |
+
const ErrorDetailsModal: React.FC<{ onClose: () => void; items: BatchError[] }> = ({ onClose, items }) => (
|
254 |
+
<div className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center p-4 z-50" onClick={onClose}>
|
255 |
+
<div className="bg-gray-800 rounded-lg shadow-xl p-6 max-w-2xl w-full" onClick={e => e.stopPropagation()}>
|
256 |
+
<div className="flex items-center justify-between mb-4">
|
257 |
+
<h3 className="text-xl font-bold text-white">Processing Errors</h3>
|
258 |
+
<div className="flex items-center gap-2">
|
259 |
+
<button onClick={() => setErrors([])} className="text-xs px-2 py-1 bg-gray-700 hover:bg-gray-600 rounded text-gray-200">Clear</button>
|
260 |
+
<button onClick={onClose} className="text-gray-300 hover:text-white">Close</button>
|
261 |
+
</div>
|
262 |
+
</div>
|
263 |
+
{items.length === 0 ? (
|
264 |
+
<p className="text-gray-300">No errors recorded.</p>
|
265 |
+
) : (
|
266 |
+
<div className="max-h-80 overflow-y-auto divide-y divide-gray-700">
|
267 |
+
{items.map((e, idx) => (
|
268 |
+
<div key={idx} className="py-3 text-sm">
|
269 |
+
<div className="flex items-start justify-between">
|
270 |
+
<div>
|
271 |
+
<div className="text-white font-medium">{e.row ? `Row ${e.row}` : 'General'} — {e.code}</div>
|
272 |
+
<div className="text-gray-300 mt-1">{e.message}</div>
|
273 |
+
{e.suggestion && <div className="text-gray-400 mt-1">Suggestion: {e.suggestion}</div>}
|
274 |
+
{e.at && <div className="text-gray-500 mt-1 text-xs">Area: {e.at}</div>}
|
275 |
+
</div>
|
276 |
+
<button
|
277 |
+
className="text-xs text-gray-400 hover:text-white"
|
278 |
+
onClick={() => setErrors(prev => prev.filter((_, i) => i !== idx))}
|
279 |
+
title="Dismiss this error"
|
280 |
+
>Dismiss</button>
|
281 |
+
</div>
|
282 |
+
{e.debug && (
|
283 |
+
<div className="mt-2">
|
284 |
+
<button
|
285 |
+
className="text-xs text-blue-300 hover:text-blue-200 underline"
|
286 |
+
onClick={() => setExpandedDebugIndex(expandedDebugIndex === idx ? null : idx)}
|
287 |
+
>
|
288 |
+
{expandedDebugIndex === idx ? 'Hide debug' : 'Show debug'}
|
289 |
+
</button>
|
290 |
+
{expandedDebugIndex === idx && (
|
291 |
+
<div className="mt-2 bg-gray-900 rounded border border-gray-700 p-2">
|
292 |
+
<pre className="text-xs text-gray-300 whitespace-pre-wrap max-h-48 overflow-auto">{e.debug}</pre>
|
293 |
+
<button
|
294 |
+
className="mt-2 text-xs text-gray-300 hover:text-white underline"
|
295 |
+
onClick={() => {
|
296 |
+
navigator.clipboard?.writeText(e.debug || '');
|
297 |
+
}}
|
298 |
+
>Copy debug</button>
|
299 |
+
</div>
|
300 |
+
)}
|
301 |
+
</div>
|
302 |
+
)}
|
303 |
+
</div>
|
304 |
+
))}
|
305 |
+
</div>
|
306 |
+
)}
|
307 |
+
</div>
|
308 |
+
</div>
|
309 |
+
);
|
310 |
+
|
311 |
+
const renderMainContent = () => {
|
312 |
+
if (view.type === 'welcome') {
|
313 |
+
return (
|
314 |
+
<div className="text-center">
|
315 |
+
<h1 className="text-4xl font-bold text-white">Welcome to the ACE Copywriting Pipeline</h1>
|
316 |
+
<p className="mt-4 text-lg text-gray-400">Process CSV files, generate optimized copy, and review your job history.</p>
|
317 |
+
<button
|
318 |
+
onClick={handleStartNewJob}
|
319 |
+
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"
|
320 |
+
>
|
321 |
+
<PlusIcon className="w-6 h-6"/> Start Your First Job
|
322 |
+
</button>
|
323 |
+
</div>
|
324 |
+
);
|
325 |
+
}
|
326 |
+
|
327 |
+
if (view.type === 'view_history' && view.jobId) {
|
328 |
+
const job = history.find(h => h.id === view.jobId);
|
329 |
+
if (!job) return <div className="text-center text-red-400">Job not found in history.</div>;
|
330 |
+
|
331 |
+
// If we have stored results, show them; otherwise show summary
|
332 |
+
if (job.results && job.results.length > 0) {
|
333 |
+
return <ResultsTable results={job.results} />;
|
334 |
+
}
|
335 |
+
|
336 |
+
return (
|
337 |
+
<div className="w-full max-w-4xl mx-auto">
|
338 |
+
<div className="bg-gray-800 p-8 rounded-xl shadow-2xl">
|
339 |
+
<h2 className="text-3xl font-bold text-white truncate" title={job.filename}>{job.filename}</h2>
|
340 |
+
<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>
|
341 |
+
|
342 |
+
<div className="mt-8 grid grid-cols-1 md:grid-cols-3 gap-6 text-center">
|
343 |
+
<div className="bg-gray-700/50 p-6 rounded-lg">
|
344 |
+
<p className="text-sm text-gray-400 uppercase tracking-wider">Total Rows</p>
|
345 |
+
<p className="text-4xl font-semibold text-white mt-1">{job.totalRows}</p>
|
346 |
+
</div>
|
347 |
+
<div className="bg-green-900/40 p-6 rounded-lg">
|
348 |
+
<p className="text-sm text-green-400 uppercase tracking-wider">Succeeded</p>
|
349 |
+
<p className="text-4xl font-semibold text-green-300 mt-1">{job.completedCount}</p>
|
350 |
+
</div>
|
351 |
+
<div className="bg-red-900/40 p-6 rounded-lg">
|
352 |
+
<p className="text-sm text-red-400 uppercase tracking-wider">Failed</p>
|
353 |
+
<p className="text-4xl font-semibold text-red-300 mt-1">{job.failedCount}</p>
|
354 |
+
</div>
|
355 |
+
</div>
|
356 |
+
|
357 |
+
<div className="mt-8 bg-gray-900/50 border border-gray-700 rounded-lg p-4 text-center">
|
358 |
+
<p className="text-gray-300">
|
359 |
+
Detailed results are not available for this job.
|
360 |
+
</p>
|
361 |
+
<p className="text-gray-400 text-sm mt-1">
|
362 |
+
This may be due to job size limitations or legacy storage format.
|
363 |
+
</p>
|
364 |
+
</div>
|
365 |
+
</div>
|
366 |
+
</div>
|
367 |
+
)
|
368 |
+
}
|
369 |
+
|
370 |
+
if (view.type === 'new_job') {
|
371 |
+
if (status === 'completed') {
|
372 |
+
return <ResultsTable results={results} />;
|
373 |
+
}
|
374 |
+
if (queue.length === 0) {
|
375 |
+
return <FileUpload onFileParsed={handleFileParsed} onParseError={(msg) => {
|
376 |
+
setError(msg);
|
377 |
+
setErrors(prev => ([...prev, { code: 'CSV_PARSE_ERROR', message: msg, at: 'csv', suggestion: 'Check required columns and CSV formatting.' }]));
|
378 |
+
}} disabled={false} />;
|
379 |
+
}
|
380 |
+
return (
|
381 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 w-full">
|
382 |
+
<div className="lg:col-span-1 bg-gray-800 p-6 rounded-xl shadow-lg self-start">
|
383 |
+
<h2 className="text-2xl font-semibold mb-4 text-white">Controls</h2>
|
384 |
+
<div className="space-y-4">
|
385 |
+
<div className="p-4 bg-gray-700 rounded-lg">
|
386 |
+
<p className="font-medium text-white truncate" title={filename}>{filename}</p>
|
387 |
+
<p className="text-sm text-gray-300">{queue.length} rows ready for processing.</p>
|
388 |
+
</div>
|
389 |
+
<button
|
390 |
+
onClick={handleStartProcessing}
|
391 |
+
disabled={status === 'processing'}
|
392 |
+
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"
|
393 |
+
>
|
394 |
+
{status === 'processing' ? <><Spinner /> Processing...</> : <><RocketLaunchIcon className="w-5 h-5"/> Start Processing</>}
|
395 |
+
</button>
|
396 |
+
<button
|
397 |
+
onClick={handleStartNewJob}
|
398 |
+
disabled={status === 'processing'}
|
399 |
+
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"
|
400 |
+
>
|
401 |
+
<UploadIcon className="w-5 h-5"/> Upload New File
|
402 |
+
</button>
|
403 |
+
</div>
|
404 |
+
</div>
|
405 |
+
<div className="lg:col-span-2 bg-gray-800 p-6 rounded-xl shadow-lg">
|
406 |
+
<h2 className="text-2xl font-semibold mb-4 text-white">Processing Queue</h2>
|
407 |
+
<div className="space-y-1 max-h-96 overflow-y-auto pr-2">
|
408 |
+
{queue
|
409 |
+
.filter((item, index, arr) => arr.findIndex(q => q.id === item.id) === index) // Remove any runtime duplicates
|
410 |
+
.map(item => (
|
411 |
+
<div key={item.id} className="flex items-center justify-between p-3 bg-gray-700/50 rounded-lg" title={item.error}>
|
412 |
+
<div className="flex items-center gap-3 overflow-hidden">
|
413 |
+
{renderStatusIcon(item.status)}
|
414 |
+
<p className="truncate text-gray-300 text-sm">Row {item.id + 1}: <span className="text-gray-400">{item.data.URL}</span></p>
|
415 |
+
</div>
|
416 |
+
</div>
|
417 |
+
))}
|
418 |
+
</div>
|
419 |
+
{status === 'processing' &&
|
420 |
+
<div className="mt-4 text-blue-300">
|
421 |
+
<p className="text-sm">Processed: {results.length}/{queue.length}</p>
|
422 |
+
<div className="w-full bg-gray-700 rounded-full h-2.5 mt-2">
|
423 |
+
<div className="bg-blue-600 h-2.5 rounded-full" style={{ width: `${(results.length / queue.length) * 100}%` }}></div>
|
424 |
+
</div>
|
425 |
+
</div>
|
426 |
+
}
|
427 |
+
</div>
|
428 |
+
</div>
|
429 |
+
);
|
430 |
+
}
|
431 |
+
|
432 |
+
return null;
|
433 |
+
};
|
434 |
+
|
435 |
+
|
436 |
+
const DeleteConfirmationDialog: React.FC<{ jobId: string; filename: string; onConfirm: () => void; onCancel: () => void }> = ({ jobId, filename, onConfirm, onCancel }) => (
|
437 |
+
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-50" onClick={onCancel}>
|
438 |
+
<div className="bg-gray-800 rounded-lg shadow-xl p-6 max-w-md w-full" onClick={e => e.stopPropagation()}>
|
439 |
+
<div className="flex items-center gap-3 mb-4">
|
440 |
+
<TrashIcon className="w-6 h-6 text-red-400" />
|
441 |
+
<h3 className="text-xl font-bold text-white">Delete Job</h3>
|
442 |
+
</div>
|
443 |
+
<p className="text-gray-300 mb-6">
|
444 |
+
Are you sure you want to delete the job for <span className="font-medium text-white">"{filename}"</span>?
|
445 |
+
This action cannot be undone.
|
446 |
+
</p>
|
447 |
+
<div className="flex gap-3 justify-end">
|
448 |
+
<button
|
449 |
+
onClick={onCancel}
|
450 |
+
className="px-4 py-2 bg-gray-600 hover:bg-gray-500 text-white rounded-lg transition-colors"
|
451 |
+
>
|
452 |
+
Cancel
|
453 |
+
</button>
|
454 |
+
<button
|
455 |
+
onClick={onConfirm}
|
456 |
+
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
|
457 |
+
>
|
458 |
+
Delete
|
459 |
+
</button>
|
460 |
+
</div>
|
461 |
+
</div>
|
462 |
+
</div>
|
463 |
+
);
|
464 |
+
|
465 |
+
return (
|
466 |
+
<div className="flex h-screen bg-gray-900 text-gray-100">
|
467 |
+
{/* --- SIDEBAR --- */}
|
468 |
+
<aside className="w-80 bg-gray-800/50 p-4 flex flex-col flex-shrink-0 border-r border-gray-700 overflow-hidden">
|
469 |
+
<header className="px-2 mb-6">
|
470 |
+
<h1 className="text-2xl font-bold text-white tracking-tight">ACE Pipeline</h1>
|
471 |
+
</header>
|
472 |
+
<button
|
473 |
+
onClick={handleStartNewJob}
|
474 |
+
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'}`}
|
475 |
+
>
|
476 |
+
<PlusIcon className="w-6 h-6 flex-shrink-0"/>
|
477 |
+
<span className="truncate">New Job</span>
|
478 |
+
</button>
|
479 |
+
<h2 className="px-2 text-sm font-semibold text-gray-400 uppercase tracking-wider mb-2">History</h2>
|
480 |
+
<div className="flex-grow overflow-y-auto overflow-x-hidden pr-1">
|
481 |
+
{history.length === 0 ? (
|
482 |
+
<div className="text-center text-gray-500 p-4">No jobs completed yet.</div>
|
483 |
+
) : (
|
484 |
+
<ul className="space-y-1">
|
485 |
+
{history
|
486 |
+
.filter((h, index, arr) => arr.findIndex(item => item.id === h.id) === index) // Remove any runtime duplicates
|
487 |
+
.map(h => (
|
488 |
+
<li key={h.id} className="group">
|
489 |
+
<div className="flex items-center min-w-0">
|
490 |
+
<button
|
491 |
+
onClick={() => setView({ type: 'view_history', jobId: h.id })}
|
492 |
+
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'}`}
|
493 |
+
>
|
494 |
+
<div className="flex items-start gap-3 min-w-0">
|
495 |
+
<DocumentTextIcon className="w-6 h-6 text-gray-400 mt-0.5 flex-shrink-0"/>
|
496 |
+
<div className="flex-1 min-w-0">
|
497 |
+
<p className="font-medium text-gray-200 truncate" title={h.filename}>{h.filename}</p>
|
498 |
+
<p className="text-xs text-gray-400 truncate">{new Date(h.date).toLocaleDateString()}</p>
|
499 |
+
<div className="text-xs mt-1">
|
500 |
+
<span className="text-green-400">{h.completedCount}</span>
|
501 |
+
<span className="text-gray-500"> / </span>
|
502 |
+
<span className="text-red-400">{h.failedCount}</span>
|
503 |
+
{h.results && h.results.length > 0 && (
|
504 |
+
<span className="text-blue-400 ml-2">• Full Results</span>
|
505 |
+
)}
|
506 |
+
</div>
|
507 |
+
</div>
|
508 |
+
</div>
|
509 |
+
</button>
|
510 |
+
<button
|
511 |
+
onClick={(e) => {
|
512 |
+
e.stopPropagation();
|
513 |
+
setDeleteConfirmId(h.id);
|
514 |
+
}}
|
515 |
+
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"
|
516 |
+
title="Delete job"
|
517 |
+
>
|
518 |
+
<TrashIcon className="w-4 h-4" />
|
519 |
+
</button>
|
520 |
+
</div>
|
521 |
+
</li>
|
522 |
+
))}
|
523 |
+
</ul>
|
524 |
+
)}
|
525 |
+
</div>
|
526 |
+
</aside>
|
527 |
+
|
528 |
+
{/* --- MAIN CONTENT --- */}
|
529 |
+
<main className="flex-grow p-8 flex items-center justify-center overflow-auto">
|
530 |
+
{error && view.type === 'new_job' && (
|
531 |
+
<div className="fixed top-4 right-4 max-w-sm text-left text-red-200 bg-red-900/70 p-4 rounded-lg shadow-lg border border-red-700">
|
532 |
+
<div className="flex items-start justify-between gap-3">
|
533 |
+
<div>
|
534 |
+
<div className="font-semibold">Issues detected during processing</div>
|
535 |
+
<div className="text-sm mt-1">{error}</div>
|
536 |
+
{errors.length > 0 && (
|
537 |
+
<button
|
538 |
+
className="mt-3 text-xs underline text-red-200 hover:text-white"
|
539 |
+
onClick={() => setShowErrorDetails(true)}
|
540 |
+
>
|
541 |
+
View details ({errors.length})
|
542 |
+
</button>
|
543 |
+
)}
|
544 |
+
</div>
|
545 |
+
<button
|
546 |
+
onClick={() => { setError(null); }}
|
547 |
+
className="text-xs px-2 py-1 bg-red-800 hover:bg-red-700 rounded text-red-100"
|
548 |
+
title="Dismiss"
|
549 |
+
>Dismiss</button>
|
550 |
+
</div>
|
551 |
+
</div>
|
552 |
+
)}
|
553 |
+
{renderMainContent()}
|
554 |
+
</main>
|
555 |
+
|
556 |
+
{/* --- DELETE CONFIRMATION DIALOG --- */}
|
557 |
+
{deleteConfirmId && (
|
558 |
+
<DeleteConfirmationDialog
|
559 |
+
jobId={deleteConfirmId}
|
560 |
+
filename={history.find(h => h.id === deleteConfirmId)?.filename || 'Unknown'}
|
561 |
+
onConfirm={() => handleDeleteJob(deleteConfirmId)}
|
562 |
+
onCancel={() => setDeleteConfirmId(null)}
|
563 |
+
/>
|
564 |
+
)}
|
565 |
+
|
566 |
+
{/* --- ERROR DETAILS MODAL --- */}
|
567 |
+
{showErrorDetails && <ErrorDetailsModal onClose={() => setShowErrorDetails(false)} items={errors} />}
|
568 |
+
</div>
|
569 |
+
);
|
570 |
+
};
|
571 |
+
|
572 |
+
// Simple router to handle signin vs main app
|
573 |
+
const AppRouter: React.FC = () => {
|
574 |
+
const [currentPath, setCurrentPath] = useState(window.location.pathname);
|
575 |
+
|
576 |
+
useEffect(() => {
|
577 |
+
const handlePopState = () => {
|
578 |
+
setCurrentPath(window.location.pathname);
|
579 |
+
};
|
580 |
+
|
581 |
+
window.addEventListener('popstate', handlePopState);
|
582 |
+
return () => window.removeEventListener('popstate', handlePopState);
|
583 |
+
}, []);
|
584 |
+
|
585 |
+
// Route to signin page
|
586 |
+
if (currentPath === '/auth/signin' || currentPath.startsWith('/auth/signin')) {
|
587 |
+
return <SignInPage />;
|
588 |
+
}
|
589 |
+
|
590 |
+
// Default to main authenticated app
|
591 |
+
return <AuthenticatedApp />;
|
592 |
+
};
|
593 |
+
|
594 |
+
// Main App component wrapped with authentication
|
595 |
+
const AuthenticatedApp: React.FC = () => {
|
596 |
+
const { user, isAuthenticated, isLoading, logout } = useAuth();
|
597 |
+
|
598 |
+
if (isLoading) {
|
599 |
+
return (
|
600 |
+
<div className="min-h-screen flex items-center justify-center bg-gray-900">
|
601 |
+
<Spinner />
|
602 |
+
</div>
|
603 |
+
);
|
604 |
+
}
|
605 |
+
|
606 |
+
if (!isAuthenticated || !user) {
|
607 |
+
// Show signin page instead of redirecting
|
608 |
+
return <SignInPage />;
|
609 |
+
}
|
610 |
+
|
611 |
+
return (
|
612 |
+
<div className="min-h-screen bg-gray-900">
|
613 |
+
{/* Header with logout */}
|
614 |
+
<header className="bg-gray-800 border-b border-gray-700 px-6 py-4">
|
615 |
+
<div className="flex justify-between items-center">
|
616 |
+
<h1 className="text-2xl font-bold text-white">ACE UI Dashboard</h1>
|
617 |
+
<div className="flex items-center gap-4">
|
618 |
+
<span className="text-gray-300">Welcome, {user.name}</span>
|
619 |
+
<button
|
620 |
+
onClick={() => logout()}
|
621 |
+
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors"
|
622 |
+
>
|
623 |
+
Sign Out
|
624 |
+
</button>
|
625 |
+
</div>
|
626 |
+
</div>
|
627 |
+
</header>
|
628 |
+
<App />
|
629 |
+
</div>
|
630 |
+
);
|
631 |
+
};
|
632 |
+
|
633 |
+
// Root component with routing
|
634 |
+
const AppWithAuth: React.FC = () => {
|
635 |
+
return <AppRouter />;
|
636 |
+
};
|
637 |
+
|
638 |
+
export default AppWithAuth;
|
Dockerfile
ADDED
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Read the doc: https://huggingface.co/docs/hub/spaces-sdks-docker
|
2 |
+
# ACE Copywriting Pipeline - Optimized for HuggingFace Spaces
|
3 |
+
|
4 |
+
FROM node:18-alpine
|
5 |
+
|
6 |
+
# Set working directory
|
7 |
+
WORKDIR /app
|
8 |
+
|
9 |
+
# Create user for HuggingFace Spaces (user ID 1000 as required)
|
10 |
+
# Simple approach: create user with any available UID, HF Spaces will handle the mapping
|
11 |
+
RUN adduser -D -s /bin/sh user || echo "User already exists"
|
12 |
+
|
13 |
+
# Install serve globally for serving static files
|
14 |
+
RUN npm install -g serve
|
15 |
+
|
16 |
+
# Copy package files with proper ownership
|
17 |
+
COPY --chown=user package*.json ./
|
18 |
+
|
19 |
+
# Install dependencies (use npm install since we don't have package-lock.json in git)
|
20 |
+
RUN npm install
|
21 |
+
|
22 |
+
# Copy source code with proper ownership
|
23 |
+
COPY --chown=user . .
|
24 |
+
|
25 |
+
# Set authentication environment variables for build
|
26 |
+
# These are hashed values so they're safe to include in the build
|
27 |
+
ENV VITE_AUTH_USERNAME_HASH=d86c4384ddade642a91c5bd72d1e428879a1e94e707b8c7c7f6242ca5f014dc8 \
|
28 |
+
VITE_AUTH_PASSWORD_HASH=a8e69ee9f4a340cd9c6264569efb4ecbe5f2ebdaac13313338008be2b7c945d8 \
|
29 |
+
VITE_AUTH_USER_ID=1 \
|
30 |
+
VITE_AUTH_USER_NAME="ACE Admin" \
|
31 | |
32 |
+
|
33 |
+
# Build the application
|
34 |
+
RUN npm run build
|
35 |
+
|
36 |
+
# Switch to user (required by HuggingFace Spaces)
|
37 |
+
USER user
|
38 |
+
|
39 |
+
# Set environment variables for HuggingFace Spaces
|
40 |
+
ENV HOME=/home/user \
|
41 |
+
PATH=/home/user/.local/bin:$PATH
|
42 |
+
|
43 |
+
# Expose port 7860 (required by HuggingFace Spaces)
|
44 |
+
EXPOSE 7860
|
45 |
+
|
46 |
+
# Start the application
|
47 |
+
CMD ["serve", "-s", "dist", "-l", "7860"]
|
app/.DS_Store
ADDED
Binary file (6.15 kB). View file
|
|
components/FileUpload.tsx
ADDED
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useCallback, useState } from 'react';
|
2 |
+
import { CsvInputRow, REQUIRED_CSV_HEADERS } from '../types';
|
3 |
+
import { UploadIcon } from './Icons';
|
4 |
+
|
5 |
+
// This tells TypeScript that `Papa` is available on the global window object.
|
6 |
+
declare const Papa: any;
|
7 |
+
|
8 |
+
interface FileUploadProps {
|
9 |
+
onFileParsed: (data: CsvInputRow[], file: File) => void;
|
10 |
+
onParseError: (message: string) => void;
|
11 |
+
disabled: boolean;
|
12 |
+
}
|
13 |
+
|
14 |
+
const FileUpload: React.FC<FileUploadProps> = ({ onFileParsed, onParseError, disabled }) => {
|
15 |
+
const [isDragging, setIsDragging] = useState(false);
|
16 |
+
|
17 |
+
const handleFile = useCallback((file: File) => {
|
18 |
+
if (!file) {
|
19 |
+
onParseError('No file selected.');
|
20 |
+
return;
|
21 |
+
}
|
22 |
+
if (file.type !== 'text/csv') {
|
23 |
+
onParseError('Invalid file type. Please upload a CSV file.');
|
24 |
+
return;
|
25 |
+
}
|
26 |
+
|
27 |
+
Papa.parse(file, {
|
28 |
+
header: true,
|
29 |
+
skipEmptyLines: true,
|
30 |
+
complete: (results: any) => {
|
31 |
+
if (results.errors.length > 0) {
|
32 |
+
console.error('CSV Parsing Errors:', results.errors);
|
33 |
+
onParseError(`Error parsing CSV: ${results.errors[0].message}`);
|
34 |
+
return;
|
35 |
+
}
|
36 |
+
|
37 |
+
const headers = results.meta.fields;
|
38 |
+
const missingHeaders = REQUIRED_CSV_HEADERS.filter(
|
39 |
+
(requiredHeader) => !headers.includes(requiredHeader)
|
40 |
+
);
|
41 |
+
|
42 |
+
if (missingHeaders.length > 0) {
|
43 |
+
onParseError(`Missing required CSV columns: ${missingHeaders.join(', ')}`);
|
44 |
+
return;
|
45 |
+
}
|
46 |
+
|
47 |
+
onFileParsed(results.data as CsvInputRow[], file);
|
48 |
+
},
|
49 |
+
error: (error: any) => {
|
50 |
+
console.error('PapaParse Error:', error);
|
51 |
+
onParseError('An unexpected error occurred while parsing the file.');
|
52 |
+
},
|
53 |
+
});
|
54 |
+
}, [onFileParsed, onParseError]);
|
55 |
+
|
56 |
+
const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
|
57 |
+
e.preventDefault();
|
58 |
+
e.stopPropagation();
|
59 |
+
if (!disabled) setIsDragging(true);
|
60 |
+
};
|
61 |
+
|
62 |
+
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
63 |
+
e.preventDefault();
|
64 |
+
e.stopPropagation();
|
65 |
+
setIsDragging(false);
|
66 |
+
};
|
67 |
+
|
68 |
+
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
69 |
+
e.preventDefault();
|
70 |
+
e.stopPropagation();
|
71 |
+
};
|
72 |
+
|
73 |
+
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
74 |
+
e.preventDefault();
|
75 |
+
e.stopPropagation();
|
76 |
+
setIsDragging(false);
|
77 |
+
if (disabled) return;
|
78 |
+
|
79 |
+
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
80 |
+
handleFile(e.dataTransfer.files[0]);
|
81 |
+
e.dataTransfer.clearData();
|
82 |
+
}
|
83 |
+
};
|
84 |
+
|
85 |
+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
86 |
+
if (e.target.files && e.target.files.length > 0) {
|
87 |
+
handleFile(e.target.files[0]);
|
88 |
+
}
|
89 |
+
};
|
90 |
+
|
91 |
+
const borderColor = isDragging ? 'border-blue-500' : 'border-gray-600';
|
92 |
+
const bgColor = isDragging ? 'bg-gray-700' : 'bg-gray-800';
|
93 |
+
|
94 |
+
return (
|
95 |
+
<div
|
96 |
+
className={`relative p-8 border-2 ${borderColor} border-dashed rounded-xl text-center transition-all duration-300 ${bgColor} ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
|
97 |
+
onDragEnter={handleDragEnter}
|
98 |
+
onDragLeave={handleDragLeave}
|
99 |
+
onDragOver={handleDragOver}
|
100 |
+
onDrop={handleDrop}
|
101 |
+
>
|
102 |
+
<input
|
103 |
+
type="file"
|
104 |
+
id="file-upload"
|
105 |
+
className="hidden"
|
106 |
+
accept=".csv"
|
107 |
+
onChange={handleInputChange}
|
108 |
+
disabled={disabled}
|
109 |
+
/>
|
110 |
+
<label htmlFor="file-upload" className={`${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`}>
|
111 |
+
<div className="flex flex-col items-center">
|
112 |
+
<UploadIcon className="w-12 h-12 text-gray-400 mb-4" />
|
113 |
+
<p className="text-lg font-semibold text-white">
|
114 |
+
Drag & drop your CSV file here
|
115 |
+
</p>
|
116 |
+
<p className="text-gray-400">or click to browse</p>
|
117 |
+
</div>
|
118 |
+
</label>
|
119 |
+
</div>
|
120 |
+
);
|
121 |
+
};
|
122 |
+
|
123 |
+
export default FileUpload;
|
components/Icons.tsx
ADDED
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from 'react';
|
2 |
+
|
3 |
+
type IconProps = {
|
4 |
+
className?: string;
|
5 |
+
};
|
6 |
+
|
7 |
+
export const UploadIcon: React.FC<IconProps> = ({ className }) => (
|
8 |
+
<svg className={className} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
9 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
10 |
+
</svg>
|
11 |
+
);
|
12 |
+
|
13 |
+
export const DownloadIcon: React.FC<IconProps> = ({ className }) => (
|
14 |
+
<svg className={className} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
15 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
16 |
+
</svg>
|
17 |
+
);
|
18 |
+
|
19 |
+
export const CheckCircleIcon: React.FC<IconProps> = ({ className }) => (
|
20 |
+
<svg className={className} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
21 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
22 |
+
</svg>
|
23 |
+
);
|
24 |
+
|
25 |
+
export const XCircleIcon: React.FC<IconProps> = ({ className }) => (
|
26 |
+
<svg className={className} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
27 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
28 |
+
</svg>
|
29 |
+
);
|
30 |
+
|
31 |
+
export const ClockIcon: React.FC<IconProps> = ({ className }) => (
|
32 |
+
<svg className={className} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
33 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
34 |
+
</svg>
|
35 |
+
);
|
36 |
+
|
37 |
+
export const RocketLaunchIcon: React.FC<IconProps> = ({ className }) => (
|
38 |
+
<svg className={className} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
39 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M15.59 14.37a6 6 0 01-5.84 7.38v-4.8m5.84-2.58a14.98 14.98 0 006.16-12.12A14.98 14.98 0 009.63 2.18a14.98 14.98 0 00-2.58 5.84m2.58 9.24v-4.8m0 0a6 6 0 01-7.38-5.84m0 0a14.98 14.98 0 00-12.12 6.16A14.98 14.98 0 002.18 14.37a14.98 14.98 0 005.84 2.58m8.34-12.73a6 6 0 00-7.38 5.84l-2.58 5.84m0 0a14.98 14.98 0 016.16 12.12 14.98 14.98 0 0110.5-10.5 14.98 14.98 0 01-2.58-5.84m-7.76 11.94a6 6 0 01-5.84-7.38 6 6 0 017.38-5.84m0 0a6 6 0 015.84 7.38 6 6 0 01-7.38 5.84z" />
|
40 |
+
</svg>
|
41 |
+
);
|
42 |
+
|
43 |
+
export const PlusIcon: React.FC<IconProps> = ({ className }) => (
|
44 |
+
<svg className={className} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
45 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
46 |
+
</svg>
|
47 |
+
);
|
48 |
+
|
49 |
+
export const HistoryIcon: React.FC<IconProps> = ({ className }) => (
|
50 |
+
<svg className={className} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
51 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
52 |
+
</svg>
|
53 |
+
);
|
54 |
+
|
55 |
+
export const DocumentTextIcon: React.FC<IconProps> = ({ className }) => (
|
56 |
+
<svg className={className} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
57 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
58 |
+
</svg>
|
59 |
+
);
|
60 |
+
|
61 |
+
export const EyeIcon: React.FC<IconProps> = ({ className }) => (
|
62 |
+
<svg className={className} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
63 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
64 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
65 |
+
</svg>
|
66 |
+
);
|
67 |
+
|
68 |
+
export const TrashIcon: React.FC<IconProps> = ({ className }) => (
|
69 |
+
<svg className={className} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
70 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
71 |
+
</svg>
|
72 |
+
);
|
components/ResultsTable.tsx
ADDED
@@ -0,0 +1,535 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
3 |
+
import ReactMarkdown from 'react-markdown';
|
4 |
+
import { ResultRow, DetailedQaReport, QaSectionResult } from '../types';
|
5 |
+
import { DownloadIcon, CheckCircleIcon, XCircleIcon, EyeIcon } from './Icons';
|
6 |
+
import jsPDF from 'jspdf';
|
7 |
+
|
8 |
+
// This tells TypeScript that `Papa` is available on the global window object.
|
9 |
+
declare const Papa: any;
|
10 |
+
|
11 |
+
interface ResultsTableProps {
|
12 |
+
results: ResultRow[];
|
13 |
+
}
|
14 |
+
|
15 |
+
type SortConfig = {
|
16 |
+
key: keyof ResultRow;
|
17 |
+
direction: 'ascending' | 'descending';
|
18 |
+
} | null;
|
19 |
+
|
20 |
+
const QAReportModal: React.FC<{ report: DetailedQaReport; onClose: () => void }> = ({ report, onClose }) => {
|
21 |
+
|
22 |
+
const Section: React.FC<{ title: string; data: QaSectionResult }> = ({ title, data }) => (
|
23 |
+
<div className="bg-gray-900/70 p-4 rounded-lg mb-4 border border-gray-700">
|
24 |
+
<h4 className="text-lg font-semibold text-white mb-3 flex items-center gap-3">
|
25 |
+
{data.pass
|
26 |
+
? <CheckCircleIcon className="w-6 h-6 text-green-400 flex-shrink-0" />
|
27 |
+
: <XCircleIcon className="w-6 h-6 text-red-400 flex-shrink-0" />
|
28 |
+
}
|
29 |
+
{title}
|
30 |
+
</h4>
|
31 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-2 text-sm pl-9">
|
32 |
+
<p><span className="font-semibold text-gray-400">Grade:</span> <span className="text-gray-200">{data.grade}</span></p>
|
33 |
+
<p><span className="font-semibold text-gray-400">Pass:</span> <span className={`font-medium ${data.pass ? 'text-green-400' : 'text-red-400'}`}>{data.pass ? 'Yes' : 'No'}</span></p>
|
34 |
+
</div>
|
35 |
+
<div className="pl-9 mt-3 text-sm">
|
36 |
+
<p className="font-semibold text-gray-400">Errors:</p>
|
37 |
+
<ul className="list-disc list-inside text-gray-300 pl-2 mt-1 space-y-1">
|
38 |
+
{data.errors.map((err, i) => <li key={i}>{err}</li>)}
|
39 |
+
</ul>
|
40 |
+
</div>
|
41 |
+
<div className="pl-9 mt-3 text-sm">
|
42 |
+
<p className="font-semibold text-gray-400">Content:</p>
|
43 |
+
<div className="text-gray-300 text-xs mt-1 bg-gray-950/50 p-3 rounded-md border border-gray-600/50 overflow-auto max-h-48">
|
44 |
+
<div className="prose prose-sm prose-invert max-w-none">
|
45 |
+
<ReactMarkdown
|
46 |
+
components={{
|
47 |
+
// Custom styling for markdown elements
|
48 |
+
p: ({ children }) => <p className="mb-2 last:mb-0 text-gray-300">{children}</p>,
|
49 |
+
ul: ({ children }) => <ul className="list-disc ml-4 mb-2 text-gray-300">{children}</ul>,
|
50 |
+
ol: ({ children }) => <ol className="list-decimal ml-4 mb-2 text-gray-300">{children}</ol>,
|
51 |
+
li: ({ children }) => <li className="mb-1 text-gray-300">{children}</li>,
|
52 |
+
code: ({ children }) => <code className="bg-gray-800 px-1 py-0.5 rounded text-blue-300 text-xs">{children}</code>,
|
53 |
+
strong: ({ children }) => <strong className="font-semibold text-white">{children}</strong>,
|
54 |
+
em: ({ children }) => <em className="italic text-gray-200">{children}</em>,
|
55 |
+
h1: ({ children }) => <h1 className="text-lg font-bold mb-2 text-white">{children}</h1>,
|
56 |
+
h2: ({ children }) => <h2 className="text-base font-semibold mb-2 text-white">{children}</h2>,
|
57 |
+
h3: ({ children }) => <h3 className="text-sm font-semibold mb-1 text-white">{children}</h3>,
|
58 |
+
}}
|
59 |
+
>
|
60 |
+
{data.corrected}
|
61 |
+
</ReactMarkdown>
|
62 |
+
</div>
|
63 |
+
</div>
|
64 |
+
</div>
|
65 |
+
</div>
|
66 |
+
);
|
67 |
+
|
68 |
+
return (
|
69 |
+
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-50" onClick={onClose}>
|
70 |
+
<div className="bg-gray-800 rounded-lg shadow-xl p-6 max-w-4xl w-full max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
71 |
+
<div className="flex justify-between items-center mb-4 pb-4 border-b border-gray-700">
|
72 |
+
<h3 className="text-2xl font-bold text-white">QA Report Details</h3>
|
73 |
+
<button onClick={onClose} className="text-gray-400 hover:text-white text-3xl font-bold">×</button>
|
74 |
+
</div>
|
75 |
+
<div className="space-y-2">
|
76 |
+
<Section title="Title" data={report.title} />
|
77 |
+
<Section title="Meta Description" data={report.meta} />
|
78 |
+
<Section title="H1" data={report.h1} />
|
79 |
+
<Section title="Copy" data={report.copy} />
|
80 |
+
</div>
|
81 |
+
</div>
|
82 |
+
</div>
|
83 |
+
);
|
84 |
+
};
|
85 |
+
|
86 |
+
|
87 |
+
const ResultsTable: React.FC<ResultsTableProps> = ({ results }) => {
|
88 |
+
const [sortConfig, setSortConfig] = useState<SortConfig>(null);
|
89 |
+
const [selectedReport, setSelectedReport] = useState<DetailedQaReport | null>(null);
|
90 |
+
const [showDownloadMenu, setShowDownloadMenu] = useState(false);
|
91 |
+
const downloadMenuRef = useRef<HTMLDivElement>(null);
|
92 |
+
|
93 |
+
// Close dropdown when clicking outside
|
94 |
+
useEffect(() => {
|
95 |
+
const handleClickOutside = (event: MouseEvent) => {
|
96 |
+
if (downloadMenuRef.current && !downloadMenuRef.current.contains(event.target as Node)) {
|
97 |
+
setShowDownloadMenu(false);
|
98 |
+
}
|
99 |
+
};
|
100 |
+
|
101 |
+
document.addEventListener('mousedown', handleClickOutside);
|
102 |
+
return () => {
|
103 |
+
document.removeEventListener('mousedown', handleClickOutside);
|
104 |
+
};
|
105 |
+
}, []);
|
106 |
+
|
107 |
+
const handleDownloadCSV = () => {
|
108 |
+
if (results.length === 0) return;
|
109 |
+
|
110 |
+
const csvData = results.map(row => {
|
111 |
+
const report = row.detailedQaReport;
|
112 |
+
return {
|
113 |
+
'URL': row.URL,
|
114 |
+
'Page': row.Page,
|
115 |
+
'Keywords': row.Keywords,
|
116 |
+
'Original Title': row.Recommended_Title,
|
117 |
+
'Original H1': row.Recommended_H1,
|
118 |
+
'Original Copy': row.Copy,
|
119 |
+
'Internal Links': row.Internal_Links,
|
120 |
+
'Generated Title': row.generatedTitle,
|
121 |
+
'Generated H1': row.generatedH1,
|
122 |
+
'Generated Meta': row.generatedMeta,
|
123 |
+
'Generated Copy': row.generatedCopy,
|
124 |
+
'Overall Pass': row.overallPass,
|
125 |
+
'Overall Grade': row.overallGrade,
|
126 |
+
'Title Pass': report?.title.pass,
|
127 |
+
'Title Grade': report?.title.grade,
|
128 |
+
'Title Errors': report?.title.errors.join('; '),
|
129 |
+
'Meta Pass': report?.meta.pass,
|
130 |
+
'Meta Grade': report?.meta.grade,
|
131 |
+
'Meta Errors': report?.meta.errors.join('; '),
|
132 |
+
'H1 Pass': report?.h1.pass,
|
133 |
+
'H1 Grade': report?.h1.grade,
|
134 |
+
'H1 Errors': report?.h1.errors.join('; '),
|
135 |
+
'Copy Pass': report?.copy.pass,
|
136 |
+
'Copy Grade': report?.copy.grade,
|
137 |
+
'Copy Errors': report?.copy.errors.join('; '),
|
138 |
+
'QA Full Report': row.qaReport,
|
139 |
+
};
|
140 |
+
});
|
141 |
+
|
142 |
+
const csv = Papa.unparse(csvData);
|
143 |
+
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
144 |
+
const link = document.createElement('a');
|
145 |
+
const url = URL.createObjectURL(blob);
|
146 |
+
link.setAttribute('href', url);
|
147 |
+
link.setAttribute('download', 'ace_copywriting_results.csv');
|
148 |
+
document.body.appendChild(link);
|
149 |
+
link.click();
|
150 |
+
document.body.removeChild(link);
|
151 |
+
setShowDownloadMenu(false);
|
152 |
+
};
|
153 |
+
|
154 |
+
const handleDownloadPDF = () => {
|
155 |
+
if (results.length === 0) return;
|
156 |
+
|
157 |
+
const pdf = new jsPDF();
|
158 |
+
const pageWidth = pdf.internal.pageSize.getWidth();
|
159 |
+
const pageHeight = pdf.internal.pageSize.getHeight();
|
160 |
+
const margin = 20;
|
161 |
+
const maxLineWidth = pageWidth - (margin * 2);
|
162 |
+
|
163 |
+
let currentY = margin;
|
164 |
+
const lineHeight = 6;
|
165 |
+
const sectionSpacing = 10;
|
166 |
+
|
167 |
+
// Helper function to add text with word wrapping and automatic page breaks
|
168 |
+
const addWrappedText = (text: string, x: number, y: number, maxWidth: number, fontSize: number = 12): number => {
|
169 |
+
pdf.setFontSize(fontSize);
|
170 |
+
const lines = pdf.splitTextToSize(text, maxWidth);
|
171 |
+
let currentLineY = y;
|
172 |
+
|
173 |
+
for (let i = 0; i < lines.length; i++) {
|
174 |
+
// Check if we need a new page for this line
|
175 |
+
if (currentLineY + lineHeight > pageHeight - margin) {
|
176 |
+
pdf.addPage();
|
177 |
+
currentLineY = margin;
|
178 |
+
}
|
179 |
+
|
180 |
+
pdf.text(lines[i], x, currentLineY);
|
181 |
+
currentLineY += lineHeight;
|
182 |
+
}
|
183 |
+
|
184 |
+
return currentLineY;
|
185 |
+
};
|
186 |
+
|
187 |
+
// Helper function to ensure minimum space on page for section headers
|
188 |
+
const ensureMinSpace = (minHeight: number = 40): number => {
|
189 |
+
if (currentY + minHeight > pageHeight - margin) {
|
190 |
+
pdf.addPage();
|
191 |
+
return margin;
|
192 |
+
}
|
193 |
+
return currentY;
|
194 |
+
};
|
195 |
+
|
196 |
+
// Title
|
197 |
+
pdf.setFontSize(20);
|
198 |
+
pdf.setFont('helvetica', 'bold');
|
199 |
+
pdf.text('ACE Copywriting Pipeline Results', margin, currentY);
|
200 |
+
currentY += 15;
|
201 |
+
|
202 |
+
// Summary
|
203 |
+
pdf.setFontSize(12);
|
204 |
+
pdf.setFont('helvetica', 'normal');
|
205 |
+
const totalResults = results.length;
|
206 |
+
const passedResults = results.filter(r => r.overallPass).length;
|
207 |
+
const summaryText = `Generated ${totalResults} results | ${passedResults} passed QA | ${totalResults - passedResults} failed QA`;
|
208 |
+
currentY = addWrappedText(summaryText, margin, currentY, maxLineWidth);
|
209 |
+
currentY += sectionSpacing;
|
210 |
+
|
211 |
+
// Results
|
212 |
+
results.forEach((row, index) => {
|
213 |
+
// Estimate space needed for this entry based on content length
|
214 |
+
const estimatedHeight = 50 + // Base height for headers and metadata
|
215 |
+
Math.ceil((row.generatedTitle?.length || 0) / 80) * 6 + // Title
|
216 |
+
Math.ceil((row.generatedH1?.length || 0) / 80) * 6 + // H1
|
217 |
+
Math.ceil((row.generatedMeta?.length || 0) / 80) * 6 + // Meta
|
218 |
+
Math.ceil((row.generatedCopy?.length || 0) / 100) * 5; // Copy (smaller font)
|
219 |
+
|
220 |
+
// Ensure minimum space for section header
|
221 |
+
currentY = ensureMinSpace(40);
|
222 |
+
|
223 |
+
// Page/URL Header
|
224 |
+
pdf.setFontSize(14);
|
225 |
+
pdf.setFont('helvetica', 'bold');
|
226 |
+
currentY = addWrappedText(`${index + 1}. ${row.Page || 'Page'} (${row.URL})`, margin, currentY, maxLineWidth, 14);
|
227 |
+
currentY += 5;
|
228 |
+
|
229 |
+
// Keywords
|
230 |
+
pdf.setFontSize(10);
|
231 |
+
pdf.setFont('helvetica', 'normal');
|
232 |
+
currentY = addWrappedText(`Keywords: ${row.Keywords}`, margin, currentY, maxLineWidth, 10);
|
233 |
+
currentY += 3;
|
234 |
+
|
235 |
+
// Overall QA Status
|
236 |
+
pdf.setFont('helvetica', 'bold');
|
237 |
+
if (row.overallPass) {
|
238 |
+
pdf.setTextColor(0, 128, 0);
|
239 |
+
} else {
|
240 |
+
pdf.setTextColor(255, 0, 0);
|
241 |
+
}
|
242 |
+
currentY = addWrappedText(`Overall QA: ${row.overallPass ? 'PASS' : 'FAIL'} (${row.overallGrade})`, margin, currentY, maxLineWidth, 10);
|
243 |
+
pdf.setTextColor(0, 0, 0);
|
244 |
+
currentY += 5;
|
245 |
+
|
246 |
+
// Generated Content
|
247 |
+
pdf.setFont('helvetica', 'bold');
|
248 |
+
currentY = addWrappedText('Generated Title:', margin, currentY, maxLineWidth, 10);
|
249 |
+
pdf.setFont('helvetica', 'normal');
|
250 |
+
currentY = addWrappedText(row.generatedTitle || '', margin, currentY, maxLineWidth, 9);
|
251 |
+
currentY += 3;
|
252 |
+
|
253 |
+
pdf.setFont('helvetica', 'bold');
|
254 |
+
currentY = addWrappedText('Generated H1:', margin, currentY, maxLineWidth, 10);
|
255 |
+
pdf.setFont('helvetica', 'normal');
|
256 |
+
currentY = addWrappedText(row.generatedH1 || '', margin, currentY, maxLineWidth, 9);
|
257 |
+
currentY += 3;
|
258 |
+
|
259 |
+
pdf.setFont('helvetica', 'bold');
|
260 |
+
currentY = addWrappedText('Generated Meta:', margin, currentY, maxLineWidth, 10);
|
261 |
+
pdf.setFont('helvetica', 'normal');
|
262 |
+
currentY = addWrappedText(row.generatedMeta || '', margin, currentY, maxLineWidth, 9);
|
263 |
+
currentY += 3;
|
264 |
+
|
265 |
+
// Generated Copy (full content)
|
266 |
+
pdf.setFont('helvetica', 'bold');
|
267 |
+
currentY = addWrappedText('Generated Copy:', margin, currentY, maxLineWidth, 10);
|
268 |
+
pdf.setFont('helvetica', 'normal');
|
269 |
+
|
270 |
+
// Add the full copy content with proper spacing
|
271 |
+
if (row.generatedCopy) {
|
272 |
+
currentY = addWrappedText(row.generatedCopy, margin, currentY, maxLineWidth, 9);
|
273 |
+
}
|
274 |
+
|
275 |
+
// Add QA Details if available
|
276 |
+
if (row.detailedQaReport) {
|
277 |
+
currentY = ensureMinSpace(60); // Ensure space for QA header + at least one section
|
278 |
+
currentY += 5;
|
279 |
+
pdf.setFontSize(12);
|
280 |
+
pdf.setFont('helvetica', 'bold');
|
281 |
+
currentY = addWrappedText('QA Report Details:', margin + 5, currentY, maxLineWidth - 5, 12);
|
282 |
+
currentY += 2;
|
283 |
+
|
284 |
+
const addQaSection = (title: string, section: QaSectionResult) => {
|
285 |
+
currentY = ensureMinSpace(30);
|
286 |
+
pdf.setFontSize(10);
|
287 |
+
pdf.setFont('helvetica', 'bold');
|
288 |
+
|
289 |
+
if (section.pass) {
|
290 |
+
pdf.setTextColor(0, 128, 0); // Green for PASS
|
291 |
+
} else {
|
292 |
+
pdf.setTextColor(255, 0, 0); // Red for FAIL
|
293 |
+
}
|
294 |
+
currentY = addWrappedText(`${title}: ${section.pass ? 'PASS' : 'FAIL'} (Grade: ${section.grade})`, margin + 5, currentY, maxLineWidth - 5, 10);
|
295 |
+
pdf.setTextColor(0, 0, 0); // Reset color
|
296 |
+
|
297 |
+
pdf.setFont('helvetica', 'normal');
|
298 |
+
currentY = addWrappedText(`Errors: ${section.errors.join(', ')}`, margin + 10, currentY, maxLineWidth - 10, 8);
|
299 |
+
|
300 |
+
currentY = ensureMinSpace(20);
|
301 |
+
pdf.setFont('helvetica', 'italic');
|
302 |
+
currentY = addWrappedText(`Correction/Analysis: ${section.corrected}`, margin + 10, currentY, maxLineWidth - 10, 8);
|
303 |
+
currentY += 4;
|
304 |
+
};
|
305 |
+
|
306 |
+
addQaSection('Title', row.detailedQaReport.title);
|
307 |
+
addQaSection('Meta', row.detailedQaReport.meta);
|
308 |
+
addQaSection('H1', row.detailedQaReport.h1);
|
309 |
+
addQaSection('Copy', row.detailedQaReport.copy);
|
310 |
+
}
|
311 |
+
|
312 |
+
currentY += sectionSpacing * 2; // Extra spacing between entries
|
313 |
+
});
|
314 |
+
|
315 |
+
// Footer on last page
|
316 |
+
pdf.setFontSize(8);
|
317 |
+
pdf.setTextColor(128, 128, 128);
|
318 |
+
pdf.text('Generated by ACE Copywriting Pipeline', margin, pageHeight - 10);
|
319 |
+
|
320 |
+
// Save the PDF
|
321 |
+
pdf.save('ace_copywriting_results.pdf');
|
322 |
+
setShowDownloadMenu(false);
|
323 |
+
};
|
324 |
+
|
325 |
+
const handleDownloadJSON = () => {
|
326 |
+
if (results.length === 0) return;
|
327 |
+
|
328 |
+
const jsonData = {
|
329 |
+
metadata: {
|
330 |
+
generatedAt: new Date().toISOString(),
|
331 |
+
totalResults: results.length,
|
332 |
+
passedResults: results.filter(r => r.overallPass).length,
|
333 |
+
failedResults: results.filter(r => !r.overallPass).length
|
334 |
+
},
|
335 |
+
results: results.map(row => ({
|
336 |
+
url: row.URL,
|
337 |
+
page: row.Page,
|
338 |
+
keywords: row.Keywords,
|
339 |
+
original: {
|
340 |
+
title: row.Recommended_Title,
|
341 |
+
h1: row.Recommended_H1,
|
342 |
+
copy: row.Copy,
|
343 |
+
internalLinks: row.Internal_Links
|
344 |
+
},
|
345 |
+
generated: {
|
346 |
+
title: row.generatedTitle,
|
347 |
+
h1: row.generatedH1,
|
348 |
+
meta: row.generatedMeta,
|
349 |
+
copy: row.generatedCopy
|
350 |
+
},
|
351 |
+
qa: {
|
352 |
+
overallPass: row.overallPass,
|
353 |
+
overallGrade: row.overallGrade,
|
354 |
+
sections: {
|
355 |
+
title: {
|
356 |
+
pass: row.detailedQaReport?.title.pass,
|
357 |
+
grade: row.detailedQaReport?.title.grade,
|
358 |
+
errors: row.detailedQaReport?.title.errors
|
359 |
+
},
|
360 |
+
meta: {
|
361 |
+
pass: row.detailedQaReport?.meta.pass,
|
362 |
+
grade: row.detailedQaReport?.meta.grade,
|
363 |
+
errors: row.detailedQaReport?.meta.errors
|
364 |
+
},
|
365 |
+
h1: {
|
366 |
+
pass: row.detailedQaReport?.h1.pass,
|
367 |
+
grade: row.detailedQaReport?.h1.grade,
|
368 |
+
errors: row.detailedQaReport?.h1.errors
|
369 |
+
},
|
370 |
+
copy: {
|
371 |
+
pass: row.detailedQaReport?.copy.pass,
|
372 |
+
grade: row.detailedQaReport?.copy.grade,
|
373 |
+
errors: row.detailedQaReport?.copy.errors
|
374 |
+
}
|
375 |
+
},
|
376 |
+
fullReport: row.qaReport
|
377 |
+
}
|
378 |
+
}))
|
379 |
+
};
|
380 |
+
|
381 |
+
const jsonString = JSON.stringify(jsonData, null, 2);
|
382 |
+
const blob = new Blob([jsonString], { type: 'application/json;charset=utf-8;' });
|
383 |
+
const link = document.createElement('a');
|
384 |
+
const url = URL.createObjectURL(blob);
|
385 |
+
link.setAttribute('href', url);
|
386 |
+
link.setAttribute('download', 'ace_copywriting_results.json');
|
387 |
+
document.body.appendChild(link);
|
388 |
+
link.click();
|
389 |
+
document.body.removeChild(link);
|
390 |
+
setShowDownloadMenu(false);
|
391 |
+
};
|
392 |
+
|
393 |
+
const sortedResults = useMemo(() => {
|
394 |
+
let sortableItems = [...results];
|
395 |
+
if (sortConfig !== null) {
|
396 |
+
sortableItems.sort((a, b) => {
|
397 |
+
const key = sortConfig.key;
|
398 |
+
const valA = a[key as keyof typeof a];
|
399 |
+
const valB = b[key as keyof typeof b];
|
400 |
+
|
401 |
+
if (typeof valA === 'boolean' && typeof valB === 'boolean') {
|
402 |
+
if (valA === valB) return 0;
|
403 |
+
return sortConfig.direction === 'ascending' ? (valA ? -1 : 1) : (valA ? 1 : -1);
|
404 |
+
}
|
405 |
+
|
406 |
+
if (valA < valB) {
|
407 |
+
return sortConfig.direction === 'ascending' ? -1 : 1;
|
408 |
+
}
|
409 |
+
if (valA > valB) {
|
410 |
+
return sortConfig.direction === 'ascending' ? 1 : -1;
|
411 |
+
}
|
412 |
+
return 0;
|
413 |
+
});
|
414 |
+
}
|
415 |
+
return sortableItems;
|
416 |
+
}, [results, sortConfig]);
|
417 |
+
|
418 |
+
const requestSort = (key: keyof ResultRow) => {
|
419 |
+
let direction: 'ascending' | 'descending' = 'ascending';
|
420 |
+
if (sortConfig && sortConfig.key === key && sortConfig.direction === 'ascending') {
|
421 |
+
direction = 'descending';
|
422 |
+
}
|
423 |
+
setSortConfig({ key, direction });
|
424 |
+
};
|
425 |
+
|
426 |
+
const getSortIndicator = (key: keyof ResultRow) => {
|
427 |
+
if (!sortConfig || sortConfig.key !== key) {
|
428 |
+
return ' ↕';
|
429 |
+
}
|
430 |
+
return sortConfig.direction === 'ascending' ? ' ▲' : ' ▼';
|
431 |
+
};
|
432 |
+
|
433 |
+
if (results.length === 0) {
|
434 |
+
return (
|
435 |
+
<div className="bg-gray-800 rounded-xl shadow-lg p-6 text-center">
|
436 |
+
<h2 className="text-2xl font-semibold text-white">Generated Content</h2>
|
437 |
+
<p className="mt-4 text-gray-400">No results to display yet. Process a file to see the output here.</p>
|
438 |
+
</div>
|
439 |
+
)
|
440 |
+
}
|
441 |
+
|
442 |
+
return (
|
443 |
+
<>
|
444 |
+
<div className="bg-gray-800 rounded-xl shadow-lg p-6 w-full">
|
445 |
+
<div className="flex justify-between items-center mb-4">
|
446 |
+
<h2 className="text-2xl font-semibold text-white">Generated Content</h2>
|
447 |
+
<div className="relative" ref={downloadMenuRef}>
|
448 |
+
<button
|
449 |
+
onClick={() => setShowDownloadMenu(!showDownloadMenu)}
|
450 |
+
className="flex items-center gap-2 bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-200"
|
451 |
+
>
|
452 |
+
<DownloadIcon className="w-5 h-5"/>
|
453 |
+
Download
|
454 |
+
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
455 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
456 |
+
</svg>
|
457 |
+
</button>
|
458 |
+
|
459 |
+
{showDownloadMenu && (
|
460 |
+
<div className="absolute right-0 mt-2 w-48 bg-gray-700 rounded-lg shadow-lg z-10 border border-gray-600">
|
461 |
+
<div className="py-1">
|
462 |
+
<button
|
463 |
+
onClick={handleDownloadCSV}
|
464 |
+
className="w-full text-left px-4 py-2 text-white hover:bg-gray-600 flex items-center gap-2"
|
465 |
+
>
|
466 |
+
📊 Download CSV
|
467 |
+
</button>
|
468 |
+
<button
|
469 |
+
onClick={handleDownloadPDF}
|
470 |
+
className="w-full text-left px-4 py-2 text-white hover:bg-gray-600 flex items-center gap-2"
|
471 |
+
>
|
472 |
+
📄 Download PDF
|
473 |
+
</button>
|
474 |
+
<button
|
475 |
+
onClick={handleDownloadJSON}
|
476 |
+
className="w-full text-left px-4 py-2 text-white hover:bg-gray-600 flex items-center gap-2"
|
477 |
+
>
|
478 |
+
💾 Download JSON
|
479 |
+
</button>
|
480 |
+
</div>
|
481 |
+
</div>
|
482 |
+
)}
|
483 |
+
</div>
|
484 |
+
</div>
|
485 |
+
<div className="overflow-x-auto">
|
486 |
+
<table className="w-full text-sm text-left text-gray-300">
|
487 |
+
<thead className="text-xs text-gray-400 uppercase bg-gray-700">
|
488 |
+
<tr>
|
489 |
+
<th scope="col" className="px-6 py-3 cursor-pointer" onClick={() => requestSort('URL')}>URL{getSortIndicator('URL')}</th>
|
490 |
+
<th scope="col" className="px-6 py-3">Generated Title</th>
|
491 |
+
<th scope="col" className="px-6 py-3">Generated H1</th>
|
492 |
+
<th scope="col" className="px-6 py-3 cursor-pointer" onClick={() => requestSort('overallPass')}>Overall Pass{getSortIndicator('overallPass')}</th>
|
493 |
+
<th scope="col" className="px-6 py-3 text-center">Details</th>
|
494 |
+
</tr>
|
495 |
+
</thead>
|
496 |
+
<tbody>
|
497 |
+
{sortedResults
|
498 |
+
.filter((row, index, arr) => arr.findIndex(r => r.id === row.id) === index) // Remove any runtime duplicates
|
499 |
+
.map((row) => (
|
500 |
+
<tr key={row.id} className="bg-gray-800 border-b border-gray-700 hover:bg-gray-700/50">
|
501 |
+
<td className="px-6 py-4 font-medium text-white max-w-xs truncate" title={row.URL}>{row.URL}</td>
|
502 |
+
<td className="px-6 py-4 max-w-xs truncate" title={row.generatedTitle}>{row.generatedTitle}</td>
|
503 |
+
<td className="px-6 py-4 max-w-xs truncate" title={row.generatedH1}>{row.generatedH1}</td>
|
504 |
+
<td className="px-6 py-4">
|
505 |
+
<div className="flex justify-center">
|
506 |
+
{row.overallPass
|
507 |
+
? <CheckCircleIcon className="w-6 h-6 text-green-400" />
|
508 |
+
: <XCircleIcon className="w-6 h-6 text-red-400" />
|
509 |
+
}
|
510 |
+
</div>
|
511 |
+
</td>
|
512 |
+
<td className="px-6 py-4 text-center">
|
513 |
+
<button
|
514 |
+
onClick={() => {
|
515 |
+
console.log('Opening QA modal with data:', row.detailedQaReport);
|
516 |
+
setSelectedReport(row.detailedQaReport || null);
|
517 |
+
}}
|
518 |
+
disabled={!row.detailedQaReport}
|
519 |
+
className="flex items-center justify-center mx-auto gap-1 text-blue-400 hover:text-blue-300 disabled:text-gray-500 disabled:cursor-not-allowed transition-colors"
|
520 |
+
>
|
521 |
+
<EyeIcon className="w-5 h-5" /> View
|
522 |
+
</button>
|
523 |
+
</td>
|
524 |
+
</tr>
|
525 |
+
))}
|
526 |
+
</tbody>
|
527 |
+
</table>
|
528 |
+
</div>
|
529 |
+
</div>
|
530 |
+
{selectedReport && <QAReportModal report={selectedReport} onClose={() => setSelectedReport(null)} />}
|
531 |
+
</>
|
532 |
+
);
|
533 |
+
};
|
534 |
+
|
535 |
+
export default ResultsTable;
|
components/SignInPage.tsx
ADDED
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useState, useEffect } from "react"
|
2 |
+
import { useAuth } from "../services/authService"
|
3 |
+
|
4 |
+
export default function SignInPage() {
|
5 |
+
const [username, setUsername] = useState("")
|
6 |
+
const [password, setPassword] = useState("")
|
7 |
+
const [error, setError] = useState("")
|
8 |
+
const { login, isAuthenticated, isLoading } = useAuth()
|
9 |
+
|
10 |
+
useEffect(() => {
|
11 |
+
// Redirect if already authenticated
|
12 |
+
if (isAuthenticated) {
|
13 |
+
window.location.href = "/"
|
14 |
+
}
|
15 |
+
}, [isAuthenticated])
|
16 |
+
|
17 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
18 |
+
e.preventDefault()
|
19 |
+
setError("")
|
20 |
+
|
21 |
+
const result = await login({ username, password })
|
22 |
+
|
23 |
+
if (!result.success) {
|
24 |
+
setError(result.error || "Invalid credentials")
|
25 |
+
} else {
|
26 |
+
// Redirect to main app
|
27 |
+
window.location.href = "/"
|
28 |
+
}
|
29 |
+
}
|
30 |
+
|
31 |
+
// Show loading spinner if checking auth state
|
32 |
+
if (isLoading) {
|
33 |
+
return (
|
34 |
+
<div className="min-h-screen flex items-center justify-center bg-gray-900">
|
35 |
+
<div className="text-white">Loading...</div>
|
36 |
+
</div>
|
37 |
+
)
|
38 |
+
}
|
39 |
+
|
40 |
+
return (
|
41 |
+
<div className="min-h-screen flex items-center justify-center bg-gray-900">
|
42 |
+
<div className="max-w-md w-full space-y-8 p-8">
|
43 |
+
<div>
|
44 |
+
<h2 className="mt-6 text-center text-3xl font-extrabold text-white">
|
45 |
+
ACE UI Login
|
46 |
+
</h2>
|
47 |
+
<p className="mt-2 text-center text-sm text-gray-400">
|
48 |
+
Enter your credentials to access the dashboard
|
49 |
+
</p>
|
50 |
+
</div>
|
51 |
+
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
52 |
+
<div className="space-y-4">
|
53 |
+
<div>
|
54 |
+
<label htmlFor="username" className="sr-only">
|
55 |
+
Username
|
56 |
+
</label>
|
57 |
+
<input
|
58 |
+
id="username"
|
59 |
+
name="username"
|
60 |
+
type="text"
|
61 |
+
required
|
62 |
+
className="relative block w-full px-3 py-2 border border-gray-600 rounded-md placeholder-gray-400 text-white bg-gray-800 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
63 |
+
placeholder="Username"
|
64 |
+
value={username}
|
65 |
+
onChange={(e) => setUsername(e.target.value)}
|
66 |
+
/>
|
67 |
+
</div>
|
68 |
+
<div>
|
69 |
+
<label htmlFor="password" className="sr-only">
|
70 |
+
Password
|
71 |
+
</label>
|
72 |
+
<input
|
73 |
+
id="password"
|
74 |
+
name="password"
|
75 |
+
type="password"
|
76 |
+
required
|
77 |
+
className="relative block w-full px-3 py-2 border border-gray-600 rounded-md placeholder-gray-400 text-white bg-gray-800 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
78 |
+
placeholder="Password"
|
79 |
+
value={password}
|
80 |
+
onChange={(e) => setPassword(e.target.value)}
|
81 |
+
/>
|
82 |
+
</div>
|
83 |
+
</div>
|
84 |
+
|
85 |
+
{error && (
|
86 |
+
<div className="text-red-400 text-sm text-center">{error}</div>
|
87 |
+
)}
|
88 |
+
|
89 |
+
<div>
|
90 |
+
<button
|
91 |
+
type="submit"
|
92 |
+
disabled={isLoading}
|
93 |
+
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
94 |
+
>
|
95 |
+
{isLoading ? "Signing in..." : "Sign in"}
|
96 |
+
</button>
|
97 |
+
</div>
|
98 |
+
</form>
|
99 |
+
</div>
|
100 |
+
</div>
|
101 |
+
)
|
102 |
+
}
|
components/Spinner.tsx
ADDED
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
import React from 'react';
|
3 |
+
|
4 |
+
const Spinner: React.FC = () => {
|
5 |
+
return (
|
6 |
+
<div className="w-5 h-5 border-2 border-t-transparent border-white rounded-full animate-spin"></div>
|
7 |
+
);
|
8 |
+
};
|
9 |
+
|
10 |
+
export default Spinner;
|
constants.ts
ADDED
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
export const API_URL = 'https://api.dify.ai/v1/workflows/run';
|
3 |
+
export const API_TOKEN = 'app-yufQq1vEs336l4r2R5zx4fwo';
|
4 |
+
export const API_USER = 'ace-copywriting-frontend-user';
|
dist/.DS_Store
ADDED
Binary file (6.15 kB). View file
|
|
dist/assets/html2canvas.esm-CBrSDip1.js
ADDED
The diff for this file is too large to render.
See raw diff
|
|
dist/assets/index-DQmHHp3O.js
ADDED
The diff for this file is too large to render.
See raw diff
|
|
dist/assets/index.es-B33Y2F_W.js
ADDED
The diff for this file is too large to render.
See raw diff
|
|
dist/assets/purify.es-C_uT9hQ1.js
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
/*! @license DOMPurify 2.5.8 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/2.5.8/LICENSE */function I(a){"@babel/helpers - typeof";return I=typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?function(n){return typeof n}:function(n){return n&&typeof Symbol=="function"&&n.constructor===Symbol&&n!==Symbol.prototype?"symbol":typeof n},I(a)}function Fe(a,n){return Fe=Object.setPrototypeOf||function(s,c){return s.__proto__=c,s},Fe(a,n)}function zt(){if(typeof Reflect>"u"||!Reflect.construct||Reflect.construct.sham)return!1;if(typeof Proxy=="function")return!0;try{return Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],function(){})),!0}catch{return!1}}function le(a,n,o){return zt()?le=Reflect.construct:le=function(c,g,y){var D=[null];D.push.apply(D,g);var x=Function.bind.apply(c,D),j=new x;return y&&Fe(j,y.prototype),j},le.apply(null,arguments)}function M(a){return Gt(a)||Wt(a)||Bt(a)||$t()}function Gt(a){if(Array.isArray(a))return Ue(a)}function Wt(a){if(typeof Symbol<"u"&&a[Symbol.iterator]!=null||a["@@iterator"]!=null)return Array.from(a)}function Bt(a,n){if(a){if(typeof a=="string")return Ue(a,n);var o=Object.prototype.toString.call(a).slice(8,-1);if(o==="Object"&&a.constructor&&(o=a.constructor.name),o==="Map"||o==="Set")return Array.from(a);if(o==="Arguments"||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(o))return Ue(a,n)}}function Ue(a,n){(n==null||n>a.length)&&(n=a.length);for(var o=0,s=new Array(n);o<n;o++)s[o]=a[o];return s}function $t(){throw new TypeError(`Invalid attempt to spread non-iterable instance.
|
2 |
+
In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}var jt=Object.hasOwnProperty,ut=Object.setPrototypeOf,Yt=Object.isFrozen,Xt=Object.getPrototypeOf,Vt=Object.getOwnPropertyDescriptor,E=Object.freeze,b=Object.seal,qt=Object.create,vt=typeof Reflect<"u"&&Reflect,ue=vt.apply,He=vt.construct;ue||(ue=function(n,o,s){return n.apply(o,s)});E||(E=function(n){return n});b||(b=function(n){return n});He||(He=function(n,o){return le(n,M(o))});var Kt=O(Array.prototype.forEach),ft=O(Array.prototype.pop),q=O(Array.prototype.push),se=O(String.prototype.toLowerCase),we=O(String.prototype.toString),ct=O(String.prototype.match),L=O(String.prototype.replace),Zt=O(String.prototype.indexOf),Jt=O(String.prototype.trim),_=O(RegExp.prototype.test),Ce=Qt(TypeError);function O(a){return function(n){for(var o=arguments.length,s=new Array(o>1?o-1:0),c=1;c<o;c++)s[c-1]=arguments[c];return ue(a,n,s)}}function Qt(a){return function(){for(var n=arguments.length,o=new Array(n),s=0;s<n;s++)o[s]=arguments[s];return He(a,o)}}function l(a,n,o){var s;o=(s=o)!==null&&s!==void 0?s:se,ut&&ut(a,null);for(var c=n.length;c--;){var g=n[c];if(typeof g=="string"){var y=o(g);y!==g&&(Yt(n)||(n[c]=y),g=y)}a[g]=!0}return a}function U(a){var n=qt(null),o;for(o in a)ue(jt,a,[o])===!0&&(n[o]=a[o]);return n}function ie(a,n){for(;a!==null;){var o=Vt(a,n);if(o){if(o.get)return O(o.get);if(typeof o.value=="function")return O(o.value)}a=Xt(a)}function s(c){return console.warn("fallback value for",c),null}return s}var mt=E(["a","abbr","acronym","address","area","article","aside","audio","b","bdi","bdo","big","blink","blockquote","body","br","button","canvas","caption","center","cite","code","col","colgroup","content","data","datalist","dd","decorator","del","details","dfn","dialog","dir","div","dl","dt","element","em","fieldset","figcaption","figure","font","footer","form","h1","h2","h3","h4","h5","h6","head","header","hgroup","hr","html","i","img","input","ins","kbd","label","legend","li","main","map","mark","marquee","menu","menuitem","meter","nav","nobr","ol","optgroup","option","output","p","picture","pre","progress","q","rp","rt","ruby","s","samp","section","select","shadow","small","source","spacer","span","strike","strong","style","sub","summary","sup","table","tbody","td","template","textarea","tfoot","th","thead","time","tr","track","tt","u","ul","var","video","wbr"]),Ie=E(["svg","a","altglyph","altglyphdef","altglyphitem","animatecolor","animatemotion","animatetransform","circle","clippath","defs","desc","ellipse","filter","font","g","glyph","glyphref","hkern","image","line","lineargradient","marker","mask","metadata","mpath","path","pattern","polygon","polyline","radialgradient","rect","stop","style","switch","symbol","text","textpath","title","tref","tspan","view","vkern"]),xe=E(["feBlend","feColorMatrix","feComponentTransfer","feComposite","feConvolveMatrix","feDiffuseLighting","feDisplacementMap","feDistantLight","feFlood","feFuncA","feFuncB","feFuncG","feFuncR","feGaussianBlur","feImage","feMerge","feMergeNode","feMorphology","feOffset","fePointLight","feSpecularLighting","feSpotLight","feTile","feTurbulence"]),er=E(["animate","color-profile","cursor","discard","fedropshadow","font-face","font-face-format","font-face-name","font-face-src","font-face-uri","foreignobject","hatch","hatchpath","mesh","meshgradient","meshpatch","meshrow","missing-glyph","script","set","solidcolor","unknown","use"]),ke=E(["math","menclose","merror","mfenced","mfrac","mglyph","mi","mlabeledtr","mmultiscripts","mn","mo","mover","mpadded","mphantom","mroot","mrow","ms","mspace","msqrt","mstyle","msub","msup","msubsup","mtable","mtd","mtext","mtr","munder","munderover"]),tr=E(["maction","maligngroup","malignmark","mlongdiv","mscarries","mscarry","msgroup","mstack","msline","msrow","semantics","annotation","annotation-xml","mprescripts","none"]),pt=E(["#text"]),dt=E(["accept","action","align","alt","autocapitalize","autocomplete","autopictureinpicture","autoplay","background","bgcolor","border","capture","cellpadding","cellspacing","checked","cite","class","clear","color","cols","colspan","controls","controlslist","coords","crossorigin","datetime","decoding","default","dir","disabled","disablepictureinpicture","disableremoteplayback","download","draggable","enctype","enterkeyhint","face","for","headers","height","hidden","high","href","hreflang","id","inputmode","integrity","ismap","kind","label","lang","list","loading","loop","low","max","maxlength","media","method","min","minlength","multiple","muted","name","nonce","noshade","novalidate","nowrap","open","optimum","pattern","placeholder","playsinline","poster","preload","pubdate","radiogroup","readonly","rel","required","rev","reversed","role","rows","rowspan","spellcheck","scope","selected","shape","size","sizes","span","srclang","start","src","srcset","step","style","summary","tabindex","title","translate","type","usemap","valign","value","width","xmlns","slot"]),Pe=E(["accent-height","accumulate","additive","alignment-baseline","ascent","attributename","attributetype","azimuth","basefrequency","baseline-shift","begin","bias","by","class","clip","clippathunits","clip-path","clip-rule","color","color-interpolation","color-interpolation-filters","color-profile","color-rendering","cx","cy","d","dx","dy","diffuseconstant","direction","display","divisor","dur","edgemode","elevation","end","fill","fill-opacity","fill-rule","filter","filterunits","flood-color","flood-opacity","font-family","font-size","font-size-adjust","font-stretch","font-style","font-variant","font-weight","fx","fy","g1","g2","glyph-name","glyphref","gradientunits","gradienttransform","height","href","id","image-rendering","in","in2","k","k1","k2","k3","k4","kerning","keypoints","keysplines","keytimes","lang","lengthadjust","letter-spacing","kernelmatrix","kernelunitlength","lighting-color","local","marker-end","marker-mid","marker-start","markerheight","markerunits","markerwidth","maskcontentunits","maskunits","max","mask","media","method","mode","min","name","numoctaves","offset","operator","opacity","order","orient","orientation","origin","overflow","paint-order","path","pathlength","patterncontentunits","patterntransform","patternunits","points","preservealpha","preserveaspectratio","primitiveunits","r","rx","ry","radius","refx","refy","repeatcount","repeatdur","restart","result","rotate","scale","seed","shape-rendering","specularconstant","specularexponent","spreadmethod","startoffset","stddeviation","stitchtiles","stop-color","stop-opacity","stroke-dasharray","stroke-dashoffset","stroke-linecap","stroke-linejoin","stroke-miterlimit","stroke-opacity","stroke","stroke-width","style","surfacescale","systemlanguage","tabindex","targetx","targety","transform","transform-origin","text-anchor","text-decoration","text-rendering","textlength","type","u1","u2","unicode","values","viewbox","visibility","version","vert-adv-y","vert-origin-x","vert-origin-y","width","word-spacing","wrap","writing-mode","xchannelselector","ychannelselector","x","x1","x2","xmlns","y","y1","y2","z","zoomandpan"]),Tt=E(["accent","accentunder","align","bevelled","close","columnsalign","columnlines","columnspan","denomalign","depth","dir","display","displaystyle","encoding","fence","frame","height","href","id","largeop","length","linethickness","lspace","lquote","mathbackground","mathcolor","mathsize","mathvariant","maxsize","minsize","movablelimits","notation","numalign","open","rowalign","rowlines","rowspacing","rowspan","rspace","rquote","scriptlevel","scriptminsize","scriptsizemultiplier","selection","separator","separators","stretchy","subscriptshift","supscriptshift","symmetric","voffset","width","xmlns"]),oe=E(["xlink:href","xml:id","xlink:title","xml:space","xmlns:xlink"]),rr=b(/\{\{[\w\W]*|[\w\W]*\}\}/gm),ar=b(/<%[\w\W]*|[\w\W]*%>/gm),nr=b(/\${[\w\W]*}/gm),ir=b(/^data-[\-\w.\u00B7-\uFFFF]+$/),or=b(/^aria-[\-\w]+$/),lr=b(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),sr=b(/^(?:\w+script|data):/i),ur=b(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),fr=b(/^html$/i),cr=b(/^[a-z][.\w]*(-[.\w]+)+$/i),mr=function(){return typeof window>"u"?null:window},pr=function(n,o){if(I(n)!=="object"||typeof n.createPolicy!="function")return null;var s=null,c="data-tt-policy-suffix";o.currentScript&&o.currentScript.hasAttribute(c)&&(s=o.currentScript.getAttribute(c));var g="dompurify"+(s?"#"+s:"");try{return n.createPolicy(g,{createHTML:function(D){return D},createScriptURL:function(D){return D}})}catch{return console.warn("TrustedTypes policy "+g+" could not be created."),null}};function _t(){var a=arguments.length>0&&arguments[0]!==void 0?arguments[0]:mr(),n=function(e){return _t(e)};if(n.version="2.5.8",n.removed=[],!a||!a.document||a.document.nodeType!==9)return n.isSupported=!1,n;var o=a.document,s=a.document,c=a.DocumentFragment,g=a.HTMLTemplateElement,y=a.Node,D=a.Element,x=a.NodeFilter,j=a.NamedNodeMap,ht=j===void 0?a.NamedNodeMap||a.MozNamedAttrMap:j,Et=a.HTMLFormElement,At=a.DOMParser,K=a.trustedTypes,Z=D.prototype,yt=ie(Z,"cloneNode"),gt=ie(Z,"nextSibling"),St=ie(Z,"childNodes"),fe=ie(Z,"parentNode");if(typeof g=="function"){var ce=s.createElement("template");ce.content&&ce.content.ownerDocument&&(s=ce.content.ownerDocument)}var R=pr(K,o),me=R?R.createHTML(""):"",J=s,pe=J.implementation,bt=J.createNodeIterator,Ot=J.createDocumentFragment,Rt=J.getElementsByTagName,Lt=o.importNode,ze={};try{ze=U(s).documentMode?s.documentMode:{}}catch{}var N={};n.isSupported=typeof fe=="function"&&pe&&pe.createHTMLDocument!==void 0&&ze!==9;var de=rr,Te=ar,ve=nr,Mt=ir,Dt=or,Nt=sr,Ge=ur,wt=cr,_e=lr,p=null,We=l({},[].concat(M(mt),M(Ie),M(xe),M(ke),M(pt))),d=null,Be=l({},[].concat(M(dt),M(Pe),M(Tt),M(oe))),f=Object.seal(Object.create(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),Y=null,he=null,$e=!0,Ee=!0,je=!1,Ye=!0,H=!1,Ae=!0,k=!1,ye=!1,ge=!1,z=!1,Q=!1,ee=!1,Xe=!0,Ve=!1,Ct="user-content-",Se=!0,X=!1,G={},W=null,qe=l({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]),Ke=null,Ze=l({},["audio","video","img","source","image","track"]),be=null,Je=l({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),te="http://www.w3.org/1998/Math/MathML",re="http://www.w3.org/2000/svg",w="http://www.w3.org/1999/xhtml",B=w,Oe=!1,Re=null,It=l({},[te,re,w],we),P,xt=["application/xhtml+xml","text/html"],kt="text/html",T,$=null,Pt=s.createElement("form"),Qe=function(e){return e instanceof RegExp||e instanceof Function},Le=function(e){$&&$===e||((!e||I(e)!=="object")&&(e={}),e=U(e),P=xt.indexOf(e.PARSER_MEDIA_TYPE)===-1?P=kt:P=e.PARSER_MEDIA_TYPE,T=P==="application/xhtml+xml"?we:se,p="ALLOWED_TAGS"in e?l({},e.ALLOWED_TAGS,T):We,d="ALLOWED_ATTR"in e?l({},e.ALLOWED_ATTR,T):Be,Re="ALLOWED_NAMESPACES"in e?l({},e.ALLOWED_NAMESPACES,we):It,be="ADD_URI_SAFE_ATTR"in e?l(U(Je),e.ADD_URI_SAFE_ATTR,T):Je,Ke="ADD_DATA_URI_TAGS"in e?l(U(Ze),e.ADD_DATA_URI_TAGS,T):Ze,W="FORBID_CONTENTS"in e?l({},e.FORBID_CONTENTS,T):qe,Y="FORBID_TAGS"in e?l({},e.FORBID_TAGS,T):{},he="FORBID_ATTR"in e?l({},e.FORBID_ATTR,T):{},G="USE_PROFILES"in e?e.USE_PROFILES:!1,$e=e.ALLOW_ARIA_ATTR!==!1,Ee=e.ALLOW_DATA_ATTR!==!1,je=e.ALLOW_UNKNOWN_PROTOCOLS||!1,Ye=e.ALLOW_SELF_CLOSE_IN_ATTR!==!1,H=e.SAFE_FOR_TEMPLATES||!1,Ae=e.SAFE_FOR_XML!==!1,k=e.WHOLE_DOCUMENT||!1,z=e.RETURN_DOM||!1,Q=e.RETURN_DOM_FRAGMENT||!1,ee=e.RETURN_TRUSTED_TYPE||!1,ge=e.FORCE_BODY||!1,Xe=e.SANITIZE_DOM!==!1,Ve=e.SANITIZE_NAMED_PROPS||!1,Se=e.KEEP_CONTENT!==!1,X=e.IN_PLACE||!1,_e=e.ALLOWED_URI_REGEXP||_e,B=e.NAMESPACE||w,f=e.CUSTOM_ELEMENT_HANDLING||{},e.CUSTOM_ELEMENT_HANDLING&&Qe(e.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(f.tagNameCheck=e.CUSTOM_ELEMENT_HANDLING.tagNameCheck),e.CUSTOM_ELEMENT_HANDLING&&Qe(e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(f.attributeNameCheck=e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),e.CUSTOM_ELEMENT_HANDLING&&typeof e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements=="boolean"&&(f.allowCustomizedBuiltInElements=e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),H&&(Ee=!1),Q&&(z=!0),G&&(p=l({},M(pt)),d=[],G.html===!0&&(l(p,mt),l(d,dt)),G.svg===!0&&(l(p,Ie),l(d,Pe),l(d,oe)),G.svgFilters===!0&&(l(p,xe),l(d,Pe),l(d,oe)),G.mathMl===!0&&(l(p,ke),l(d,Tt),l(d,oe))),e.ADD_TAGS&&(p===We&&(p=U(p)),l(p,e.ADD_TAGS,T)),e.ADD_ATTR&&(d===Be&&(d=U(d)),l(d,e.ADD_ATTR,T)),e.ADD_URI_SAFE_ATTR&&l(be,e.ADD_URI_SAFE_ATTR,T),e.FORBID_CONTENTS&&(W===qe&&(W=U(W)),l(W,e.FORBID_CONTENTS,T)),Se&&(p["#text"]=!0),k&&l(p,["html","head","body"]),p.table&&(l(p,["tbody"]),delete Y.tbody),E&&E(e),$=e)},et=l({},["mi","mo","mn","ms","mtext"]),tt=l({},["annotation-xml"]),Ft=l({},["title","style","font","a","script"]),ae=l({},Ie);l(ae,xe),l(ae,er);var Me=l({},ke);l(Me,tr);var Ut=function(e){var t=fe(e);(!t||!t.tagName)&&(t={namespaceURI:B,tagName:"template"});var r=se(e.tagName),u=se(t.tagName);return Re[e.namespaceURI]?e.namespaceURI===re?t.namespaceURI===w?r==="svg":t.namespaceURI===te?r==="svg"&&(u==="annotation-xml"||et[u]):!!ae[r]:e.namespaceURI===te?t.namespaceURI===w?r==="math":t.namespaceURI===re?r==="math"&&tt[u]:!!Me[r]:e.namespaceURI===w?t.namespaceURI===re&&!tt[u]||t.namespaceURI===te&&!et[u]?!1:!Me[r]&&(Ft[r]||!ae[r]):!!(P==="application/xhtml+xml"&&Re[e.namespaceURI]):!1},S=function(e){q(n.removed,{element:e});try{e.parentNode.removeChild(e)}catch{try{e.outerHTML=me}catch{e.remove()}}},ne=function(e,t){try{q(n.removed,{attribute:t.getAttributeNode(e),from:t})}catch{q(n.removed,{attribute:null,from:t})}if(t.removeAttribute(e),e==="is"&&!d[e])if(z||Q)try{S(t)}catch{}else try{t.setAttribute(e,"")}catch{}},rt=function(e){var t,r;if(ge)e="<remove></remove>"+e;else{var u=ct(e,/^[\r\n\t ]+/);r=u&&u[0]}P==="application/xhtml+xml"&&B===w&&(e='<html xmlns="http://www.w3.org/1999/xhtml"><head></head><body>'+e+"</body></html>");var A=R?R.createHTML(e):e;if(B===w)try{t=new At().parseFromString(A,P)}catch{}if(!t||!t.documentElement){t=pe.createDocument(B,"template",null);try{t.documentElement.innerHTML=Oe?me:A}catch{}}var h=t.body||t.documentElement;return e&&r&&h.insertBefore(s.createTextNode(r),h.childNodes[0]||null),B===w?Rt.call(t,k?"html":"body")[0]:k?t.documentElement:h},at=function(e){return bt.call(e.ownerDocument||e,e,x.SHOW_ELEMENT|x.SHOW_COMMENT|x.SHOW_TEXT|x.SHOW_PROCESSING_INSTRUCTION|x.SHOW_CDATA_SECTION,null,!1)},De=function(e){return e instanceof Et&&(typeof e.nodeName!="string"||typeof e.textContent!="string"||typeof e.removeChild!="function"||!(e.attributes instanceof ht)||typeof e.removeAttribute!="function"||typeof e.setAttribute!="function"||typeof e.namespaceURI!="string"||typeof e.insertBefore!="function"||typeof e.hasChildNodes!="function")},V=function(e){return I(y)==="object"?e instanceof y:e&&I(e)==="object"&&typeof e.nodeType=="number"&&typeof e.nodeName=="string"},C=function(e,t,r){N[e]&&Kt(N[e],function(u){u.call(n,t,r,$)})},nt=function(e){var t;if(C("beforeSanitizeElements",e,null),De(e)||_(/[\u0080-\uFFFF]/,e.nodeName))return S(e),!0;var r=T(e.nodeName);if(C("uponSanitizeElement",e,{tagName:r,allowedTags:p}),e.hasChildNodes()&&!V(e.firstElementChild)&&(!V(e.content)||!V(e.content.firstElementChild))&&_(/<[/\w]/g,e.innerHTML)&&_(/<[/\w]/g,e.textContent)||r==="select"&&_(/<template/i,e.innerHTML)||e.nodeType===7||Ae&&e.nodeType===8&&_(/<[/\w]/g,e.data))return S(e),!0;if(!p[r]||Y[r]){if(!Y[r]&&ot(r)&&(f.tagNameCheck instanceof RegExp&&_(f.tagNameCheck,r)||f.tagNameCheck instanceof Function&&f.tagNameCheck(r)))return!1;if(Se&&!W[r]){var u=fe(e)||e.parentNode,A=St(e)||e.childNodes;if(A&&u)for(var h=A.length,v=h-1;v>=0;--v){var F=yt(A[v],!0);F.__removalCount=(e.__removalCount||0)+1,u.insertBefore(F,gt(e))}}return S(e),!0}return e instanceof D&&!Ut(e)||(r==="noscript"||r==="noembed"||r==="noframes")&&_(/<\/no(script|embed|frames)/i,e.innerHTML)?(S(e),!0):(H&&e.nodeType===3&&(t=e.textContent,t=L(t,de," "),t=L(t,Te," "),t=L(t,ve," "),e.textContent!==t&&(q(n.removed,{element:e.cloneNode()}),e.textContent=t)),C("afterSanitizeElements",e,null),!1)},it=function(e,t,r){if(Xe&&(t==="id"||t==="name")&&(r in s||r in Pt))return!1;if(!(Ee&&!he[t]&&_(Mt,t))){if(!($e&&_(Dt,t))){if(!d[t]||he[t]){if(!(ot(e)&&(f.tagNameCheck instanceof RegExp&&_(f.tagNameCheck,e)||f.tagNameCheck instanceof Function&&f.tagNameCheck(e))&&(f.attributeNameCheck instanceof RegExp&&_(f.attributeNameCheck,t)||f.attributeNameCheck instanceof Function&&f.attributeNameCheck(t))||t==="is"&&f.allowCustomizedBuiltInElements&&(f.tagNameCheck instanceof RegExp&&_(f.tagNameCheck,r)||f.tagNameCheck instanceof Function&&f.tagNameCheck(r))))return!1}else if(!be[t]){if(!_(_e,L(r,Ge,""))){if(!((t==="src"||t==="xlink:href"||t==="href")&&e!=="script"&&Zt(r,"data:")===0&&Ke[e])){if(!(je&&!_(Nt,L(r,Ge,"")))){if(r)return!1}}}}}}return!0},ot=function(e){return e!=="annotation-xml"&&ct(e,wt)},lt=function(e){var t,r,u,A;C("beforeSanitizeAttributes",e,null);var h=e.attributes;if(!(!h||De(e))){var v={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:d};for(A=h.length;A--;){t=h[A];var F=t,m=F.name,Ne=F.namespaceURI;if(r=m==="value"?t.value:Jt(t.value),u=T(m),v.attrName=u,v.attrValue=r,v.keepAttr=!0,v.forceKeepAttr=void 0,C("uponSanitizeAttribute",e,v),r=v.attrValue,!v.forceKeepAttr&&(ne(m,e),!!v.keepAttr)){if(!Ye&&_(/\/>/i,r)){ne(m,e);continue}H&&(r=L(r,de," "),r=L(r,Te," "),r=L(r,ve," "));var st=T(e.nodeName);if(it(st,u,r)){if(Ve&&(u==="id"||u==="name")&&(ne(m,e),r=Ct+r),Ae&&_(/((--!?|])>)|<\/(style|title)/i,r)){ne(m,e);continue}if(R&&I(K)==="object"&&typeof K.getAttributeType=="function"&&!Ne)switch(K.getAttributeType(st,u)){case"TrustedHTML":{r=R.createHTML(r);break}case"TrustedScriptURL":{r=R.createScriptURL(r);break}}try{Ne?e.setAttributeNS(Ne,m,r):e.setAttribute(m,r),De(e)?S(e):ft(n.removed)}catch{}}}}C("afterSanitizeAttributes",e,null)}},Ht=function i(e){var t,r=at(e);for(C("beforeSanitizeShadowDOM",e,null);t=r.nextNode();)C("uponSanitizeShadowNode",t,null),nt(t),lt(t),t.content instanceof c&&i(t.content);C("afterSanitizeShadowDOM",e,null)};return n.sanitize=function(i){var e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},t,r,u,A,h;if(Oe=!i,Oe&&(i="<!-->"),typeof i!="string"&&!V(i))if(typeof i.toString=="function"){if(i=i.toString(),typeof i!="string")throw Ce("dirty is not a string, aborting")}else throw Ce("toString is not a function");if(!n.isSupported){if(I(a.toStaticHTML)==="object"||typeof a.toStaticHTML=="function"){if(typeof i=="string")return a.toStaticHTML(i);if(V(i))return a.toStaticHTML(i.outerHTML)}return i}if(ye||Le(e),n.removed=[],typeof i=="string"&&(X=!1),X){if(i.nodeName){var v=T(i.nodeName);if(!p[v]||Y[v])throw Ce("root node is forbidden and cannot be sanitized in-place")}}else if(i instanceof y)t=rt("<!---->"),r=t.ownerDocument.importNode(i,!0),r.nodeType===1&&r.nodeName==="BODY"||r.nodeName==="HTML"?t=r:t.appendChild(r);else{if(!z&&!H&&!k&&i.indexOf("<")===-1)return R&&ee?R.createHTML(i):i;if(t=rt(i),!t)return z?null:ee?me:""}t&&ge&&S(t.firstChild);for(var F=at(X?i:t);u=F.nextNode();)u.nodeType===3&&u===A||(nt(u),lt(u),u.content instanceof c&&Ht(u.content),A=u);if(A=null,X)return i;if(z){if(Q)for(h=Ot.call(t.ownerDocument);t.firstChild;)h.appendChild(t.firstChild);else h=t;return(d.shadowroot||d.shadowrootmod)&&(h=Lt.call(o,h,!0)),h}var m=k?t.outerHTML:t.innerHTML;return k&&p["!doctype"]&&t.ownerDocument&&t.ownerDocument.doctype&&t.ownerDocument.doctype.name&&_(fr,t.ownerDocument.doctype.name)&&(m="<!DOCTYPE "+t.ownerDocument.doctype.name+`>
|
3 |
+
`+m),H&&(m=L(m,de," "),m=L(m,Te," "),m=L(m,ve," ")),R&&ee?R.createHTML(m):m},n.setConfig=function(i){Le(i),ye=!0},n.clearConfig=function(){$=null,ye=!1},n.isValidAttribute=function(i,e,t){$||Le({});var r=T(i),u=T(e);return it(r,u,t)},n.addHook=function(i,e){typeof e=="function"&&(N[i]=N[i]||[],q(N[i],e))},n.removeHook=function(i){if(N[i])return ft(N[i])},n.removeHooks=function(i){N[i]&&(N[i]=[])},n.removeAllHooks=function(){N={}},n}var Tr=_t();export{Tr as default};
|
dist/index.html
ADDED
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!DOCTYPE html>
|
2 |
+
<html lang="en">
|
3 |
+
<head>
|
4 |
+
<meta charset="UTF-8" />
|
5 |
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
7 |
+
<title>ACE Copywriting Pipeline</title>
|
8 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
9 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.4.1/papaparse.min.js"></script>
|
10 |
+
|
11 |
+
<link rel="stylesheet" href="/index.css">
|
12 |
+
<script type="importmap">
|
13 |
+
{
|
14 |
+
"imports": {
|
15 |
+
"react": "https://esm.sh/react@^19.1.1",
|
16 |
+
"react-dom/": "https://esm.sh/react-dom@^19.1.1/",
|
17 |
+
"react/": "https://esm.sh/react@^19.1.1/"
|
18 |
+
}
|
19 |
+
}
|
20 |
+
</script>
|
21 |
+
<script type="module" crossorigin src="/assets/index-DQmHHp3O.js"></script>
|
22 |
+
</head>
|
23 |
+
<body class="bg-gray-900 text-gray-100 font-sans antialiased">
|
24 |
+
<div id="root" class="h-screen"></div>
|
25 |
+
</body>
|
26 |
+
|
27 |
+
<script
|
28 |
+
src="https://udify.app/embed.min.js"
|
29 |
+
id="U5KHVj67e4Gum8hd"
|
30 |
+
defer>
|
31 |
+
</script>
|
32 |
+
<style>
|
33 |
+
#dify-chatbot-bubble-button {
|
34 |
+
background-color: #1C64F2 !important;
|
35 |
+
}
|
36 |
+
#dify-chatbot-bubble-window {
|
37 |
+
width: 24rem !important;
|
38 |
+
height: 40rem !important;
|
39 |
+
}
|
40 |
+
</style>
|
41 |
+
|
42 |
+
</html>
|
index.html
ADDED
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!DOCTYPE html>
|
2 |
+
<html lang="en">
|
3 |
+
<head>
|
4 |
+
<meta charset="UTF-8" />
|
5 |
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
7 |
+
<title>ACE Copywriting Pipeline</title>
|
8 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
9 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.4.1/papaparse.min.js"></script>
|
10 |
+
<script type="importmap">
|
11 |
+
{
|
12 |
+
"imports": {
|
13 |
+
"react": "https://esm.sh/react@^19.1.1",
|
14 |
+
"react-dom/": "https://esm.sh/react-dom@^19.1.1/",
|
15 |
+
"react/": "https://esm.sh/react@^19.1.1/"
|
16 |
+
}
|
17 |
+
}
|
18 |
+
</script>
|
19 |
+
<link rel="stylesheet" href="/index.css">
|
20 |
+
</head>
|
21 |
+
<body class="bg-gray-900 text-gray-100 font-sans antialiased">
|
22 |
+
<div id="root" class="h-screen"></div>
|
23 |
+
<script type="module" src="/index.tsx"></script>
|
24 |
+
</body>
|
25 |
+
|
26 |
+
<script type="module" src="/src/config/difyChatbotConfig.ts"></script>
|
27 |
+
<script
|
28 |
+
src="https://udify.app/embed.min.js"
|
29 |
+
id="U5KHVj67e4Gum8hd"
|
30 |
+
defer>
|
31 |
+
</script>
|
32 |
+
<style>
|
33 |
+
#dify-chatbot-bubble-button {
|
34 |
+
background-color: #1C64F2 !important;
|
35 |
+
}
|
36 |
+
#dify-chatbot-bubble-window {
|
37 |
+
width: 24rem !important;
|
38 |
+
height: 40rem !important;
|
39 |
+
}
|
40 |
+
</style>
|
41 |
+
|
42 |
+
</html>
|
index.tsx
ADDED
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
import React from 'react';
|
3 |
+
import ReactDOM from 'react-dom/client';
|
4 |
+
import App from './App';
|
5 |
+
|
6 |
+
const rootElement = document.getElementById('root');
|
7 |
+
if (!rootElement) {
|
8 |
+
throw new Error("Could not find root element to mount to");
|
9 |
+
}
|
10 |
+
|
11 |
+
const root = ReactDOM.createRoot(rootElement);
|
12 |
+
root.render(
|
13 |
+
<React.StrictMode>
|
14 |
+
<App />
|
15 |
+
</React.StrictMode>
|
16 |
+
);
|
metadata.json
ADDED
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"title": "ACE Copywriting Pipeline",
|
3 |
+
"emoji": "✍️",
|
4 |
+
"colorFrom": "blue",
|
5 |
+
"colorTo": "green",
|
6 |
+
"sdk": "docker",
|
7 |
+
"app_port": 7860,
|
8 |
+
"pinned": false,
|
9 |
+
"license": "mit",
|
10 |
+
"description": "AI-powered copywriting pipeline for generating and quality-assuring content for ACE Hardware pages. Upload CSV files to generate optimized titles, meta descriptions, H1 tags, and full copy content with built-in QA validation.",
|
11 |
+
"tags": [
|
12 |
+
"copywriting",
|
13 |
+
"ai",
|
14 |
+
"content-generation",
|
15 |
+
"seo",
|
16 |
+
"quality-assurance",
|
17 |
+
"react",
|
18 |
+
"typescript",
|
19 |
+
"csv-processing"
|
20 |
+
]
|
21 |
+
}
|
package.json
ADDED
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"name": "ace-copywriting-pipeline",
|
3 |
+
"private": true,
|
4 |
+
"version": "0.0.0",
|
5 |
+
"type": "module",
|
6 |
+
"scripts": {
|
7 |
+
"dev": "vite",
|
8 |
+
"build": "vite build",
|
9 |
+
"preview": "vite preview"
|
10 |
+
},
|
11 |
+
"dependencies": {
|
12 |
+
"jspdf": "^2.5.2",
|
13 |
+
"react": "^19.1.1",
|
14 |
+
"react-dom": "^19.1.1",
|
15 |
+
"react-markdown": "^10.1.0"
|
16 |
+
},
|
17 |
+
"devDependencies": {
|
18 |
+
"@types/node": "^22.14.0",
|
19 |
+
"typescript": "~5.8.2",
|
20 |
+
"vite": "^6.2.0"
|
21 |
+
}
|
22 |
+
}
|
requirements.txt
ADDED
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# This file is for HuggingFace Spaces compatibility
|
2 |
+
# The actual application runs in a Docker container with Node.js dependencies
|
3 |
+
# defined in package.json
|
4 |
+
|
5 |
+
# No Python dependencies required - this is a React/Node.js application
|
services/authService.ts
ADDED
@@ -0,0 +1,251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// Types
|
2 |
+
export interface User {
|
3 |
+
id: string
|
4 |
+
name: string
|
5 |
+
email: string
|
6 |
+
}
|
7 |
+
|
8 |
+
export interface AuthState {
|
9 |
+
user: User | null
|
10 |
+
isAuthenticated: boolean
|
11 |
+
isLoading: boolean
|
12 |
+
}
|
13 |
+
|
14 |
+
export interface LoginCredentials {
|
15 |
+
username: string
|
16 |
+
password: string
|
17 |
+
}
|
18 |
+
|
19 |
+
// Environment-based configuration with fallbacks
|
20 |
+
const getAuthConfig = () => {
|
21 |
+
// Get credentials from environment variables using type assertion
|
22 |
+
const env = (import.meta as any).env
|
23 |
+
const usernameHash = env.VITE_AUTH_USERNAME_HASH
|
24 |
+
const passwordHash = env.VITE_AUTH_PASSWORD_HASH
|
25 |
+
|
26 |
+
// Validate that required environment variables are present
|
27 |
+
if (!usernameHash || !passwordHash) {
|
28 |
+
console.error('Missing required authentication environment variables!')
|
29 |
+
console.error('Please set VITE_AUTH_USERNAME_HASH and VITE_AUTH_PASSWORD_HASH in your .env file')
|
30 |
+
throw new Error('Authentication configuration missing. Check environment variables.')
|
31 |
+
}
|
32 |
+
|
33 |
+
return {
|
34 |
+
username: usernameHash,
|
35 |
+
password: passwordHash
|
36 |
+
}
|
37 |
+
}
|
38 |
+
|
39 |
+
const getUserConfig = (): User => {
|
40 |
+
const env = (import.meta as any).env
|
41 |
+
return {
|
42 |
+
id: env.VITE_AUTH_USER_ID || "1",
|
43 |
+
name: env.VITE_AUTH_USER_NAME || "ACE Admin",
|
44 |
+
email: env.VITE_AUTH_USER_EMAIL || "[email protected]"
|
45 |
+
}
|
46 |
+
}
|
47 |
+
|
48 |
+
// Helper function to hash input with SHA-256 using Web Crypto API (browser-compatible)
|
49 |
+
async function hashInput(input: string): Promise<string> {
|
50 |
+
const encoder = new TextEncoder()
|
51 |
+
const data = encoder.encode(input)
|
52 |
+
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
53 |
+
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
54 |
+
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
55 |
+
return hashHex
|
56 |
+
}
|
57 |
+
|
58 |
+
// JWT-like token management (simplified for client-side)
|
59 |
+
const TOKEN_KEY = 'ace_auth_token'
|
60 |
+
const USER_KEY = 'ace_auth_user'
|
61 |
+
const TOKEN_EXPIRY_HOURS = 24
|
62 |
+
|
63 |
+
interface TokenData {
|
64 |
+
user: User
|
65 |
+
timestamp: number
|
66 |
+
expiresAt: number
|
67 |
+
}
|
68 |
+
|
69 |
+
class AuthService {
|
70 |
+
private listeners: Set<(authState: AuthState) => void> = new Set()
|
71 |
+
private currentState: AuthState = {
|
72 |
+
user: null,
|
73 |
+
isAuthenticated: false,
|
74 |
+
isLoading: true
|
75 |
+
}
|
76 |
+
|
77 |
+
constructor() {
|
78 |
+
this.checkExistingSession()
|
79 |
+
}
|
80 |
+
|
81 |
+
// Check for existing valid session on initialization
|
82 |
+
private checkExistingSession() {
|
83 |
+
try {
|
84 |
+
const token = localStorage.getItem(TOKEN_KEY)
|
85 |
+
const userData = localStorage.getItem(USER_KEY)
|
86 |
+
|
87 |
+
if (token && userData) {
|
88 |
+
const tokenData: TokenData = JSON.parse(token)
|
89 |
+
const user: User = JSON.parse(userData)
|
90 |
+
|
91 |
+
// Check if token is still valid
|
92 |
+
if (Date.now() < tokenData.expiresAt) {
|
93 |
+
this.updateState({
|
94 |
+
user,
|
95 |
+
isAuthenticated: true,
|
96 |
+
isLoading: false
|
97 |
+
})
|
98 |
+
return
|
99 |
+
} else {
|
100 |
+
// Token expired, clear storage
|
101 |
+
this.clearSession()
|
102 |
+
}
|
103 |
+
}
|
104 |
+
} catch (error) {
|
105 |
+
console.error('Error checking existing session:', error)
|
106 |
+
this.clearSession()
|
107 |
+
}
|
108 |
+
|
109 |
+
this.updateState({
|
110 |
+
user: null,
|
111 |
+
isAuthenticated: false,
|
112 |
+
isLoading: false
|
113 |
+
})
|
114 |
+
}
|
115 |
+
|
116 |
+
// Subscribe to auth state changes
|
117 |
+
subscribe(callback: (authState: AuthState) => void) {
|
118 |
+
this.listeners.add(callback)
|
119 |
+
// Immediately call with current state
|
120 |
+
callback(this.currentState)
|
121 |
+
|
122 |
+
// Return unsubscribe function
|
123 |
+
return () => {
|
124 |
+
this.listeners.delete(callback)
|
125 |
+
}
|
126 |
+
}
|
127 |
+
|
128 |
+
// Update state and notify listeners
|
129 |
+
private updateState(newState: AuthState) {
|
130 |
+
this.currentState = newState
|
131 |
+
this.listeners.forEach(callback => callback(newState))
|
132 |
+
}
|
133 |
+
|
134 |
+
// Get current auth state
|
135 |
+
getState(): AuthState {
|
136 |
+
return this.currentState
|
137 |
+
}
|
138 |
+
|
139 |
+
// Login with credentials
|
140 |
+
async login(credentials: LoginCredentials): Promise<{ success: boolean; error?: string }> {
|
141 |
+
try {
|
142 |
+
this.updateState({ ...this.currentState, isLoading: true })
|
143 |
+
|
144 |
+
// Simulate network delay for UX
|
145 |
+
await new Promise(resolve => setTimeout(resolve, 500))
|
146 |
+
|
147 |
+
const hashedUsername = await hashInput(credentials.username)
|
148 |
+
const hashedPassword = await hashInput(credentials.password)
|
149 |
+
|
150 |
+
// Get auth configuration
|
151 |
+
const validCredentials = getAuthConfig()
|
152 |
+
const validUser = getUserConfig()
|
153 |
+
|
154 |
+
// Validate credentials
|
155 |
+
if (hashedUsername === validCredentials.username &&
|
156 |
+
hashedPassword === validCredentials.password) {
|
157 |
+
|
158 |
+
// Create session token
|
159 |
+
const now = Date.now()
|
160 |
+
const expiresAt = now + (TOKEN_EXPIRY_HOURS * 60 * 60 * 1000)
|
161 |
+
|
162 |
+
const tokenData: TokenData = {
|
163 |
+
user: validUser,
|
164 |
+
timestamp: now,
|
165 |
+
expiresAt
|
166 |
+
}
|
167 |
+
|
168 |
+
// Store in localStorage
|
169 |
+
localStorage.setItem(TOKEN_KEY, JSON.stringify(tokenData))
|
170 |
+
localStorage.setItem(USER_KEY, JSON.stringify(validUser))
|
171 |
+
|
172 |
+
this.updateState({
|
173 |
+
user: validUser,
|
174 |
+
isAuthenticated: true,
|
175 |
+
isLoading: false
|
176 |
+
})
|
177 |
+
|
178 |
+
return { success: true }
|
179 |
+
} else {
|
180 |
+
this.updateState({
|
181 |
+
user: null,
|
182 |
+
isAuthenticated: false,
|
183 |
+
isLoading: false
|
184 |
+
})
|
185 |
+
return { success: false, error: 'Invalid username or password' }
|
186 |
+
}
|
187 |
+
} catch (error) {
|
188 |
+
console.error('Login error:', error)
|
189 |
+
this.updateState({
|
190 |
+
user: null,
|
191 |
+
isAuthenticated: false,
|
192 |
+
isLoading: false
|
193 |
+
})
|
194 |
+
return { success: false, error: 'An error occurred during login' }
|
195 |
+
}
|
196 |
+
}
|
197 |
+
|
198 |
+
// Logout
|
199 |
+
async logout(): Promise<void> {
|
200 |
+
this.clearSession()
|
201 |
+
this.updateState({
|
202 |
+
user: null,
|
203 |
+
isAuthenticated: false,
|
204 |
+
isLoading: false
|
205 |
+
})
|
206 |
+
}
|
207 |
+
|
208 |
+
// Clear session data
|
209 |
+
private clearSession() {
|
210 |
+
localStorage.removeItem(TOKEN_KEY)
|
211 |
+
localStorage.removeItem(USER_KEY)
|
212 |
+
}
|
213 |
+
|
214 |
+
// Check if session is valid
|
215 |
+
isSessionValid(): boolean {
|
216 |
+
try {
|
217 |
+
const token = localStorage.getItem(TOKEN_KEY)
|
218 |
+
if (!token) return false
|
219 |
+
|
220 |
+
const tokenData: TokenData = JSON.parse(token)
|
221 |
+
return Date.now() < tokenData.expiresAt
|
222 |
+
} catch {
|
223 |
+
return false
|
224 |
+
}
|
225 |
+
}
|
226 |
+
}
|
227 |
+
|
228 |
+
// Create singleton instance
|
229 |
+
export const authService = new AuthService()
|
230 |
+
|
231 |
+
// React hook for using auth service
|
232 |
+
export function useAuth(): AuthState & {
|
233 |
+
login: (credentials: LoginCredentials) => Promise<{ success: boolean; error?: string }>
|
234 |
+
logout: () => Promise<void>
|
235 |
+
} {
|
236 |
+
const [authState, setAuthState] = React.useState<AuthState>(authService.getState())
|
237 |
+
|
238 |
+
React.useEffect(() => {
|
239 |
+
const unsubscribe = authService.subscribe(setAuthState)
|
240 |
+
return unsubscribe
|
241 |
+
}, [])
|
242 |
+
|
243 |
+
return {
|
244 |
+
...authState,
|
245 |
+
login: authService.login.bind(authService),
|
246 |
+
logout: authService.logout.bind(authService)
|
247 |
+
}
|
248 |
+
}
|
249 |
+
|
250 |
+
// React import for the hook
|
251 |
+
import React from 'react'
|
services/difyService.ts
ADDED
@@ -0,0 +1,782 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
import { API_URL, API_TOKEN, API_USER } from '../constants';
|
3 |
+
import { ApiInput, ApiResponseOutput, ProcessedResult, QaSectionResult, DetailedQaReport } from '../types';
|
4 |
+
|
5 |
+
const MAX_RETRIES = 3;
|
6 |
+
const INITIAL_RETRY_DELAY_MS = 1000;
|
7 |
+
const RETRYABLE_STATUS_CODES = [429, 502, 503, 504]; // 429: Too Many Requests, 5xx: Server Errors
|
8 |
+
|
9 |
+
// Add jitter to delay to prevent thundering herd problem
|
10 |
+
const delay = (ms: number) => new Promise(res => setTimeout(res, ms + Math.random() * 500));
|
11 |
+
|
12 |
+
// Structured error for workflow failures
|
13 |
+
class WorkflowError extends Error {
|
14 |
+
code: string;
|
15 |
+
at: 'network' | 'api' | 'stream' | 'parse' | 'unknown';
|
16 |
+
debug?: string;
|
17 |
+
constructor(code: string, message: string, at: 'network' | 'api' | 'stream' | 'parse' | 'unknown' = 'unknown', debug?: string) {
|
18 |
+
super(message);
|
19 |
+
this.name = 'WorkflowError';
|
20 |
+
this.code = code;
|
21 |
+
this.at = at;
|
22 |
+
this.debug = debug;
|
23 |
+
}
|
24 |
+
}
|
25 |
+
|
26 |
+
/**
|
27 |
+
* Removes <think>...</think> blocks from a string.
|
28 |
+
*/
|
29 |
+
const cleanResponseText = (text: string): string => {
|
30 |
+
if (typeof text !== 'string') return '';
|
31 |
+
return text.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
|
32 |
+
};
|
33 |
+
|
34 |
+
/**
|
35 |
+
* Parses a single section of the QA report (e.g., TITLE, H1).
|
36 |
+
* This uses regular expressions to be robust against multiline content and format variations.
|
37 |
+
* @param sectionText The text content of a single QA section.
|
38 |
+
* @returns A structured object with the section's results.
|
39 |
+
*/
|
40 |
+
const parseSection = (sectionText: string): QaSectionResult => {
|
41 |
+
console.log('Parsing section text:', sectionText.substring(0, 200));
|
42 |
+
|
43 |
+
// ROBUST GRADE EXTRACTION - handles multiple formats
|
44 |
+
let grade = 'N/A';
|
45 |
+
let gradeMatch = null;
|
46 |
+
|
47 |
+
// Try various grade patterns in order of specificity
|
48 |
+
const gradePatterns = [
|
49 |
+
/-\s*\*\*Grade:\*\*\s*(.*)/, // - **Grade:** 100/100
|
50 |
+
/•\s*\*\*Grade:\*\*\s*(.*)/, // • **Grade:** 100/100
|
51 |
+
/\*\s*\*\*Grade:\*\*\s*(.*)/, // * **Grade:** 100/100
|
52 |
+
/-\s*\*\*Grade\*\*:\s*(.*)/, // - **Grade**: 100/100 (colon without space)
|
53 |
+
/•\s*\*\*Grade\*\*:\s*(.*)/, // • **Grade**: 100/100 (colon without space)
|
54 |
+
/\*\s*\*\*Grade\*\*:\s*(.*)/, // * **Grade**: 100/100
|
55 |
+
/(?:•|-|\*)\s*\*\*Grade\*\*:?:\s*(.*)/, // •/**/- **Grade**: 100/100 or - **Grade** 100/100
|
56 |
+
/(?:•|-|\*)\s*Grade:?:\s*(.*)/, // • Grade: 100/100 or - Grade 100/100
|
57 |
+
/Grade:?:\s*(\d+\/\d+|\d+)/m, // Grade: 100/100 (anywhere in text)
|
58 |
+
/(\d+\/\d+)\s*(?:grade|Grade)/ // 100/100 grade (reverse order)
|
59 |
+
];
|
60 |
+
|
61 |
+
for (const pattern of gradePatterns) {
|
62 |
+
gradeMatch = sectionText.match(pattern);
|
63 |
+
if (gradeMatch) {
|
64 |
+
grade = gradeMatch[1].trim();
|
65 |
+
break;
|
66 |
+
}
|
67 |
+
}
|
68 |
+
console.log('Grade match result:', gradeMatch, 'Final grade:', grade);
|
69 |
+
|
70 |
+
// ROBUST PASS EXTRACTION - handles multiple formats
|
71 |
+
let pass = false;
|
72 |
+
let passMatch = null;
|
73 |
+
|
74 |
+
// Try various pass patterns in order of specificity
|
75 |
+
const passPatterns = [
|
76 |
+
/-\s*\*\*Pass:\*\*\s*(.*)/i, // - **Pass:** true
|
77 |
+
/•\s*\*\*Pass:\*\*\s*(.*)/i, // • **Pass:** true
|
78 |
+
/\*\s*\*\*Pass:\*\*\s*(.*)/i, // * **Pass:** true
|
79 |
+
/-\s*\*\*Pass\*\*:\s*(.*)/i, // - **Pass**: true (colon without space)
|
80 |
+
/•\s*\*\*Pass\*\*:\s*(.*)/i, // • **Pass**: true (colon without space)
|
81 |
+
/\*\s*\*\*Pass\*\*:\s*(.*)/i, // * **Pass**: true (colon without space)
|
82 |
+
/(?:•|-|\*)\s*\*\*Pass\*\*:?:\s*(.*)/i, // •/**/- **Pass**: true
|
83 |
+
/(?:•|-|\*)\s*Pass:?:\s*(.*)/i, // • Pass: true
|
84 |
+
/Pass:?:\s*(true|false|✅|❌|TRUE|FALSE)/im, // Pass: true (anywhere)
|
85 |
+
/(true|false|✅|❌|TRUE|FALSE)\s*pass/im // true pass (reverse)
|
86 |
+
];
|
87 |
+
|
88 |
+
for (const pattern of passPatterns) {
|
89 |
+
passMatch = sectionText.match(pattern);
|
90 |
+
if (passMatch) {
|
91 |
+
const passValue = passMatch[1].toLowerCase().trim();
|
92 |
+
pass = passValue.includes('true') ||
|
93 |
+
passValue.includes('✅') ||
|
94 |
+
passValue === 'yes' ||
|
95 |
+
passValue === 'passed' ||
|
96 |
+
passValue === 'pass';
|
97 |
+
break;
|
98 |
+
}
|
99 |
+
}
|
100 |
+
console.log('Pass match result:', passMatch, 'Final pass:', pass);
|
101 |
+
|
102 |
+
// ROBUST ERRORS EXTRACTION - handles multiple formats
|
103 |
+
let errors: string[] = ['No errors reported.'];
|
104 |
+
let errorsMatch = null;
|
105 |
+
|
106 |
+
// Try various error patterns
|
107 |
+
const errorPatterns = [
|
108 |
+
/-\s*\*\*Errors:\*\*\s*([\s\S]*?)(?=\n-\s*\*\*|$)/, // - **Errors:** []
|
109 |
+
/•\s*\*\*Errors:\*\*\s*([\s\S]*?)(?=\n•\s*\*\*|$)/, // • **Errors:** []
|
110 |
+
/\*\s*\*\*Errors:\*\*\s*([\s\S]*?)(?=\n\*\s*\*\*|$)/, // * **Errors:** []
|
111 |
+
/(?:•|-|\*)\s*\*\*Errors?\*\*:?:\s*([\s\S]*?)(?=\n(?:•|-|\*)\s*\*\*|$)/, // Generic bullet/dash/star + bold
|
112 |
+
/(?:•|-|\*)\s*Errors:?:\s*([\s\S]*?)(?=\n(?:•|-|\*)\s*\*\*|$)/, // Generic bullet/dash/star + no bold
|
113 |
+
/Errors:?:\s*([\s\S]*?)(?=\n(?:•|-|\*|\*\*)|$)/m // Errors: anywhere in text
|
114 |
+
];
|
115 |
+
|
116 |
+
for (const pattern of errorPatterns) {
|
117 |
+
errorsMatch = sectionText.match(pattern);
|
118 |
+
if (errorsMatch) break;
|
119 |
+
}
|
120 |
+
|
121 |
+
if (errorsMatch) {
|
122 |
+
const errorsBlock = errorsMatch[1].trim();
|
123 |
+
|
124 |
+
if (errorsBlock === '[]' || !errorsBlock || errorsBlock.toLowerCase() === 'none') {
|
125 |
+
errors = ['No errors reported.'];
|
126 |
+
} else if (errorsBlock.startsWith('[') && errorsBlock.includes(']')) {
|
127 |
+
// Handle array format: []
|
128 |
+
try {
|
129 |
+
const parsed = JSON.parse(errorsBlock);
|
130 |
+
errors = Array.isArray(parsed) && parsed.length > 0 ? parsed : ['No errors reported.'];
|
131 |
+
} catch {
|
132 |
+
// If JSON parsing fails, treat as plain text
|
133 |
+
errors = [errorsBlock.replace(/[\[\]]/g, '').trim()];
|
134 |
+
}
|
135 |
+
} else {
|
136 |
+
// Handle multi-line bullet format or plain text
|
137 |
+
const lines = errorsBlock.split('\n').map(e => e.trim().replace(/^[-•\*]\s*/, '')).filter(Boolean);
|
138 |
+
errors = lines.length > 0 ? lines : ['No errors reported.'];
|
139 |
+
}
|
140 |
+
}
|
141 |
+
|
142 |
+
// ROBUST ANALYSIS/CORRECTED CONTENT EXTRACTION - handles multiple formats
|
143 |
+
let corrected = 'Content analysis not available.';
|
144 |
+
let contentMatch = null;
|
145 |
+
|
146 |
+
// Try various content patterns - Analysis, Corrected, or any descriptive text
|
147 |
+
const contentPatterns = [
|
148 |
+
/-\s*\*\*Analysis:\*\*\s*([\s\S]*?)(?=\n-\s*\*\*|$)/, // - **Analysis:** text
|
149 |
+
/•\s*\*\*Analysis:\*\*\s*([\s\S]*?)(?=\n•\s*\*\*|$)/, // • **Analysis:** text
|
150 |
+
/\*\s*\*\*Analysis:\*\*\s*([\s\S]*?)(?=\n\*\s*\*\*|$)/, // * **Analysis:** text
|
151 |
+
/-\s*\*\*Corrected:\*\*\s*([\s\S]*?)(?=\n-\s*\*\*|$)/, // - **Corrected:** text
|
152 |
+
/•\s*\*\*Corrected:\*\*\s*([\s\S]*?)(?=\n•\s*\*\*|$)/, // • **Corrected:** text
|
153 |
+
/\*\s*\*\*Corrected:\*\*\s*([\s\S]*?)(?=\n\*\s*\*\*|$)/, // * **Corrected:** text
|
154 |
+
/(?:•|-|\*)\s*\*\*(?:Analysis|Corrected)\*\*:?:\s*([\s\S]*?)(?=\n(?:•|-|\*)\s*\*\*|$)/, // Generic
|
155 |
+
/(?:•|-|\*)\s*(?:Analysis|Corrected):?:\s*([\s\S]*?)(?=\n(?:•|-|\*)\s*\*\*|$)/, // No bold
|
156 |
+
/Analysis:?:\s*([\s\S]*?)(?=\n(?:•|-|\*|\*\*)|$)/m, // Analysis: anywhere
|
157 |
+
/Corrected:?:\s*([\s\S]*?)(?=\n(?:•|-|\*|\*\*)|$)/m // Corrected: anywhere
|
158 |
+
];
|
159 |
+
|
160 |
+
for (const pattern of contentPatterns) {
|
161 |
+
contentMatch = sectionText.match(pattern);
|
162 |
+
if (contentMatch) {
|
163 |
+
corrected = contentMatch[1].trim();
|
164 |
+
break;
|
165 |
+
}
|
166 |
+
}
|
167 |
+
|
168 |
+
// If no Analysis/Corrected found, extract the section title/content as fallback
|
169 |
+
if (!contentMatch || corrected.length < 10) {
|
170 |
+
// Extract title or first meaningful content line
|
171 |
+
const lines = sectionText.split('\n').map(l => l.trim()).filter(Boolean);
|
172 |
+
const titleLine = lines.find(line => !line.startsWith('•') && !line.startsWith('-') && !line.startsWith('*') && !line.includes('**') && line.length > 10);
|
173 |
+
if (titleLine) {
|
174 |
+
corrected = titleLine;
|
175 |
+
}
|
176 |
+
}
|
177 |
+
|
178 |
+
// Clean up any extra formatting
|
179 |
+
corrected = corrected.replace(/^#\s*/, '').replace(/###\s*\*\*[^*]+\*\*/, '').trim();
|
180 |
+
console.log('Content match result:', contentMatch, 'Final corrected:', corrected.substring(0, 50));
|
181 |
+
|
182 |
+
return { grade, pass, errors, corrected };
|
183 |
+
};
|
184 |
+
|
185 |
+
|
186 |
+
/**
|
187 |
+
* Parses the structured QA report format that comes as plain text with sections.
|
188 |
+
* @param qaText The raw structured QA text from the API.
|
189 |
+
* @returns An object containing the detailed parsed report and top-level pass/grade info.
|
190 |
+
*/
|
191 |
+
const parseStructuredQaReport = (qaText: string): { detailedQaReport: DetailedQaReport, overallPass: boolean, overallGrade: string } => {
|
192 |
+
const defaultSection: QaSectionResult = { grade: 'N/A', pass: false, errors: ['Parsing failed'], corrected: '' };
|
193 |
+
const defaultReport: DetailedQaReport = {
|
194 |
+
title: { ...defaultSection },
|
195 |
+
meta: { ...defaultSection },
|
196 |
+
h1: { ...defaultSection },
|
197 |
+
copy: { ...defaultSection },
|
198 |
+
overall: { grade: 'N/A', pass: false, primaryIssue: 'Parsing failed' }
|
199 |
+
};
|
200 |
+
|
201 |
+
try {
|
202 |
+
// Split the text into sections by looking for section headers
|
203 |
+
const sections = qaText.split(/(?=^## [A-Z]+)/gm).filter(Boolean);
|
204 |
+
|
205 |
+
const parsedData: Partial<DetailedQaReport> = {};
|
206 |
+
|
207 |
+
sections.forEach(sectionText => {
|
208 |
+
const lines = sectionText.trim().split('\n');
|
209 |
+
const header = lines[0]?.replace('## ', '').trim().toLowerCase() || '';
|
210 |
+
|
211 |
+
// Extract grade
|
212 |
+
const gradeLine = lines.find(line => line.includes('- Grade:'))?.trim() || '';
|
213 |
+
const gradeMatch = gradeLine.match(/- Grade:\s*(\d+)\/100/) || gradeLine.match(/- Grade:\s*([^\n]+)/);
|
214 |
+
const grade = gradeMatch ? gradeMatch[1].trim() : 'N/A';
|
215 |
+
|
216 |
+
// Extract pass status
|
217 |
+
const passLine = lines.find(line => line.includes('- Pass:'))?.trim() || '';
|
218 |
+
const passMatch = passLine.match(/- Pass:\s*(true|false)/i);
|
219 |
+
const pass = passMatch ? passMatch[1].toLowerCase() === 'true' : false;
|
220 |
+
|
221 |
+
// Extract errors
|
222 |
+
let errors: string[] = [];
|
223 |
+
const errorsLineIndex = lines.findIndex(line => line.includes('- Errors:'));
|
224 |
+
if (errorsLineIndex !== -1) {
|
225 |
+
const errorsContent = lines[errorsLineIndex].replace('- Errors:', '').trim();
|
226 |
+
if (errorsContent === '[]' || errorsContent === '') {
|
227 |
+
errors = ['No errors reported.'];
|
228 |
+
} else {
|
229 |
+
// Look for multi-line errors
|
230 |
+
let errorText = errorsContent;
|
231 |
+
for (let i = errorsLineIndex + 1; i < lines.length; i++) {
|
232 |
+
if (lines[i].startsWith('- ') && !lines[i].startsWith(' ')) break;
|
233 |
+
errorText += '\n' + lines[i].trim();
|
234 |
+
}
|
235 |
+
|
236 |
+
// Parse error list
|
237 |
+
if (errorText.startsWith('[') && errorText.includes(']')) {
|
238 |
+
// Handle array format
|
239 |
+
try {
|
240 |
+
const parsedErrors = JSON.parse(errorText);
|
241 |
+
errors = Array.isArray(parsedErrors) ? parsedErrors : [errorText];
|
242 |
+
} catch {
|
243 |
+
errors = [errorText];
|
244 |
+
}
|
245 |
+
} else {
|
246 |
+
// Handle plain text or bullet list
|
247 |
+
errors = errorText.split('\n')
|
248 |
+
.map(e => e.trim().replace(/^- /, ''))
|
249 |
+
.filter(Boolean);
|
250 |
+
}
|
251 |
+
|
252 |
+
if (errors.length === 0) {
|
253 |
+
errors = ['No errors reported.'];
|
254 |
+
}
|
255 |
+
}
|
256 |
+
} else {
|
257 |
+
errors = ['Errors not found.'];
|
258 |
+
}
|
259 |
+
|
260 |
+
// Extract corrected content
|
261 |
+
let corrected = '';
|
262 |
+
const correctedLineIndex = lines.findIndex(line => line.includes('- Corrected:'));
|
263 |
+
if (correctedLineIndex !== -1) {
|
264 |
+
corrected = lines.slice(correctedLineIndex)
|
265 |
+
.join('\n')
|
266 |
+
.replace('- Corrected:', '')
|
267 |
+
.trim();
|
268 |
+
} else {
|
269 |
+
corrected = 'Correction not found.';
|
270 |
+
}
|
271 |
+
|
272 |
+
const sectionResult: QaSectionResult = { grade, pass, errors, corrected };
|
273 |
+
|
274 |
+
if (header.includes('title')) {
|
275 |
+
parsedData.title = sectionResult;
|
276 |
+
} else if (header.includes('meta')) {
|
277 |
+
parsedData.meta = sectionResult;
|
278 |
+
} else if (header.includes('h1')) {
|
279 |
+
parsedData.h1 = sectionResult;
|
280 |
+
} else if (header.includes('copy')) {
|
281 |
+
parsedData.copy = sectionResult;
|
282 |
+
} else if (header.includes('overall')) {
|
283 |
+
// Extract primary issue for overall section
|
284 |
+
const primaryIssueLine = lines.find(line => line.includes('- Primary Issue:'))?.trim() || '';
|
285 |
+
const primaryIssue = primaryIssueLine.replace('- Primary Issue:', '').trim() || 'Not specified.';
|
286 |
+
parsedData.overall = { grade, pass, primaryIssue };
|
287 |
+
}
|
288 |
+
});
|
289 |
+
|
290 |
+
const finalReport: DetailedQaReport = {
|
291 |
+
title: parsedData.title || { ...defaultSection, errors: ['Title section not found'] },
|
292 |
+
meta: parsedData.meta || { ...defaultSection, errors: ['Meta section not found'] },
|
293 |
+
h1: parsedData.h1 || { ...defaultSection, errors: ['H1 section not found'] },
|
294 |
+
copy: parsedData.copy || { ...defaultSection, errors: ['Copy section not found'] },
|
295 |
+
overall: parsedData.overall || { grade: 'N/A', pass: false, primaryIssue: 'Overall section not found' }
|
296 |
+
};
|
297 |
+
|
298 |
+
return {
|
299 |
+
detailedQaReport: finalReport,
|
300 |
+
overallPass: finalReport.overall.pass,
|
301 |
+
overallGrade: finalReport.overall.grade
|
302 |
+
};
|
303 |
+
|
304 |
+
} catch (error) {
|
305 |
+
console.error('Error parsing structured QA report:', error);
|
306 |
+
return { detailedQaReport: defaultReport, overallPass: false, overallGrade: 'N/A' };
|
307 |
+
}
|
308 |
+
};
|
309 |
+
|
310 |
+
/**
|
311 |
+
* Parses single-section format where all content is in one block.
|
312 |
+
* @param sectionText The section containing all embedded QA data.
|
313 |
+
* @param defaultReport Default report structure.
|
314 |
+
* @returns Parsed QA report data.
|
315 |
+
*/
|
316 |
+
const parseSingleSectionFormat = (sectionText: string, defaultReport: DetailedQaReport): { detailedQaReport: DetailedQaReport, overallPass: boolean, overallGrade: string } => {
|
317 |
+
console.log('Parsing single-section format');
|
318 |
+
|
319 |
+
// Extract embedded sections by looking for section patterns like "**TITLE:", "**META:", etc.
|
320 |
+
const titleMatch = sectionText.match(/\*\*TITLE[^*]*\*\*([\s\S]*?)(?=\*\*[A-Z]+|$)/i);
|
321 |
+
const metaMatch = sectionText.match(/\*\*META[^*]*\*\*([\s\S]*?)(?=\*\*[A-Z]+|$)/i);
|
322 |
+
const h1Match = sectionText.match(/\*\*H1[^*]*\*\*([\s\S]*?)(?=\*\*[A-Z]+|$)/i);
|
323 |
+
const copyMatch = sectionText.match(/\*\*COPY[^*]*\*\*([\s\S]*?)(?=\*\*[A-Z]+|$)/i);
|
324 |
+
const overallMatch = sectionText.match(/\*\*(?:OVERALL|ASSESSMENT)[^*]*\*\*([\s\S]*?)$/i);
|
325 |
+
|
326 |
+
const finalReport: DetailedQaReport = {
|
327 |
+
title: titleMatch ? parseSection(titleMatch[1]) : { ...defaultReport.title, errors: ['Title section not found'] },
|
328 |
+
meta: metaMatch ? parseSection(metaMatch[1]) : { ...defaultReport.meta, errors: ['Meta section not found'] },
|
329 |
+
h1: h1Match ? parseSection(h1Match[1]) : { ...defaultReport.h1, errors: ['H1 section not found'] },
|
330 |
+
copy: copyMatch ? parseSection(copyMatch[1]) : { ...defaultReport.copy, errors: ['Copy section not found'] },
|
331 |
+
overall: overallMatch ? {
|
332 |
+
grade: extractOverallGrade(overallMatch[1]),
|
333 |
+
pass: extractOverallPass(overallMatch[1]),
|
334 |
+
primaryIssue: 'Single-section format parsed'
|
335 |
+
} : { ...defaultReport.overall }
|
336 |
+
};
|
337 |
+
|
338 |
+
return {
|
339 |
+
detailedQaReport: finalReport,
|
340 |
+
overallPass: finalReport.overall.pass,
|
341 |
+
overallGrade: finalReport.overall.grade
|
342 |
+
};
|
343 |
+
};
|
344 |
+
|
345 |
+
/**
|
346 |
+
* Helper function to extract overall grade from text.
|
347 |
+
*/
|
348 |
+
const extractOverallGrade = (text: string): string => {
|
349 |
+
const gradeMatch = text.match(/Grade[^:]*:?\s*(\d+(?:\.\d+)?\/?\d*)/i) || text.match(/(\d+(?:\.\d+)?\/\d+)/);
|
350 |
+
return gradeMatch ? gradeMatch[1].trim() : 'N/A';
|
351 |
+
};
|
352 |
+
|
353 |
+
/**
|
354 |
+
* Helper function to extract overall pass from text.
|
355 |
+
*/
|
356 |
+
const extractOverallPass = (text: string): boolean => {
|
357 |
+
const passMatch = text.match(/Pass[^:]*:?\s*(true|false|✅|❌|TRUE|FALSE)/i);
|
358 |
+
if (passMatch) {
|
359 |
+
const passValue = passMatch[1].toLowerCase().trim();
|
360 |
+
return passValue.includes('true') || passValue.includes('✅');
|
361 |
+
}
|
362 |
+
return false;
|
363 |
+
};
|
364 |
+
|
365 |
+
/**
|
366 |
+
* Parses the new, structured QA report format.
|
367 |
+
* @param qaText The raw `qa_gaurd` string from the API.
|
368 |
+
* @returns An object containing the detailed parsed report and top-level pass/grade info.
|
369 |
+
*/
|
370 |
+
const parseNewQaReport = (qaText: string): { detailedQaReport: DetailedQaReport, overallPass: boolean, overallGrade: string } => {
|
371 |
+
// Default structure in case of parsing failure
|
372 |
+
const defaultSection: QaSectionResult = { grade: 'N/A', pass: false, errors: ['Parsing failed'], corrected: '' };
|
373 |
+
const defaultReport: DetailedQaReport = {
|
374 |
+
title: { ...defaultSection },
|
375 |
+
meta: { ...defaultSection },
|
376 |
+
h1: { ...defaultSection },
|
377 |
+
copy: { ...defaultSection },
|
378 |
+
overall: { grade: 'N/A', pass: false, primaryIssue: 'Parsing failed' }
|
379 |
+
};
|
380 |
+
|
381 |
+
const cleanedQaText = cleanResponseText(qaText);
|
382 |
+
if (!cleanedQaText || typeof cleanedQaText !== 'string') {
|
383 |
+
return { detailedQaReport: defaultReport, overallPass: false, overallGrade: 'N/A' };
|
384 |
+
}
|
385 |
+
|
386 |
+
// Check if it's the new markdown format (starts with ##) or contains **TITLE** style sections
|
387 |
+
if (cleanedQaText.startsWith('##') || cleanedQaText.includes('**TITLE**') || cleanedQaText.includes('### **') || cleanedQaText.includes('### TITLE')) {
|
388 |
+
console.log('Using markdown parser for QA report');
|
389 |
+
console.log('QA text starts with:', cleanedQaText.substring(0, 200));
|
390 |
+
|
391 |
+
// Handle different section header formats
|
392 |
+
let sections;
|
393 |
+
if (cleanedQaText.includes('### **')) {
|
394 |
+
// Split on ### ** and keep the ** in the section content
|
395 |
+
sections = cleanedQaText.split(/(?=### \*\*)/g).slice(1);
|
396 |
+
console.log('Splitting on ### ** (keeping section headers)');
|
397 |
+
} else if (cleanedQaText.includes('**TITLE**')) {
|
398 |
+
// Split on bold section headers like **TITLE**, **META**, etc.
|
399 |
+
sections = cleanedQaText.split(/(?=\*\*(?:TITLE|META|H1|COPY|OVERALL|ASSESSMENT|CORRECTED COPY)[^*]*\*\*)/g).slice(1);
|
400 |
+
console.log('Splitting on **SECTION** headers');
|
401 |
+
} else if (cleanedQaText.includes('### ')) {
|
402 |
+
// Split generic ### headers
|
403 |
+
sections = cleanedQaText.split(/(?=###\s+)/g).slice(1);
|
404 |
+
console.log('Splitting on generic ### headers');
|
405 |
+
} else {
|
406 |
+
sections = cleanedQaText.split('## ').slice(1);
|
407 |
+
console.log('Splitting on ## headers');
|
408 |
+
}
|
409 |
+
console.log('Found sections:', sections.length);
|
410 |
+
sections.forEach((section, index) => {
|
411 |
+
console.log(`Section ${index}:`, section.substring(0, 100));
|
412 |
+
});
|
413 |
+
|
414 |
+
const parsedData: Partial<DetailedQaReport> = {};
|
415 |
+
let correctedCopyFromSeparateSection = '';
|
416 |
+
|
417 |
+
// Special handling for single-section format like "## GRADE REPORT"
|
418 |
+
if (sections.length === 1 && (sections[0].includes('GRADE REPORT') || sections[0].includes('QUALITY ASSURANCE'))) {
|
419 |
+
console.log('Detected single-section format, parsing embedded sections');
|
420 |
+
return parseSingleSectionFormat(sections[0], defaultReport);
|
421 |
+
}
|
422 |
+
|
423 |
+
sections.forEach(sectionBlock => {
|
424 |
+
const lines = sectionBlock.trim().split('\n');
|
425 |
+
let headerRaw = lines[0].trim();
|
426 |
+
let header = headerRaw.toLowerCase();
|
427 |
+
|
428 |
+
// Clean up header - remove markdown formatting and punctuation
|
429 |
+
header = header.replace(/^#+\s*/, '').replace(/\*\*/g, '').replace(/[:\-–]+$/, '').trim();
|
430 |
+
console.log('Processing header:', header);
|
431 |
+
|
432 |
+
if (header.includes('title')) {
|
433 |
+
console.log('Parsing title section');
|
434 |
+
parsedData.title = parseSection(sectionBlock);
|
435 |
+
} else if (header.includes('meta')) {
|
436 |
+
console.log('Parsing meta section');
|
437 |
+
parsedData.meta = parseSection(sectionBlock);
|
438 |
+
} else if (header.includes('h1')) {
|
439 |
+
console.log('Parsing h1 section');
|
440 |
+
parsedData.h1 = parseSection(sectionBlock);
|
441 |
+
} else if (header.includes('copy') && !header.includes('corrected')) {
|
442 |
+
console.log('Parsing copy section');
|
443 |
+
parsedData.copy = parseSection(sectionBlock);
|
444 |
+
} else if (header.includes('corrected') && header.includes('copy')) {
|
445 |
+
console.log('Capturing separate CORRECTED COPY section');
|
446 |
+
correctedCopyFromSeparateSection = lines.slice(1).join('\n').trim();
|
447 |
+
} else if (header.includes('overall') || header.includes('assessment') || header.includes('pipeline')) {
|
448 |
+
console.log('Parsing overall section');
|
449 |
+
console.log('Overall section text:', sectionBlock.substring(0, 300));
|
450 |
+
|
451 |
+
// ROBUST OVERALL GRADE EXTRACTION - handles multiple formats
|
452 |
+
let grade = 'N/A';
|
453 |
+
let gradeMatch = null;
|
454 |
+
|
455 |
+
const overallGradePatterns = [
|
456 |
+
/-\s*\*\*Final Grade:\*\*\s*(.*)/, // - **Final Grade:** 100/100
|
457 |
+
/•\s*\*\*Final Grade:\*\*\s*(.*)/, // • **Final Grade:** 100/100
|
458 |
+
/\*\s*\*\*Final Grade:\*\*\s*(.*)/, // * **Final Grade:** 100/100
|
459 |
+
/-\s*\*\*Total Grade:\*\*\s*(.*)/, // - **Total Grade:** 100/100
|
460 |
+
/•\s*\*\*Total Grade:\*\*\s*(.*)/, // • **Total Grade:** 100/100
|
461 |
+
/\*\s*\*\*Total Grade:\*\*\s*(.*)/, // * **Total Grade:** 100/100
|
462 |
+
/(?:•|-|\*)\s*\*\*(?:Final|Total|Overall)?\s*Grade\*\*:?:\s*(.*)/i, // Generic grade
|
463 |
+
/(?:•|-|\*)\s*(?:Final|Total|Overall)?\s*Grade:?:\s*(.*)/i, // No bold
|
464 |
+
/(?:Final|Total|Overall)\s*Grade:?:\s*(\d+\/\d+|\d+)/im, // Anywhere in text
|
465 |
+
/(\d+\/\d+)\s*(?:final|total|overall)?/im // Number first
|
466 |
+
];
|
467 |
+
|
468 |
+
for (const pattern of overallGradePatterns) {
|
469 |
+
gradeMatch = sectionBlock.match(pattern);
|
470 |
+
if (gradeMatch) {
|
471 |
+
grade = gradeMatch[1].trim();
|
472 |
+
break;
|
473 |
+
}
|
474 |
+
}
|
475 |
+
console.log('Overall grade match:', gradeMatch, 'Final grade:', grade);
|
476 |
+
|
477 |
+
// ROBUST OVERALL PASS EXTRACTION - handles multiple formats
|
478 |
+
let pass = false;
|
479 |
+
let passMatch = null;
|
480 |
+
|
481 |
+
const overallPassPatterns = [
|
482 |
+
// Exact format variations found in logs
|
483 |
+
/-\s*\*\*Overall Pass:\*\*\s*(.*)/i, // - **Overall Pass:** true
|
484 |
+
/•\s*\*\*Overall Pass:\*\*\s*(.*)/i, // • **Overall Pass:** true
|
485 |
+
/\*\s*\*\*Overall Pass:\*\*\s*(.*)/i, // * **Overall Pass:** true
|
486 |
+
/•\s*\*\*All Sections Pass:\*\*\s*(.*)/i, // • **All Sections Pass:** true
|
487 |
+
/-\s*\*\*All Sections Pass:\*\*\s*(.*)/i, // - **All Sections Pass:** true
|
488 |
+
/\*\s*\*\*All Sections Pass:\*\*\s*(.*)/i, // * **All Sections Pass:** true
|
489 |
+
/•\s*\*\*Final Pass:\*\*\s*(.*)/i, // • **Final Pass:** true
|
490 |
+
/-\s*\*\*Final Pass:\*\*\s*(.*)/i, // - **Final Pass:** true
|
491 |
+
/\*\s*\*\*Final Pass:\*\*\s*(.*)/i, // * **Final Pass:** true
|
492 |
+
|
493 |
+
// Generic patterns with flexible formatting
|
494 |
+
/(?:•|-|\*)\s*\*\*(?:Overall\s+|All\s+Sections\s+|Final\s+)?Pass\*\*:?:\s*(.*)/i,
|
495 |
+
/(?:•|-|\*)\s*(?:Overall\s+|All\s+Sections\s+|Final\s+)?Pass:?:\s*(.*)/i,
|
496 |
+
|
497 |
+
// Anywhere in text patterns
|
498 |
+
/Pass:?:\s*(true|false|✅|❌|TRUE|FALSE)/im,
|
499 |
+
/Overall\s*Pass:?:\s*(true|false|✅|❌|TRUE|FALSE)/im,
|
500 |
+
/All\s*Sections\s*Pass:?:\s*(true|false|✅|❌|TRUE|FALSE)/im,
|
501 |
+
/(true|false|✅|❌|TRUE|FALSE)\s*(?:overall|pass)/im,
|
502 |
+
|
503 |
+
// Handle capitalized boolean values
|
504 |
+
/Pass:?:\s*(True|False|TRUE|FALSE)/im,
|
505 |
+
/Overall\s*Pass:?:\s*(True|False|TRUE|FALSE)/im
|
506 |
+
];
|
507 |
+
|
508 |
+
for (const pattern of overallPassPatterns) {
|
509 |
+
passMatch = sectionBlock.match(pattern);
|
510 |
+
if (passMatch) {
|
511 |
+
const passValue = passMatch[1].toLowerCase().trim();
|
512 |
+
pass = passValue.includes('true') ||
|
513 |
+
passValue.includes('✅') ||
|
514 |
+
passValue === 'yes' ||
|
515 |
+
passValue === 'passed' ||
|
516 |
+
passValue === 'pass';
|
517 |
+
break;
|
518 |
+
}
|
519 |
+
}
|
520 |
+
console.log('Overall pass match:', passMatch, 'Final pass:', pass);
|
521 |
+
|
522 |
+
// Look for various primary issue formats
|
523 |
+
const explanationMatch = sectionBlock.match(/\*\*Overall Pass\*\*:\s*[^()]*\(([^)]+)\)/);
|
524 |
+
const statusMatch = sectionBlock.match(/(?:•|-|\*)\s*\*\*(?:Pipeline\s+)?Status\*\*?:?\s*([\s\S]*?)(?=\n(?:•|-|\*)\s*\*\*|$)/);
|
525 |
+
const primaryIssueMatch = sectionBlock.match(/(?:•|-|\*)\s*\*\*(?:Primary\s+)?Issue\*\*?:?\s*([\s\S]*?)(?=\n(?:•|-|\*)\s*\*\*|$)/) ||
|
526 |
+
sectionBlock.match(/(?:•|-|\*)\s*(?:Primary\s+)?Issue:?:\s*([\s\S]*?)(?=\n(?:•|-|\*)\s*\*\*|$)/);
|
527 |
+
const errorsMatch = sectionBlock.match(/(?:•|-|\*)\s*\*\*Total\s+Errors?\*\*?:?\s*([\s\S]*?)(?=\n(?:•|-|\*)\s*\*\*|$)/);
|
528 |
+
const totalSectionsMatch = sectionBlock.match(/Total\s*Sections\s*Passing:?:\s*([^\n]+)/i);
|
529 |
+
|
530 |
+
let primaryIssue = 'All sections passed successfully.';
|
531 |
+
if (explanationMatch) {
|
532 |
+
primaryIssue = explanationMatch[1].trim();
|
533 |
+
} else if (statusMatch) {
|
534 |
+
primaryIssue = statusMatch[1].trim();
|
535 |
+
} else if (primaryIssueMatch) {
|
536 |
+
primaryIssue = primaryIssueMatch[1].trim();
|
537 |
+
} else if (errorsMatch) {
|
538 |
+
const errorText = errorsMatch[1].trim();
|
539 |
+
if (errorText !== '[]' && errorText !== '') {
|
540 |
+
primaryIssue = `Errors found: ${errorText}`;
|
541 |
+
}
|
542 |
+
}
|
543 |
+
if (totalSectionsMatch) {
|
544 |
+
primaryIssue = `${primaryIssue} | Total Sections Passing: ${totalSectionsMatch[1].trim()}`;
|
545 |
+
}
|
546 |
+
|
547 |
+
console.log('Primary issue extraction - explanation:', explanationMatch, 'status:', statusMatch, 'issue:', primaryIssueMatch, 'Final issue:', primaryIssue);
|
548 |
+
|
549 |
+
console.log('Setting overall data - grade:', grade, 'pass:', pass, 'primaryIssue:', primaryIssue);
|
550 |
+
parsedData.overall = { grade, pass, primaryIssue };
|
551 |
+
}
|
552 |
+
});
|
553 |
+
|
554 |
+
const finalReport: DetailedQaReport = {
|
555 |
+
title: parsedData.title || { ...defaultSection, errors: ['Title section not found'] },
|
556 |
+
meta: parsedData.meta || { ...defaultSection, errors: ['Meta section not found'] },
|
557 |
+
h1: parsedData.h1 || { ...defaultSection, errors: ['H1 section not found'] },
|
558 |
+
copy: parsedData.copy || { ...defaultSection, errors: ['Copy section not found'] },
|
559 |
+
overall: parsedData.overall || { grade: 'N/A', pass: false, primaryIssue: 'Overall section not found' }
|
560 |
+
};
|
561 |
+
|
562 |
+
// If we saw a separate CORRECTED COPY section, populate copy.corrected with it when useful
|
563 |
+
if (correctedCopyFromSeparateSection) {
|
564 |
+
const cleanedCorrected = correctedCopyFromSeparateSection.replace(/^###.*$/m, '').trim();
|
565 |
+
if (!finalReport.copy.corrected || finalReport.copy.corrected.length < 20) {
|
566 |
+
finalReport.copy.corrected = cleanedCorrected;
|
567 |
+
}
|
568 |
+
}
|
569 |
+
|
570 |
+
// If no explicit overall section was found, check for inline overall results at end of text
|
571 |
+
if (!parsedData.overall) {
|
572 |
+
// Look for overall results anywhere in the full text (sometimes appears at the end)
|
573 |
+
const inlineOverallGradeMatch = cleanedQaText.match(/##?\s*OVERALL\s*(?:PIPELINE\s*)?GRADE:?:\s*([^#\n]+)/i);
|
574 |
+
const inlineOverallPassMatch = cleanedQaText.match(/##?\s*OVERALL\s*(?:PIPELINE\s*)?PASS:?:\s*([^#\n(]+)/i);
|
575 |
+
|
576 |
+
if (inlineOverallGradeMatch || inlineOverallPassMatch) {
|
577 |
+
let grade = 'N/A';
|
578 |
+
let pass = false;
|
579 |
+
|
580 |
+
if (inlineOverallGradeMatch) {
|
581 |
+
grade = inlineOverallGradeMatch[1].trim();
|
582 |
+
}
|
583 |
+
|
584 |
+
if (inlineOverallPassMatch) {
|
585 |
+
const passValue = inlineOverallPassMatch[1].toLowerCase().trim();
|
586 |
+
pass = passValue.includes('true') ||
|
587 |
+
passValue.includes('✅') ||
|
588 |
+
passValue === 'yes' ||
|
589 |
+
passValue === 'passed';
|
590 |
+
}
|
591 |
+
|
592 |
+
parsedData.overall = {
|
593 |
+
grade,
|
594 |
+
pass,
|
595 |
+
primaryIssue: pass ? 'All sections passed successfully.' : 'Overall assessment failed.'
|
596 |
+
};
|
597 |
+
|
598 |
+
console.log('Found inline overall results - grade:', grade, 'pass:', pass);
|
599 |
+
} else {
|
600 |
+
// Calculate overall pass from individual sections
|
601 |
+
const allSectionsPassed = finalReport.title.pass &&
|
602 |
+
finalReport.meta.pass &&
|
603 |
+
finalReport.h1.pass &&
|
604 |
+
finalReport.copy.pass;
|
605 |
+
|
606 |
+
// Calculate average grade if all sections have numeric grades
|
607 |
+
let averageGrade = 'N/A';
|
608 |
+
const grades = [finalReport.title.grade, finalReport.meta.grade, finalReport.h1.grade, finalReport.copy.grade];
|
609 |
+
const numericGrades = grades.filter(g => g !== 'N/A' && g !== undefined)
|
610 |
+
.map(g => {
|
611 |
+
const match = String(g).match(/(\d+(?:\.\d+)?)/);
|
612 |
+
return match ? parseFloat(match[1]) : null;
|
613 |
+
})
|
614 |
+
.filter(g => g !== null) as number[];
|
615 |
+
|
616 |
+
if (numericGrades.length === 4) {
|
617 |
+
const avg = numericGrades.reduce((sum, grade) => sum + grade, 0) / numericGrades.length;
|
618 |
+
averageGrade = `${avg.toFixed(2)}/100`;
|
619 |
+
}
|
620 |
+
|
621 |
+
parsedData.overall = {
|
622 |
+
grade: averageGrade,
|
623 |
+
pass: allSectionsPassed,
|
624 |
+
primaryIssue: allSectionsPassed ? 'All sections passed successfully.' : 'One or more sections failed.'
|
625 |
+
};
|
626 |
+
|
627 |
+
console.log('Calculated overall from individual sections - pass:', allSectionsPassed, 'grade:', averageGrade);
|
628 |
+
}
|
629 |
+
|
630 |
+
// Update the final report with the calculated/found overall data
|
631 |
+
finalReport.overall = parsedData.overall;
|
632 |
+
}
|
633 |
+
|
634 |
+
console.log('Final parsed QA data:', finalReport.overall);
|
635 |
+
console.log('Setting overallPass:', finalReport.overall.pass, 'overallGrade:', finalReport.overall.grade);
|
636 |
+
|
637 |
+
return {
|
638 |
+
detailedQaReport: finalReport,
|
639 |
+
overallPass: finalReport.overall.pass,
|
640 |
+
overallGrade: finalReport.overall.grade
|
641 |
+
};
|
642 |
+
} else {
|
643 |
+
// Parse the new structured format
|
644 |
+
return parseStructuredQaReport(cleanedQaText);
|
645 |
+
}
|
646 |
+
};
|
647 |
+
|
648 |
+
|
649 |
+
/**
|
650 |
+
* Runs the Dify workflow for a given input row, with retries for transient errors.
|
651 |
+
* @param inputs - The data from a CSV row.
|
652 |
+
* @returns A promise that resolves to the processed and cleaned results.
|
653 |
+
*/
|
654 |
+
export const runWorkflow = async (inputs: ApiInput): Promise<ProcessedResult> => {
|
655 |
+
let lastError: Error = new Error('Workflow failed after all retries.');
|
656 |
+
|
657 |
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
658 |
+
let responseText = '';
|
659 |
+
try {
|
660 |
+
const payload = {
|
661 |
+
inputs,
|
662 |
+
response_mode: 'streaming',
|
663 |
+
user: API_USER,
|
664 |
+
};
|
665 |
+
|
666 |
+
const response = await fetch(API_URL, {
|
667 |
+
method: 'POST',
|
668 |
+
headers: {
|
669 |
+
'Authorization': `Bearer ${API_TOKEN}`,
|
670 |
+
'Content-Type': 'application/json',
|
671 |
+
},
|
672 |
+
body: JSON.stringify(payload),
|
673 |
+
});
|
674 |
+
|
675 |
+
if (!response.ok) {
|
676 |
+
responseText = await response.text();
|
677 |
+
// Check for retryable HTTP status codes.
|
678 |
+
if (RETRYABLE_STATUS_CODES.includes(response.status)) {
|
679 |
+
throw new WorkflowError(`RETRYABLE_HTTP_${response.status}`, `Temporary service issue (HTTP ${response.status}).`, 'network', responseText);
|
680 |
+
}
|
681 |
+
// For other HTTP errors, fail immediately.
|
682 |
+
throw new WorkflowError(`HTTP_${response.status}`, `API request failed (HTTP ${response.status}).`, 'api', responseText);
|
683 |
+
}
|
684 |
+
|
685 |
+
if (!response.body) {
|
686 |
+
throw new WorkflowError('EMPTY_RESPONSE', 'Empty response from API.', 'network');
|
687 |
+
}
|
688 |
+
|
689 |
+
const reader = response.body.getReader();
|
690 |
+
const decoder = new TextDecoder();
|
691 |
+
let streamContent = '';
|
692 |
+
while (true) {
|
693 |
+
const { done, value } = await reader.read();
|
694 |
+
if (done) break;
|
695 |
+
streamContent += decoder.decode(value, { stream: true });
|
696 |
+
}
|
697 |
+
|
698 |
+
// The full stream content becomes our responseText for error logging
|
699 |
+
responseText = streamContent;
|
700 |
+
|
701 |
+
const lines = streamContent.trim().split('\n');
|
702 |
+
const finishedLine = lines.find(line => line.includes('"event": "workflow_finished"')) || '';
|
703 |
+
|
704 |
+
if (!finishedLine) {
|
705 |
+
// The gateway might have returned an HTML error page instead of a stream
|
706 |
+
if (streamContent.trim().toLowerCase().startsWith('<html')) {
|
707 |
+
throw new WorkflowError('RETRYABLE_HTML_RESPONSE', 'Service returned an HTML error page.', 'stream', streamContent.slice(0, 1000));
|
708 |
+
}
|
709 |
+
console.error('Full stream content:', streamContent);
|
710 |
+
throw new WorkflowError('FINISH_EVENT_MISSING', 'Workflow did not finish successfully.', 'stream', streamContent.slice(0, 1000));
|
711 |
+
}
|
712 |
+
|
713 |
+
const jsonString = finishedLine.replace(/^data: /, '');
|
714 |
+
const finishedEventData = JSON.parse(jsonString);
|
715 |
+
|
716 |
+
if (finishedEventData.data.status !== 'succeeded') {
|
717 |
+
const apiError = finishedEventData.data.error || 'Unknown';
|
718 |
+
const isOverloaded = typeof apiError === 'string' && (apiError.toLowerCase().includes('overloaded') || apiError.toLowerCase().includes('gateway time-out'));
|
719 |
+
|
720 |
+
// If it's a known transient error, mark it as retryable.
|
721 |
+
if (isOverloaded) {
|
722 |
+
throw new WorkflowError('RETRYABLE_API_ERROR', 'Service overloaded. Retrying...', 'api', String(apiError));
|
723 |
+
}
|
724 |
+
// Otherwise, it's a permanent failure for this row.
|
725 |
+
throw new WorkflowError('WORKFLOW_FAILED', `Workflow failed: ${apiError}`, 'api', String(apiError));
|
726 |
+
}
|
727 |
+
|
728 |
+
const outputs: ApiResponseOutput = finishedEventData.data.outputs;
|
729 |
+
|
730 |
+
if (!outputs || Object.keys(outputs).length === 0) {
|
731 |
+
throw new WorkflowError('EMPTY_OUTPUTS', 'Workflow succeeded but returned empty outputs.', 'parse');
|
732 |
+
}
|
733 |
+
|
734 |
+
const rawQaReport = outputs.qa_gaurd || 'QA report not available.';
|
735 |
+
console.log('QA Report length:', rawQaReport.length);
|
736 |
+
|
737 |
+
const { detailedQaReport, overallPass, overallGrade } = parseNewQaReport(rawQaReport);
|
738 |
+
console.log('Final Parsed QA - Pass:', overallPass, 'Grade:', overallGrade);
|
739 |
+
|
740 |
+
// Success, return the result and exit the loop.
|
741 |
+
return {
|
742 |
+
generatedTitle: cleanResponseText(outputs.title),
|
743 |
+
generatedH1: cleanResponseText(outputs.h1),
|
744 |
+
generatedCopy: cleanResponseText(outputs.copy),
|
745 |
+
generatedMeta: cleanResponseText(outputs.meta),
|
746 |
+
qaReport: rawQaReport,
|
747 |
+
detailedQaReport,
|
748 |
+
overallPass,
|
749 |
+
overallGrade,
|
750 |
+
};
|
751 |
+
|
752 |
+
} catch (error) {
|
753 |
+
lastError = error instanceof Error ? error : new Error(String(error));
|
754 |
+
|
755 |
+
const isRetryable = lastError instanceof WorkflowError && lastError.code.startsWith('RETRYABLE_');
|
756 |
+
|
757 |
+
if (isRetryable && attempt < MAX_RETRIES) {
|
758 |
+
// Exponential backoff with jitter: 1s, 2s, 4s, ... + random
|
759 |
+
const delayMs = INITIAL_RETRY_DELAY_MS * Math.pow(2, attempt - 1);
|
760 |
+
console.warn(`Attempt ${attempt}/${MAX_RETRIES} failed due to a transient error. Retrying in ~${Math.round(delayMs / 1000)}s...`, { error: lastError.message });
|
761 |
+
await delay(delayMs);
|
762 |
+
continue; // Move to the next attempt
|
763 |
+
}
|
764 |
+
|
765 |
+
// For non-retryable errors, or if we've exhausted retries, break the loop to throw the error.
|
766 |
+
console.error(`Failed to process row. Attempt ${attempt}/${MAX_RETRIES}. Error: ${lastError.message}`);
|
767 |
+
if (responseText) {
|
768 |
+
// Log the problematic response that caused the failure
|
769 |
+
console.error('Problematic Response:', responseText);
|
770 |
+
}
|
771 |
+
break;
|
772 |
+
}
|
773 |
+
}
|
774 |
+
|
775 |
+
// If the loop finished without returning, it means all attempts failed.
|
776 |
+
// We re-throw the last captured error, making it more user-friendly if it was a transient one.
|
777 |
+
if (lastError instanceof WorkflowError && lastError.code.startsWith('RETRYABLE_')) {
|
778 |
+
throw new WorkflowError('SERVICE_UNAVAILABLE', `API service is temporarily unavailable. Tried ${MAX_RETRIES} times. Please try again later.`, 'api', lastError.debug || lastError.stack);
|
779 |
+
}
|
780 |
+
|
781 |
+
throw lastError;
|
782 |
+
};
|
src/.DS_Store
ADDED
Binary file (6.15 kB). View file
|
|
src/config/difyChatbotConfig.ts
ADDED
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
declare global {
|
2 |
+
interface Window {
|
3 |
+
difyChatbotConfig: {
|
4 |
+
token: string;
|
5 |
+
inputs: Record<string, string>;
|
6 |
+
systemVariables: Record<string, string>;
|
7 |
+
userVariables: Record<string, string>;
|
8 |
+
};
|
9 |
+
}
|
10 |
+
}
|
11 |
+
|
12 |
+
window.difyChatbotConfig = {
|
13 |
+
token: 'U5KHVj67e4Gum8hd',
|
14 |
+
inputs: {
|
15 |
+
// You can define the inputs from the Start node here
|
16 |
+
// key is the variable name
|
17 |
+
// e.g.
|
18 |
+
// name: "NAME"
|
19 |
+
},
|
20 |
+
systemVariables: {
|
21 |
+
// user_id: 'YOU CAN DEFINE USER ID HERE',
|
22 |
+
// conversation_id: 'YOU CAN DEFINE CONVERSATION ID HERE, IT MUST BE A VALID UUID',
|
23 |
+
},
|
24 |
+
userVariables: {
|
25 |
+
// avatar_url: 'YOU CAN DEFINE USER AVATAR URL HERE',
|
26 |
+
// name: 'YOU CAN DEFINE USER NAME HERE',
|
27 |
+
},
|
28 |
+
};
|
tsconfig.json
ADDED
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"compilerOptions": {
|
3 |
+
"target": "ES2022",
|
4 |
+
"experimentalDecorators": true,
|
5 |
+
"useDefineForClassFields": false,
|
6 |
+
"module": "ESNext",
|
7 |
+
"lib": [
|
8 |
+
"ES2022",
|
9 |
+
"DOM",
|
10 |
+
"DOM.Iterable"
|
11 |
+
],
|
12 |
+
"skipLibCheck": true,
|
13 |
+
"moduleResolution": "bundler",
|
14 |
+
"isolatedModules": true,
|
15 |
+
"moduleDetection": "force",
|
16 |
+
"allowJs": true,
|
17 |
+
"jsx": "react-jsx",
|
18 |
+
"paths": {
|
19 |
+
"@/*": [
|
20 |
+
"./*"
|
21 |
+
]
|
22 |
+
},
|
23 |
+
"allowImportingTsExtensions": true,
|
24 |
+
"noEmit": true
|
25 |
+
}
|
26 |
+
}
|
types.ts
ADDED
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// The expected structure of a row from the input CSV.
|
2 |
+
export interface CsvInputRow {
|
3 |
+
URL: string;
|
4 |
+
Page: string;
|
5 |
+
Keywords: string;
|
6 |
+
Recommended_Title: string;
|
7 |
+
Recommended_H1: string;
|
8 |
+
Internal_Links: string;
|
9 |
+
Copy: string;
|
10 |
+
}
|
11 |
+
|
12 |
+
// The structure of the 'inputs' object for the Dify API call.
|
13 |
+
export type ApiInput = CsvInputRow;
|
14 |
+
|
15 |
+
|
16 |
+
// The structure of the `outputs` object from the Dify API response.
|
17 |
+
export interface ApiResponseOutput {
|
18 |
+
title: string;
|
19 |
+
h1: string;
|
20 |
+
copy: string;
|
21 |
+
meta: string;
|
22 |
+
qa_gaurd: string;
|
23 |
+
}
|
24 |
+
|
25 |
+
// Represents an item in the processing queue.
|
26 |
+
export interface QueueItem {
|
27 |
+
id: number;
|
28 |
+
data: CsvInputRow;
|
29 |
+
status: 'pending' | 'processing' | 'completed' | 'failed';
|
30 |
+
error?: string;
|
31 |
+
}
|
32 |
+
|
33 |
+
// Represents the parsed QA results for a single section (e.g., Title, H1).
|
34 |
+
export interface QaSectionResult {
|
35 |
+
grade: string;
|
36 |
+
pass: boolean;
|
37 |
+
errors: string[];
|
38 |
+
corrected: string;
|
39 |
+
}
|
40 |
+
|
41 |
+
// Represents the full, structured QA report.
|
42 |
+
export interface DetailedQaReport {
|
43 |
+
title: QaSectionResult;
|
44 |
+
meta: QaSectionResult;
|
45 |
+
h1: QaSectionResult;
|
46 |
+
copy: QaSectionResult;
|
47 |
+
overall: {
|
48 |
+
grade: string;
|
49 |
+
pass: boolean;
|
50 |
+
primaryIssue: string;
|
51 |
+
};
|
52 |
+
}
|
53 |
+
|
54 |
+
|
55 |
+
// Extracted and cleaned data from the API response.
|
56 |
+
export interface ProcessedResult {
|
57 |
+
generatedTitle: string;
|
58 |
+
generatedH1: string;
|
59 |
+
generatedCopy: string;
|
60 |
+
generatedMeta: string;
|
61 |
+
qaReport: string; // The raw, original QA report string
|
62 |
+
detailedQaReport?: DetailedQaReport; // The parsed, structured QA report
|
63 |
+
overallPass: boolean; // For quick access in the main table view
|
64 |
+
overallGrade: string; // For quick access in the main table view
|
65 |
+
}
|
66 |
+
|
67 |
+
// A complete row for the final results table and CSV export.
|
68 |
+
export type ResultRow = CsvInputRow & ProcessedResult & { id: number };
|
69 |
+
|
70 |
+
// Represents a completed job in the history.
|
71 |
+
export interface JobHistoryItem {
|
72 |
+
id: string;
|
73 |
+
filename: string;
|
74 |
+
date: string;
|
75 |
+
totalRows: number;
|
76 |
+
completedCount: number;
|
77 |
+
failedCount: number;
|
78 |
+
results?: ResultRow[]; // Store the full results for review and re-download
|
79 |
+
}
|
80 |
+
|
81 |
+
|
82 |
+
// Expected headers for the input CSV, used for validation.
|
83 |
+
export const REQUIRED_CSV_HEADERS: (keyof CsvInputRow)[] = [
|
84 |
+
'URL',
|
85 |
+
'Page',
|
86 |
+
'Keywords',
|
87 |
+
'Recommended_Title',
|
88 |
+
'Recommended_H1',
|
89 |
+
'Internal_Links',
|
90 |
+
'Copy'
|
91 |
+
];
|
92 |
+
|
93 |
+
// Structured, user-facing error captured during batching
|
94 |
+
export interface BatchError {
|
95 |
+
row?: number; // 1-based row index for per-row errors
|
96 |
+
code: string; // machine-friendly code e.g. CSV_PARSE_ERROR, HTTP_ERROR, SERVICE_UNAVAILABLE
|
97 |
+
message: string; // user-friendly message
|
98 |
+
suggestion?: string; // optional remediation hint
|
99 |
+
at?: 'csv' | 'network' | 'api' | 'stream' | 'parse' | 'unknown';
|
100 |
+
debug?: string; // optional raw/console error text to help diagnose
|
101 |
+
}
|
vite.config.ts
ADDED
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import path from 'path';
|
2 |
+
import { defineConfig, loadEnv } from 'vite';
|
3 |
+
|
4 |
+
export default defineConfig(({ mode }) => {
|
5 |
+
const env = loadEnv(mode, '.', '');
|
6 |
+
return {
|
7 |
+
define: {
|
8 |
+
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
9 |
+
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
|
10 |
+
},
|
11 |
+
resolve: {
|
12 |
+
alias: {
|
13 |
+
'@': path.resolve(__dirname, '.'),
|
14 |
+
}
|
15 |
+
}
|
16 |
+
};
|
17 |
+
});
|