Severian commited on
Commit
159f9c9
·
verified ·
1 Parent(s): 4f8e134

Upload 27 files

Browse files
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">&times;</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
+ });