Seth commited on
Commit
89a3828
·
1 Parent(s): 8aefeb2
frontend/src/components/ExportButtons.jsx CHANGED
@@ -175,3 +175,518 @@ function objectToXML(obj, rootName = "extraction") {
175
  }
176
 
177
  export default function ExportButtons({ isComplete, extractionResult }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  }
176
 
177
  export default function ExportButtons({ isComplete, extractionResult }) {
178
+ const [downloading, setDownloading] = useState(null);
179
+ const [copied, setCopied] = useState(false);
180
+ const [isShareModalOpen, setIsShareModalOpen] = useState(false);
181
+ const [isShareLinkModalOpen, setIsShareLinkModalOpen] = useState(false);
182
+ const [shareLink, setShareLink] = useState("");
183
+ const [isGeneratingLink, setIsGeneratingLink] = useState(false);
184
+
185
+ // Helper function to extract text from fields (same as in ExtractionOutput)
186
+ const extractTextFromFields = (fields) => {
187
+ if (!fields || typeof fields !== "object") {
188
+ return "";
189
+ }
190
+
191
+ // Check for page_X structure first (preferred format)
192
+ const pageKeys = Object.keys(fields).filter(key => key.startsWith("page_"));
193
+ if (pageKeys.length > 0) {
194
+ // Get text from first page (or combine all pages)
195
+ const pageTexts = pageKeys.map(key => {
196
+ const page = fields[key];
197
+ if (page && page.text) {
198
+ return page.text;
199
+ }
200
+ return "";
201
+ }).filter(text => text);
202
+
203
+ if (pageTexts.length > 0) {
204
+ return pageTexts.join("\n\n");
205
+ }
206
+ }
207
+
208
+ // Fallback to full_text
209
+ if (fields.full_text) {
210
+ return fields.full_text;
211
+ }
212
+
213
+ return "";
214
+ };
215
+
216
+ // Helper function to escape HTML
217
+ const escapeHtml = (text) => {
218
+ if (!text) return '';
219
+ const div = document.createElement('div');
220
+ div.textContent = text;
221
+ return div.innerHTML;
222
+ };
223
+
224
+ // Helper function to convert pipe-separated tables to HTML tables
225
+ const convertPipeTablesToHTML = (text) => {
226
+ if (!text) return text;
227
+
228
+ const lines = text.split('\n');
229
+ const result = [];
230
+ let i = 0;
231
+
232
+ while (i < lines.length) {
233
+ const line = lines[i];
234
+
235
+ // Check if this line looks like a table row (has multiple pipes)
236
+ if (line.includes('|') && line.split('|').length >= 3) {
237
+ // Check if it's a separator line (only |, -, :, spaces)
238
+ const isSeparator = /^[\s|\-:]+$/.test(line.trim());
239
+
240
+ if (!isSeparator) {
241
+ // Start of a table - collect all table rows
242
+ const tableRows = [];
243
+ let j = i;
244
+
245
+ // Collect header row
246
+ const headerLine = lines[j];
247
+ const headerCells = headerLine.split('|').map(cell => cell.trim()).filter(cell => cell || cell === '');
248
+ // Remove empty cells at start/end
249
+ if (headerCells.length > 0 && !headerCells[0]) headerCells.shift();
250
+ if (headerCells.length > 0 && !headerCells[headerCells.length - 1]) headerCells.pop();
251
+
252
+ if (headerCells.length >= 2) {
253
+ tableRows.push(headerCells);
254
+ j++;
255
+
256
+ // Skip separator line if present
257
+ if (j < lines.length && /^[\s|\-:]+$/.test(lines[j].trim())) {
258
+ j++;
259
+ }
260
+
261
+ // Collect data rows
262
+ while (j < lines.length) {
263
+ const rowLine = lines[j];
264
+ if (!rowLine.trim()) break; // Empty line ends table
265
+
266
+ // Check if it's still a table row
267
+ if (rowLine.includes('|') && rowLine.split('|').length >= 2) {
268
+ const isRowSeparator = /^[\s|\-:]+$/.test(rowLine.trim());
269
+ if (!isRowSeparator) {
270
+ const rowCells = rowLine.split('|').map(cell => cell.trim());
271
+ // Remove empty cells at start/end
272
+ if (rowCells.length > 0 && !rowCells[0]) rowCells.shift();
273
+ if (rowCells.length > 0 && !rowCells[rowCells.length - 1]) rowCells.pop();
274
+ tableRows.push(rowCells);
275
+ j++;
276
+ } else {
277
+ j++;
278
+ }
279
+ } else {
280
+ break; // Not a table row anymore
281
+ }
282
+ }
283
+
284
+ // Convert to HTML table
285
+ if (tableRows.length > 0) {
286
+ let htmlTable = '<table class="border-collapse border border-gray-300 w-full my-4">\n<thead>\n<tr>';
287
+
288
+ // Header row
289
+ tableRows[0].forEach(cell => {
290
+ htmlTable += `<th class="border border-gray-300 px-4 py-2 bg-gray-100 font-semibold text-left">${escapeHtml(cell)}</th>`;
291
+ });
292
+ htmlTable += '</tr>\n</thead>\n<tbody>\n';
293
+
294
+ // Data rows
295
+ for (let rowIdx = 1; rowIdx < tableRows.length; rowIdx++) {
296
+ htmlTable += '<tr>';
297
+ tableRows[rowIdx].forEach((cell, colIdx) => {
298
+ // Use header cell count to ensure alignment
299
+ const cellContent = cell || '';
300
+ htmlTable += `<td class="border border-gray-300 px-4 py-2">${escapeHtml(cellContent)}</td>`;
301
+ });
302
+ htmlTable += '</tr>\n';
303
+ }
304
+
305
+ htmlTable += '</tbody>\n</table>';
306
+ result.push(htmlTable);
307
+ i = j;
308
+ continue;
309
+ }
310
+ }
311
+ }
312
+ }
313
+
314
+ // Not a table row, add as-is
315
+ result.push(line);
316
+ i++;
317
+ }
318
+
319
+ return result.join('\n');
320
+ };
321
+
322
+ // Helper function to render markdown to HTML (same as in ExtractionOutput)
323
+ const renderMarkdownToHTML = (text) => {
324
+ if (!text) return "";
325
+
326
+ let html = text;
327
+
328
+ // FIRST: Convert pipe-separated tables to HTML tables
329
+ html = convertPipeTablesToHTML(html);
330
+
331
+ // Convert LaTeX-style superscripts/subscripts FIRST
332
+ html = html.replace(/\$\s*\^\s*\{([^}]+)\}\s*\$/g, '<sup>$1</sup>');
333
+ html = html.replace(/\$\s*\^\s*([^\s$<>]+)\s*\$/g, '<sup>$1</sup>');
334
+ html = html.replace(/\$\s*_\s*\{([^}]+)\}\s*\$/g, '<sub>$1</sub>');
335
+ html = html.replace(/\$\s*_\s*([^\s$<>]+)\s*\$/g, '<sub>$1</sub>');
336
+
337
+ // Protect HTML table blocks
338
+ const htmlBlocks = [];
339
+ let htmlBlockIndex = 0;
340
+
341
+ html = html.replace(/<table[\s\S]*?<\/table>/gi, (match) => {
342
+ const placeholder = `__HTML_BLOCK_${htmlBlockIndex}__`;
343
+ htmlBlocks[htmlBlockIndex] = match;
344
+ htmlBlockIndex++;
345
+ return placeholder;
346
+ });
347
+
348
+ // Convert markdown headers
349
+ html = html.replace(/^### (.*$)/gim, '<h3>$1</h3>');
350
+ html = html.replace(/^## (.*$)/gim, '<h2>$1</h2>');
351
+ html = html.replace(/^# (.*$)/gim, '<h1>$1</h1>');
352
+
353
+ // Convert markdown bold/italic
354
+ html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
355
+ html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
356
+
357
+ // Convert markdown links
358
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
359
+
360
+ // Process line breaks
361
+ const parts = html.split(/(__HTML_BLOCK_\d+__)/);
362
+ const processedParts = parts.map((part) => {
363
+ if (part.match(/^__HTML_BLOCK_\d+__$/)) {
364
+ const blockIndex = parseInt(part.match(/\d+/)[0]);
365
+ return htmlBlocks[blockIndex];
366
+ } else {
367
+ let processed = part;
368
+ processed = processed.replace(/\n\n+/g, '</p><p>');
369
+ processed = processed.replace(/([^\n>])\n([^\n<])/g, '$1<br>$2');
370
+ if (processed.trim() && !processed.trim().startsWith('<')) {
371
+ processed = '<p>' + processed + '</p>';
372
+ }
373
+ return processed;
374
+ }
375
+ });
376
+
377
+ html = processedParts.join('');
378
+ html = html.replace(/<p><\/p>/g, '');
379
+ html = html.replace(/<p>\s*<br>\s*<\/p>/g, '');
380
+ html = html.replace(/<p>\s*<\/p>/g, '');
381
+
382
+ return html;
383
+ };
384
+
385
+ const handleDownload = async (format) => {
386
+ if (!extractionResult || !extractionResult.fields) {
387
+ console.error("No extraction data available");
388
+ return;
389
+ }
390
+
391
+ setDownloading(format);
392
+
393
+ try {
394
+ const fields = extractionResult.fields;
395
+ let content = "";
396
+ let filename = "";
397
+ let mimeType = "";
398
+
399
+ if (format === "json") {
400
+ const preparedFields = prepareFieldsForOutput(fields, "json");
401
+ content = JSON.stringify(preparedFields, null, 2);
402
+ filename = `extraction_${new Date().toISOString().split('T')[0]}.json`;
403
+ mimeType = "application/json";
404
+ } else if (format === "xml") {
405
+ content = objectToXML(fields);
406
+ filename = `extraction_${new Date().toISOString().split('T')[0]}.xml`;
407
+ mimeType = "application/xml";
408
+ } else if (format === "docx") {
409
+ // For DOCX, create a Word-compatible HTML document that preserves layout
410
+ // Extract text and convert to HTML (same as text viewer)
411
+ const textContent = extractTextFromFields(fields);
412
+ const htmlContent = renderMarkdownToHTML(textContent);
413
+
414
+ // Create a Word-compatible HTML document with proper MIME type
415
+ // Word can open HTML files with .docx extension if we use the right MIME type
416
+ const wordHTML = `<!DOCTYPE html>
417
+ <html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
418
+ <head>
419
+ <meta charset="UTF-8">
420
+ <meta name="ProgId" content="Word.Document">
421
+ <meta name="Generator" content="Microsoft Word">
422
+ <meta name="Originator" content="Microsoft Word">
423
+ <!--[if gte mso 9]><xml>
424
+ <w:WordDocument>
425
+ <w:View>Print</w:View>
426
+ <w:Zoom>100</w:Zoom>
427
+ <w:DoNotOptimizeForBrowser/>
428
+ </w:WordDocument>
429
+ </xml><![endif]-->
430
+ <title>Document Extraction</title>
431
+ <style>
432
+ @page {
433
+ size: 8.5in 11in;
434
+ margin: 1in;
435
+ }
436
+ body {
437
+ font-family: 'Calibri', 'Arial', sans-serif;
438
+ font-size: 11pt;
439
+ line-height: 1.6;
440
+ margin: 0;
441
+ color: #333;
442
+ }
443
+ h1 {
444
+ font-size: 18pt;
445
+ font-weight: bold;
446
+ color: #0f172a;
447
+ margin-top: 24pt;
448
+ margin-bottom: 12pt;
449
+ page-break-after: avoid;
450
+ }
451
+ h2 {
452
+ font-size: 16pt;
453
+ font-weight: 600;
454
+ color: #0f172a;
455
+ margin-top: 20pt;
456
+ margin-bottom: 10pt;
457
+ page-break-after: avoid;
458
+ }
459
+ h3 {
460
+ font-size: 14pt;
461
+ font-weight: 600;
462
+ color: #1e293b;
463
+ margin-top: 16pt;
464
+ margin-bottom: 8pt;
465
+ page-break-after: avoid;
466
+ }
467
+ p {
468
+ margin-top: 6pt;
469
+ margin-bottom: 6pt;
470
+ }
471
+ table {
472
+ width: 100%;
473
+ border-collapse: collapse;
474
+ margin: 12pt 0;
475
+ font-size: 10pt;
476
+ page-break-inside: avoid;
477
+ }
478
+ table th {
479
+ background-color: #f8fafc;
480
+ border: 1pt solid #cbd5e1;
481
+ padding: 6pt;
482
+ text-align: left;
483
+ font-weight: 600;
484
+ color: #0f172a;
485
+ }
486
+ table td {
487
+ border: 1pt solid #cbd5e1;
488
+ padding: 6pt;
489
+ color: #334155;
490
+ }
491
+ table tr:nth-child(even) {
492
+ background-color: #f8fafc;
493
+ }
494
+ sup {
495
+ font-size: 0.75em;
496
+ vertical-align: super;
497
+ line-height: 0;
498
+ }
499
+ sub {
500
+ font-size: 0.75em;
501
+ vertical-align: sub;
502
+ line-height: 0;
503
+ }
504
+ strong {
505
+ font-weight: 600;
506
+ }
507
+ em {
508
+ font-style: italic;
509
+ }
510
+ a {
511
+ color: #4f46e5;
512
+ text-decoration: underline;
513
+ }
514
+ </style>
515
+ </head>
516
+ <body>
517
+ ${htmlContent}
518
+ </body>
519
+ </html>`;
520
+
521
+ content = wordHTML;
522
+ filename = `extraction_${new Date().toISOString().split('T')[0]}.doc`;
523
+ mimeType = "application/msword";
524
+ }
525
+
526
+ // Create blob and download
527
+ const blob = new Blob([content], { type: mimeType });
528
+ const url = URL.createObjectURL(blob);
529
+ const link = document.createElement("a");
530
+ link.href = url;
531
+ link.download = filename;
532
+ document.body.appendChild(link);
533
+ link.click();
534
+ document.body.removeChild(link);
535
+ URL.revokeObjectURL(url);
536
+
537
+ setDownloading(null);
538
+ } catch (error) {
539
+ console.error("Download error:", error);
540
+ setDownloading(null);
541
+ }
542
+ };
543
+
544
+ const handleCopyLink = async () => {
545
+ if (!extractionResult?.id) return;
546
+
547
+ setIsGeneratingLink(true);
548
+ setIsShareLinkModalOpen(true);
549
+ setShareLink("");
550
+
551
+ try {
552
+ const result = await createShareLink(extractionResult.id);
553
+ if (result.success && result.share_link) {
554
+ setShareLink(result.share_link);
555
+ } else {
556
+ throw new Error("Failed to generate share link");
557
+ }
558
+ } catch (err) {
559
+ console.error("Failed to create share link:", err);
560
+ setShareLink("");
561
+ // Still show modal but with error state
562
+ } finally {
563
+ setIsGeneratingLink(false);
564
+ }
565
+ };
566
+
567
+ const handleShare = async (extractionId, recipientEmail) => {
568
+ await shareExtraction(extractionId, recipientEmail);
569
+ };
570
+
571
+ if (!isComplete) return null;
572
+
573
+ return (
574
+ <motion.div
575
+ initial={{ opacity: 0, y: 20 }}
576
+ animate={{ opacity: 1, y: 0 }}
577
+ className="flex items-center gap-3"
578
+ >
579
+ {/* Export Options Dropdown */}
580
+ <DropdownMenu>
581
+ <DropdownMenuTrigger asChild>
582
+ <Button
583
+ variant="ghost"
584
+ className="h-11 w-11 rounded-xl hover:bg-slate-100"
585
+ disabled={downloading !== null}
586
+ >
587
+ {downloading ? (
588
+ <motion.div
589
+ animate={{ rotate: 360 }}
590
+ transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
591
+ >
592
+ <Download className="h-4 w-4" />
593
+ </motion.div>
594
+ ) : (
595
+ <Share2 className="h-4 w-4" />
596
+ )}
597
+ </Button>
598
+ </DropdownMenuTrigger>
599
+ <DropdownMenuContent align="end" className="w-56 rounded-xl p-2">
600
+ <DropdownMenuItem
601
+ className="rounded-lg cursor-pointer"
602
+ onClick={() => setIsShareModalOpen(true)}
603
+ >
604
+ <Mail className="h-4 w-4 mr-2 text-indigo-600" />
605
+ Share output
606
+ </DropdownMenuItem>
607
+ <DropdownMenuItem
608
+ className="rounded-lg cursor-pointer"
609
+ onClick={handleCopyLink}
610
+ >
611
+ <Link2 className="h-4 w-4 mr-2 text-indigo-600" />
612
+ Copy share link
613
+ </DropdownMenuItem>
614
+ <DropdownMenuSeparator />
615
+ <DropdownMenuItem
616
+ className="rounded-lg cursor-pointer"
617
+ onClick={() => handleDownload("docx")}
618
+ disabled={downloading === "docx"}
619
+ >
620
+ {downloading === "docx" ? (
621
+ <motion.div
622
+ animate={{ rotate: 360 }}
623
+ transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
624
+ className="h-4 w-4 mr-2"
625
+ >
626
+ <Download className="h-4 w-4" />
627
+ </motion.div>
628
+ ) : (
629
+ <FileText className="h-4 w-4 mr-2 text-blue-600" />
630
+ )}
631
+ Download Docx
632
+ </DropdownMenuItem>
633
+ <DropdownMenuItem
634
+ className="rounded-lg cursor-pointer"
635
+ onClick={() => handleDownload("json")}
636
+ disabled={downloading === "json"}
637
+ >
638
+ {downloading === "json" ? (
639
+ <motion.div
640
+ animate={{ rotate: 360 }}
641
+ transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
642
+ className="h-4 w-4 mr-2"
643
+ >
644
+ <Download className="h-4 w-4" />
645
+ </motion.div>
646
+ ) : (
647
+ <Braces className="h-4 w-4 mr-2 text-indigo-600" />
648
+ )}
649
+ Download JSON
650
+ </DropdownMenuItem>
651
+ <DropdownMenuItem
652
+ className="rounded-lg cursor-pointer"
653
+ onClick={() => handleDownload("xml")}
654
+ disabled={downloading === "xml"}
655
+ >
656
+ {downloading === "xml" ? (
657
+ <motion.div
658
+ animate={{ rotate: 360 }}
659
+ transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
660
+ className="h-4 w-4 mr-2"
661
+ >
662
+ <Download className="h-4 w-4" />
663
+ </motion.div>
664
+ ) : (
665
+ <FileCode2 className="h-4 w-4 mr-2 text-slate-600" />
666
+ )}
667
+ Download XML
668
+ </DropdownMenuItem>
669
+ </DropdownMenuContent>
670
+ </DropdownMenu>
671
+
672
+ {/* Share Modal */}
673
+ <ShareModal
674
+ isOpen={isShareModalOpen}
675
+ onClose={() => setIsShareModalOpen(false)}
676
+ onShare={handleShare}
677
+ extractionId={extractionResult?.id}
678
+ />
679
+
680
+ {/* Share Link Modal */}
681
+ <ShareLinkModal
682
+ isOpen={isShareLinkModalOpen}
683
+ onClose={() => {
684
+ setIsShareLinkModalOpen(false);
685
+ setShareLink("");
686
+ }}
687
+ shareLink={shareLink}
688
+ isLoading={isGeneratingLink}
689
+ />
690
+ </motion.div>
691
+ );
692
+ }
frontend/src/components/ShareLinkModal.jsx CHANGED
@@ -5,3 +5,136 @@ import { Button } from "@/components/ui/button";
5
  import { Input } from "@/components/ui/input";
6
 
7
  export default function ShareLinkModal({ isOpen, onClose, shareLink, isLoading }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  import { Input } from "@/components/ui/input";
6
 
7
  export default function ShareLinkModal({ isOpen, onClose, shareLink, isLoading }) {
8
+ const [copied, setCopied] = useState(false);
9
+
10
+ useEffect(() => {
11
+ if (!isOpen) {
12
+ setCopied(false);
13
+ }
14
+ }, [isOpen]);
15
+
16
+ const handleCopy = async () => {
17
+ if (!shareLink) return;
18
+
19
+ try {
20
+ await navigator.clipboard.writeText(shareLink);
21
+ setCopied(true);
22
+ setTimeout(() => setCopied(false), 2000);
23
+ } catch (err) {
24
+ // Fallback for older browsers
25
+ const textArea = document.createElement("textarea");
26
+ textArea.value = shareLink;
27
+ textArea.style.position = "fixed";
28
+ textArea.style.opacity = "0";
29
+ document.body.appendChild(textArea);
30
+ textArea.select();
31
+ try {
32
+ document.execCommand("copy");
33
+ setCopied(true);
34
+ setTimeout(() => setCopied(false), 2000);
35
+ } catch (fallbackErr) {
36
+ console.error("Failed to copy:", fallbackErr);
37
+ }
38
+ document.body.removeChild(textArea);
39
+ }
40
+ };
41
+
42
+ if (!isOpen) return null;
43
+
44
+ return (
45
+ <AnimatePresence>
46
+ <div className="fixed inset-0 z-50 flex items-center justify-center">
47
+ {/* Backdrop */}
48
+ <motion.div
49
+ initial={{ opacity: 0 }}
50
+ animate={{ opacity: 1 }}
51
+ exit={{ opacity: 0 }}
52
+ className="absolute inset-0 bg-black/50 backdrop-blur-sm"
53
+ onClick={onClose}
54
+ />
55
+
56
+ {/* Modal */}
57
+ <motion.div
58
+ initial={{ opacity: 0, scale: 0.95, y: 20 }}
59
+ animate={{ opacity: 1, scale: 1, y: 0 }}
60
+ exit={{ opacity: 0, scale: 0.95, y: 20 }}
61
+ className="relative z-10 w-full max-w-md mx-4 bg-white rounded-2xl shadow-2xl overflow-hidden"
62
+ onClick={(e) => e.stopPropagation()}
63
+ >
64
+ {/* Header */}
65
+ <div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
66
+ <h2 className="text-xl font-semibold text-slate-900">Copy Share Link</h2>
67
+ <button
68
+ onClick={onClose}
69
+ disabled={isLoading}
70
+ className="p-2 rounded-lg hover:bg-slate-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
71
+ >
72
+ <X className="h-5 w-5 text-slate-500" />
73
+ </button>
74
+ </div>
75
+
76
+ {/* Content */}
77
+ <div className="px-6 py-6">
78
+ {isLoading ? (
79
+ <div className="text-center py-8">
80
+ <Loader2 className="h-8 w-8 mx-auto mb-4 text-indigo-600 animate-spin" />
81
+ <p className="text-sm text-slate-600">Generating share link...</p>
82
+ </div>
83
+ ) : shareLink ? (
84
+ <div className="space-y-4">
85
+ <div>
86
+ <label className="block text-sm font-medium text-slate-700 mb-2">
87
+ Share Link
88
+ </label>
89
+ <div className="flex gap-2">
90
+ <Input
91
+ type="text"
92
+ value={shareLink}
93
+ readOnly
94
+ className="flex-1 h-12 rounded-xl border-slate-200 bg-slate-50 text-sm font-mono"
95
+ />
96
+ <Button
97
+ onClick={handleCopy}
98
+ className="h-12 px-4 rounded-xl bg-gradient-to-r from-indigo-600 to-violet-600 hover:from-indigo-700 hover:to-violet-700"
99
+ >
100
+ {copied ? (
101
+ <>
102
+ <Check className="h-4 w-4 mr-2" />
103
+ Copied!
104
+ </>
105
+ ) : (
106
+ <>
107
+ <Copy className="h-4 w-4 mr-2" />
108
+ Copy
109
+ </>
110
+ )}
111
+ </Button>
112
+ </div>
113
+ </div>
114
+ <p className="text-xs text-slate-500">
115
+ Share this link with anyone you want to give access to this extraction. They'll need to sign in to view it.
116
+ </p>
117
+ </div>
118
+ ) : (
119
+ <div className="text-center py-8">
120
+ <p className="text-sm text-slate-600">No share link available</p>
121
+ </div>
122
+ )}
123
+
124
+ <div className="pt-4 mt-6 border-t border-slate-200">
125
+ <Button
126
+ type="button"
127
+ variant="outline"
128
+ onClick={onClose}
129
+ disabled={isLoading}
130
+ className="w-full h-11 rounded-xl"
131
+ >
132
+ Close
133
+ </Button>
134
+ </div>
135
+ </div>
136
+ </motion.div>
137
+ </div>
138
+ </AnimatePresence>
139
+ );
140
+ }
frontend/src/components/ShareModal.jsx CHANGED
@@ -5,3 +5,192 @@ import { Button } from "@/components/ui/button";
5
  import { Input } from "@/components/ui/input";
6
 
7
  export default function ShareModal({ isOpen, onClose, onShare, extractionId }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  import { Input } from "@/components/ui/input";
6
 
7
  export default function ShareModal({ isOpen, onClose, onShare, extractionId }) {
8
+ const [email, setEmail] = useState("");
9
+ const [isLoading, setIsLoading] = useState(false);
10
+ const [error, setError] = useState("");
11
+ const [success, setSuccess] = useState(false);
12
+ const [successMessage, setSuccessMessage] = useState("");
13
+
14
+ const handleSubmit = async (e) => {
15
+ e.preventDefault();
16
+ setError("");
17
+ setSuccess(false);
18
+
19
+ // Parse and validate multiple emails (comma or semicolon separated)
20
+ if (!email.trim()) {
21
+ setError("Please enter at least one recipient email address");
22
+ return;
23
+ }
24
+
25
+ // Split by comma or semicolon, trim each email, and filter out empty strings
26
+ const emailList = email
27
+ .split(/[,;]/)
28
+ .map((e) => e.trim())
29
+ .filter((e) => e.length > 0);
30
+
31
+ if (emailList.length === 0) {
32
+ setError("Please enter at least one recipient email address");
33
+ return;
34
+ }
35
+
36
+ // Validate each email
37
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
38
+ const invalidEmails = emailList.filter((e) => !emailRegex.test(e));
39
+
40
+ if (invalidEmails.length > 0) {
41
+ setError(`Invalid email address(es): ${invalidEmails.join(", ")}`);
42
+ return;
43
+ }
44
+
45
+ setIsLoading(true);
46
+ try {
47
+ const result = await onShare(extractionId, emailList);
48
+ setSuccessMessage(result?.message || `Successfully shared with ${emailList.length} recipient(s)`);
49
+ setSuccess(true);
50
+ setEmail("");
51
+ // Close modal after 2 seconds
52
+ setTimeout(() => {
53
+ setSuccess(false);
54
+ setSuccessMessage("");
55
+ onClose();
56
+ }, 2000);
57
+ } catch (err) {
58
+ setError(err.message || "Failed to share extraction. Please try again.");
59
+ } finally {
60
+ setIsLoading(false);
61
+ }
62
+ };
63
+
64
+ const handleClose = () => {
65
+ if (!isLoading) {
66
+ setEmail("");
67
+ setError("");
68
+ setSuccess(false);
69
+ onClose();
70
+ }
71
+ };
72
+
73
+ if (!isOpen) return null;
74
+
75
+ return (
76
+ <AnimatePresence>
77
+ <div className="fixed inset-0 z-50 flex items-center justify-center">
78
+ {/* Backdrop */}
79
+ <motion.div
80
+ initial={{ opacity: 0 }}
81
+ animate={{ opacity: 1 }}
82
+ exit={{ opacity: 0 }}
83
+ className="absolute inset-0 bg-black/50 backdrop-blur-sm"
84
+ onClick={handleClose}
85
+ />
86
+
87
+ {/* Modal */}
88
+ <motion.div
89
+ initial={{ opacity: 0, scale: 0.95, y: 20 }}
90
+ animate={{ opacity: 1, scale: 1, y: 0 }}
91
+ exit={{ opacity: 0, scale: 0.95, y: 20 }}
92
+ className="relative z-10 w-full max-w-md mx-4 bg-white rounded-2xl shadow-2xl overflow-hidden"
93
+ onClick={(e) => e.stopPropagation()}
94
+ >
95
+ {/* Header */}
96
+ <div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
97
+ <h2 className="text-xl font-semibold text-slate-900">Share Output</h2>
98
+ <button
99
+ onClick={handleClose}
100
+ disabled={isLoading}
101
+ className="p-2 rounded-lg hover:bg-slate-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
102
+ >
103
+ <X className="h-5 w-5 text-slate-500" />
104
+ </button>
105
+ </div>
106
+
107
+ {/* Content */}
108
+ <div className="px-6 py-6">
109
+ {success ? (
110
+ <motion.div
111
+ initial={{ opacity: 0, scale: 0.9 }}
112
+ animate={{ opacity: 1, scale: 1 }}
113
+ className="text-center py-8"
114
+ >
115
+ <div className="w-16 h-16 mx-auto mb-4 rounded-full bg-emerald-100 flex items-center justify-center">
116
+ <Send className="h-8 w-8 text-emerald-600" />
117
+ </div>
118
+ <h3 className="text-lg font-semibold text-slate-900 mb-2">
119
+ Share Sent Successfully!
120
+ </h3>
121
+ <p className="text-sm text-slate-600">
122
+ {successMessage || "The recipient(s) will receive an email with a link to view the extraction."}
123
+ </p>
124
+ </motion.div>
125
+ ) : (
126
+ <form onSubmit={handleSubmit} className="space-y-4">
127
+ <div>
128
+ <label
129
+ htmlFor="recipient-email"
130
+ className="block text-sm font-medium text-slate-700 mb-2"
131
+ >
132
+ Recipient Email(s)
133
+ </label>
134
+ <p className="text-xs text-slate-500 mb-2">
135
+ Separate multiple emails with commas or semicolons
136
+ </p>
137
+ <div className="relative">
138
+ <Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-slate-400" />
139
+ <Input
140
+ id="recipient-email"
141
+ type="text"
142
+ value={email}
143
+ onChange={(e) => setEmail(e.target.value)}
144
+ placeholder="Enter email addresses (comma or semicolon separated)"
145
+ className="pl-10 h-12 rounded-xl border-slate-200 focus:border-indigo-500 focus:ring-indigo-500"
146
+ disabled={isLoading}
147
+ autoFocus
148
+ />
149
+ </div>
150
+ {error && (
151
+ <motion.p
152
+ initial={{ opacity: 0, y: -10 }}
153
+ animate={{ opacity: 1, y: 0 }}
154
+ className="mt-2 text-sm text-red-600"
155
+ >
156
+ {error}
157
+ </motion.p>
158
+ )}
159
+ </div>
160
+
161
+ <div className="pt-4 flex gap-3">
162
+ <Button
163
+ type="button"
164
+ variant="outline"
165
+ onClick={handleClose}
166
+ disabled={isLoading}
167
+ className="flex-1 h-11 rounded-xl"
168
+ >
169
+ Cancel
170
+ </Button>
171
+ <Button
172
+ type="submit"
173
+ disabled={isLoading || !email.trim()}
174
+ className="flex-1 h-11 rounded-xl bg-gradient-to-r from-indigo-600 to-violet-600 hover:from-indigo-700 hover:to-violet-700"
175
+ >
176
+ {isLoading ? (
177
+ <>
178
+ <Loader2 className="h-4 w-4 mr-2 animate-spin" />
179
+ Sending...
180
+ </>
181
+ ) : (
182
+ <>
183
+ <Send className="h-4 w-4 mr-2" />
184
+ Send
185
+ </>
186
+ )}
187
+ </Button>
188
+ </div>
189
+ </form>
190
+ )}
191
+ </div>
192
+ </motion.div>
193
+ </div>
194
+ </AnimatePresence>
195
+ );
196
+ }
frontend/src/components/ocr/DocumentPreview.jsx CHANGED
@@ -4,3 +4,226 @@ import { FileText, ZoomIn, ZoomOut, RotateCw } from "lucide-react";
4
  import { Button } from "@/components/ui/button";
5
 
6
  export default function DocumentPreview({ file, isProcessing, isFromHistory = false }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  import { Button } from "@/components/ui/button";
5
 
6
  export default function DocumentPreview({ file, isProcessing, isFromHistory = false }) {
7
+ const [previewUrls, setPreviewUrls] = useState([]);
8
+ const [zoom, setZoom] = useState(100);
9
+ const [rotation, setRotation] = useState(0);
10
+ const objectUrlsRef = useRef([]);
11
+
12
+ useEffect(() => {
13
+ if (!file) {
14
+ // Cleanup previous URLs
15
+ objectUrlsRef.current.forEach((url) => {
16
+ if (url && url.startsWith("blob:")) {
17
+ URL.revokeObjectURL(url);
18
+ }
19
+ });
20
+ objectUrlsRef.current = [];
21
+ setPreviewUrls([]);
22
+ return;
23
+ }
24
+
25
+ const loadPreview = async () => {
26
+ const urls = [];
27
+ const newObjectUrls = [];
28
+
29
+ // Check if it's a PDF
30
+ if (file.type === "application/pdf" || file.name?.toLowerCase().endsWith(".pdf")) {
31
+ try {
32
+ // Use pdf.js to render PDF pages
33
+ const pdfjsLib = await import("pdfjs-dist");
34
+
35
+ // Configure worker - use jsdelivr CDN which is more reliable
36
+ // This will use the same version as the installed package
37
+ const version = pdfjsLib.version || "4.0.379";
38
+ pdfjsLib.GlobalWorkerOptions.workerSrc = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${version}/build/pdf.worker.min.mjs`;
39
+
40
+ const arrayBuffer = await file.arrayBuffer();
41
+ const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
42
+ const numPages = pdf.numPages;
43
+
44
+ for (let pageNum = 1; pageNum <= numPages; pageNum++) {
45
+ const page = await pdf.getPage(pageNum);
46
+ const viewport = page.getViewport({ scale: 2.0 });
47
+
48
+ const canvas = document.createElement("canvas");
49
+ const context = canvas.getContext("2d");
50
+ canvas.height = viewport.height;
51
+ canvas.width = viewport.width;
52
+
53
+ await page.render({
54
+ canvasContext: context,
55
+ viewport: viewport,
56
+ }).promise;
57
+
58
+ urls.push(canvas.toDataURL("image/jpeg", 0.95));
59
+ }
60
+ } catch (error) {
61
+ console.error("Error loading PDF:", error);
62
+ // Fallback: show error message
63
+ urls.push(null);
64
+ }
65
+ } else {
66
+ // For images, create object URL
67
+ const url = URL.createObjectURL(file);
68
+ urls.push(url);
69
+ newObjectUrls.push(url);
70
+ }
71
+
72
+ // Cleanup old object URLs
73
+ objectUrlsRef.current.forEach((url) => {
74
+ if (url && url.startsWith("blob:")) {
75
+ URL.revokeObjectURL(url);
76
+ }
77
+ });
78
+ objectUrlsRef.current = newObjectUrls;
79
+ setPreviewUrls(urls);
80
+ };
81
+
82
+ loadPreview();
83
+
84
+ // Cleanup function - revoke object URLs when component unmounts or file changes
85
+ return () => {
86
+ objectUrlsRef.current.forEach((url) => {
87
+ if (url && url.startsWith("blob:")) {
88
+ URL.revokeObjectURL(url);
89
+ }
90
+ });
91
+ objectUrlsRef.current = [];
92
+ };
93
+ }, [file]);
94
+
95
+ return (
96
+ <div className="h-full flex flex-col bg-white rounded-2xl border border-slate-200 overflow-hidden">
97
+ {/* Header */}
98
+ <div className="flex items-center justify-between px-5 py-4 border-b border-slate-100">
99
+ <div className="flex items-center gap-3">
100
+ <div className="h-8 w-8 rounded-lg bg-indigo-50 flex items-center justify-center">
101
+ <FileText className="h-4 w-4 text-indigo-600" />
102
+ </div>
103
+ <div>
104
+ <h3 className="font-semibold text-slate-800 text-sm">Document Preview</h3>
105
+ <p className="text-xs text-slate-400">{file?.name || "No file selected"}</p>
106
+ </div>
107
+ </div>
108
+
109
+ {file && (
110
+ <div className="flex items-center gap-1">
111
+ <Button
112
+ variant="ghost"
113
+ size="icon"
114
+ className="h-8 w-8 text-slate-400 hover:text-slate-600"
115
+ onClick={() => setZoom(Math.max(50, zoom - 25))}
116
+ >
117
+ <ZoomOut className="h-4 w-4" />
118
+ </Button>
119
+ <span className="text-xs text-slate-500 w-12 text-center">{zoom}%</span>
120
+ <Button
121
+ variant="ghost"
122
+ size="icon"
123
+ className="h-8 w-8 text-slate-400 hover:text-slate-600"
124
+ onClick={() => setZoom(Math.min(200, zoom + 25))}
125
+ >
126
+ <ZoomIn className="h-4 w-4" />
127
+ </Button>
128
+ <div className="w-px h-4 bg-slate-200 mx-2" />
129
+ <Button
130
+ variant="ghost"
131
+ size="icon"
132
+ className="h-8 w-8 text-slate-400 hover:text-slate-600"
133
+ onClick={() => setRotation((rotation + 90) % 360)}
134
+ >
135
+ <RotateCw className="h-4 w-4" />
136
+ </Button>
137
+ </div>
138
+ )}
139
+ </div>
140
+
141
+ {/* Preview Area */}
142
+ <div className="flex-1 p-6 bg-slate-50/50 overflow-auto">
143
+ {!file ? (
144
+ <div className="h-full flex items-center justify-center">
145
+ <div className="text-center">
146
+ <div className="h-20 w-20 mx-auto rounded-2xl bg-slate-100 flex items-center justify-center mb-4">
147
+ <FileText className="h-10 w-10 text-slate-300" />
148
+ </div>
149
+ <p className="text-slate-400 text-sm">Upload a document to preview</p>
150
+ </div>
151
+ </div>
152
+ ) : previewUrls.length === 0 ? (
153
+ <div className="h-full flex items-center justify-center">
154
+ <div className="text-center">
155
+ <div className="h-20 w-20 mx-auto rounded-2xl bg-slate-100 flex items-center justify-center mb-4">
156
+ <FileText className="h-10 w-10 text-slate-300" />
157
+ </div>
158
+ <p className="text-slate-400 text-sm">Loading preview...</p>
159
+ </div>
160
+ </div>
161
+ ) : (
162
+ <div className="space-y-4">
163
+ {previewUrls.map((url, index) => (
164
+ <motion.div
165
+ key={index}
166
+ initial={{ opacity: 0, y: 20 }}
167
+ animate={{ opacity: 1, y: 0 }}
168
+ transition={{ delay: index * 0.1 }}
169
+ className="relative bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden flex items-center justify-center"
170
+ style={{
171
+ minHeight: "400px",
172
+ }}
173
+ >
174
+ {url ? (
175
+ <img
176
+ src={url}
177
+ alt={`Page ${index + 1}`}
178
+ className="w-full h-auto"
179
+ style={{
180
+ transform: `scale(${zoom / 100}) rotate(${rotation}deg)`,
181
+ maxWidth: "100%",
182
+ objectFit: "contain",
183
+ transition: "transform 0.2s ease",
184
+ }}
185
+ />
186
+ ) : (
187
+ <div className="p-8 text-center">
188
+ <p className="text-slate-400 text-sm">
189
+ {isFromHistory
190
+ ? "Original document not available for historical extractions"
191
+ : "Unable to load preview"}
192
+ </p>
193
+ </div>
194
+ )}
195
+
196
+ {/* Processing overlay */}
197
+ {isProcessing && (
198
+ <motion.div
199
+ initial={{ opacity: 0 }}
200
+ animate={{ opacity: 1 }}
201
+ className="absolute inset-0 bg-indigo-600/5 backdrop-blur-[1px] pointer-events-none"
202
+ >
203
+ <motion.div
204
+ initial={{ top: 0 }}
205
+ animate={{ top: "100%" }}
206
+ transition={{
207
+ duration: 2,
208
+ repeat: Infinity,
209
+ ease: "linear",
210
+ }}
211
+ className="absolute left-0 right-0 h-1 bg-gradient-to-r from-transparent via-indigo-500 to-transparent"
212
+ />
213
+ </motion.div>
214
+ )}
215
+
216
+ {/* Page number */}
217
+ {previewUrls.length > 1 && (
218
+ <div className="absolute bottom-3 right-3 text-xs text-slate-400 bg-white/90 px-2 py-1 rounded">
219
+ Page {index + 1}
220
+ </div>
221
+ )}
222
+ </motion.div>
223
+ ))}
224
+ </div>
225
+ )}
226
+ </div>
227
+ </div>
228
+ );
229
+ }
frontend/src/components/ocr/UpgradeModal.jsx CHANGED
@@ -46,3 +46,167 @@ const features = [
46
  ];
47
 
48
  export default function UpgradeModal({ open, onClose }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  ];
47
 
48
  export default function UpgradeModal({ open, onClose }) {
49
+ if (!open) return null;
50
+
51
+ return (
52
+ <div className="fixed inset-0 z-50 flex items-center justify-center">
53
+ {/* Backdrop */}
54
+ <motion.div
55
+ initial={{ opacity: 0 }}
56
+ animate={{ opacity: 1 }}
57
+ exit={{ opacity: 0 }}
58
+ className="absolute inset-0 bg-black/50 backdrop-blur-sm"
59
+ onClick={onClose}
60
+ />
61
+
62
+ {/* Modal */}
63
+ <motion.div
64
+ initial={{ opacity: 0, scale: 0.95, y: 20 }}
65
+ animate={{ opacity: 1, scale: 1, y: 0 }}
66
+ exit={{ opacity: 0, scale: 0.95, y: 20 }}
67
+ className="relative z-10 w-full max-w-6xl max-h-[90vh] mx-4 bg-white rounded-2xl shadow-2xl overflow-hidden flex flex-col"
68
+ onClick={(e) => e.stopPropagation()}
69
+ >
70
+ {/* Header */}
71
+ <div className="sticky top-0 bg-gradient-to-r from-indigo-600 via-violet-600 to-purple-600 text-white px-8 py-6 z-10">
72
+ <button
73
+ onClick={onClose}
74
+ className="absolute right-6 top-6 h-8 w-8 rounded-lg bg-white/10 hover:bg-white/20 flex items-center justify-center transition-colors"
75
+ >
76
+ <X className="h-4 w-4" />
77
+ </button>
78
+
79
+ <motion.div
80
+ initial={{ opacity: 0, y: 20 }}
81
+ animate={{ opacity: 1, y: 0 }}
82
+ className="text-center"
83
+ >
84
+ <div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-white/10 backdrop-blur-sm mb-4">
85
+ <Sparkles className="h-4 w-4" />
86
+ <span className="text-sm font-medium">Trial Limit Reached</span>
87
+ </div>
88
+ <h2 className="text-3xl font-bold mb-2">You've processed 2 documents</h2>
89
+ <p className="text-white/80 text-lg">Continue with production-ready document intelligence</p>
90
+ </motion.div>
91
+ </div>
92
+
93
+ {/* Stats Bar */}
94
+ <div className="grid grid-cols-3 gap-6 px-8 py-6 bg-slate-50 border-b border-slate-200">
95
+ {[
96
+ { label: "Accuracy Rate", value: "99.8%", icon: CheckCircle2 },
97
+ { label: "Processing Speed", value: "< 10s", icon: Zap },
98
+ { label: "Operational Users", value: "10,000+", icon: Users }
99
+ ].map((stat, i) => (
100
+ <motion.div
101
+ key={stat.label}
102
+ initial={{ opacity: 0, y: 20 }}
103
+ animate={{ opacity: 1, y: 0 }}
104
+ transition={{ delay: i * 0.1 }}
105
+ className="text-center"
106
+ >
107
+ <div className="flex items-center justify-center gap-2 mb-1">
108
+ <stat.icon className="h-4 w-4 text-indigo-600" />
109
+ <span className="text-2xl font-bold text-slate-900">{stat.value}</span>
110
+ </div>
111
+ <p className="text-sm text-slate-500">{stat.label}</p>
112
+ </motion.div>
113
+ ))}
114
+ </div>
115
+
116
+ {/* Features Grid - Scrollable */}
117
+ <div className="flex-1 overflow-auto px-8 py-8">
118
+ <div className="text-center mb-8">
119
+ <h3 className="text-2xl font-bold text-slate-900 mb-2">
120
+ Continue to Production Use
121
+ </h3>
122
+
123
+ </div>
124
+
125
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
126
+ {features.map((feature, index) => (
127
+ <motion.div
128
+ key={feature.title}
129
+ initial={{ opacity: 0, y: 20 }}
130
+ animate={{ opacity: 1, y: 0 }}
131
+ transition={{ delay: 0.2 + index * 0.1 }}
132
+ className="group relative bg-white rounded-2xl border border-slate-200 p-6 hover:shadow-xl hover:shadow-slate-200/50 transition-all duration-300 hover:-translate-y-1 overflow-hidden"
133
+ >
134
+ {/* Gradient Background on Hover */}
135
+ <div className={`absolute inset-0 bg-gradient-to-br ${feature.gradient} opacity-0 group-hover:opacity-5 transition-opacity duration-300`} />
136
+
137
+ <div className="relative">
138
+ <div className={cn(
139
+ "h-12 w-12 rounded-xl flex items-center justify-center mb-4 group-hover:scale-110 transition-transform duration-300",
140
+ feature.color === "amber" && "bg-amber-50",
141
+ feature.color === "indigo" && "bg-indigo-50",
142
+ feature.color === "blue" && "bg-blue-50",
143
+ feature.color === "emerald" && "bg-emerald-50",
144
+ feature.color === "slate" && "bg-slate-50",
145
+ feature.color === "purple" && "bg-purple-50"
146
+ )}>
147
+ <feature.icon className={cn(
148
+ "h-6 w-6",
149
+ feature.color === "amber" && "text-amber-600",
150
+ feature.color === "indigo" && "text-indigo-600",
151
+ feature.color === "blue" && "text-blue-600",
152
+ feature.color === "emerald" && "text-emerald-600",
153
+ feature.color === "slate" && "text-slate-600",
154
+ feature.color === "purple" && "text-purple-600"
155
+ )} />
156
+ </div>
157
+ <h4 className="font-semibold text-slate-900 mb-2">{feature.title}</h4>
158
+ <p className="text-sm text-slate-600 mb-4 leading-relaxed">{feature.description}</p>
159
+
160
+ <Button
161
+ variant="ghost"
162
+ size="sm"
163
+ className={cn(
164
+ "w-full h-9 border transition-all group-hover:shadow-md",
165
+ feature.color === "amber" && "text-amber-600 hover:bg-amber-50 border-amber-200 hover:border-amber-300",
166
+ feature.color === "indigo" && "text-indigo-600 hover:bg-indigo-50 border-indigo-200 hover:border-indigo-300",
167
+ feature.color === "blue" && "text-blue-600 hover:bg-blue-50 border-blue-200 hover:border-blue-300",
168
+ feature.color === "emerald" && "text-emerald-600 hover:bg-emerald-50 border-emerald-200 hover:border-emerald-300",
169
+ feature.color === "slate" && "text-slate-600 hover:bg-slate-50 border-slate-200 hover:border-slate-300",
170
+ feature.color === "purple" && "text-purple-600 hover:bg-purple-50 border-purple-200 hover:border-purple-300"
171
+ )}
172
+ >
173
+ {feature.cta}
174
+ <ArrowRight className="h-3.5 w-3.5 ml-2 group-hover:translate-x-1 transition-transform" />
175
+ </Button>
176
+ </div>
177
+ </motion.div>
178
+ ))}
179
+ </div>
180
+ </div>
181
+
182
+ {/* CTA Footer */}
183
+ <div className="sticky bottom-0 bg-white border-t border-slate-200 px-8 py-6">
184
+ <div className="flex items-center justify-between gap-6">
185
+ <div className="flex-1">
186
+ <h4 className="font-semibold text-slate-900 mb-1">Ready to scale?</h4>
187
+ <p className="text-sm text-slate-600">No commitment. We'll tailor the demo to your documents and workflows.</p>
188
+ </div>
189
+ <div className="flex items-center gap-3">
190
+ <Button
191
+ variant="outline"
192
+ size="lg"
193
+ className="h-11 border-slate-300"
194
+ >
195
+ <Users className="h-4 w-4 mr-2" />
196
+ Talk to Sales
197
+ </Button>
198
+ <Button
199
+ size="lg"
200
+ className="h-11 bg-gradient-to-r from-indigo-600 to-violet-600 hover:from-indigo-700 hover:to-violet-700 shadow-lg shadow-indigo-500/25 hover:shadow-xl hover:shadow-indigo-500/30"
201
+ >
202
+ <Rocket className="h-4 w-4 mr-2" />
203
+ Start a production evaluation
204
+ <Sparkles className="h-4 w-4 ml-2" />
205
+ </Button>
206
+ </div>
207
+ </div>
208
+ </div>
209
+ </motion.div>
210
+ </div>
211
+ );
212
+ }
frontend/src/components/ocr/UploadZone.jsx CHANGED
@@ -21,3 +21,231 @@ const ALLOWED_EXTENSIONS = [".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".tif"];
21
  const MAX_FILE_SIZE = 4 * 1024 * 1024; // 4 MB in bytes
22
 
23
  export default function UploadZone({ onFileSelect, selectedFile, onClear, keyFields = "", onKeyFieldsChange = () => {} }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  const MAX_FILE_SIZE = 4 * 1024 * 1024; // 4 MB in bytes
22
 
23
  export default function UploadZone({ onFileSelect, selectedFile, onClear, keyFields = "", onKeyFieldsChange = () => {} }) {
24
+ const [isDragging, setIsDragging] = useState(false);
25
+ const [error, setError] = useState(null);
26
+
27
+ const validateFile = (file) => {
28
+ // Reset error
29
+ setError(null);
30
+
31
+ // Check file type
32
+ const fileExtension = "." + file.name.split(".").pop().toLowerCase();
33
+ const isValidType = ALLOWED_TYPES.includes(file.type) || ALLOWED_EXTENSIONS.includes(fileExtension);
34
+
35
+ if (!isValidType) {
36
+ setError("Only PDF, PNG, JPG, and TIFF files are allowed.");
37
+ return false;
38
+ }
39
+
40
+ // Check file size
41
+ if (file.size > MAX_FILE_SIZE) {
42
+ const fileSizeMB = (file.size / 1024 / 1024).toFixed(2);
43
+ setError(`File size exceeds 4 MB limit. Your file is ${fileSizeMB} MB.`);
44
+ return false;
45
+ }
46
+
47
+ return true;
48
+ };
49
+
50
+ const handleFileSelect = (file) => {
51
+ if (validateFile(file)) {
52
+ setError(null);
53
+ onFileSelect(file);
54
+ }
55
+ };
56
+
57
+ const handleDragOver = (e) => {
58
+ e.preventDefault();
59
+ setIsDragging(true);
60
+ };
61
+
62
+ const handleDragLeave = () => {
63
+ setIsDragging(false);
64
+ };
65
+
66
+ const handleDrop = (e) => {
67
+ e.preventDefault();
68
+ setIsDragging(false);
69
+ const file = e.dataTransfer.files[0];
70
+ if (file) {
71
+ handleFileSelect(file);
72
+ }
73
+ };
74
+
75
+ const getFileIcon = (type) => {
76
+ if (type?.includes("image")) return Image;
77
+ if (type?.includes("spreadsheet") || type?.includes("excel")) return FileSpreadsheet;
78
+ return FileText;
79
+ };
80
+
81
+ const FileIcon = selectedFile ? getFileIcon(selectedFile.type) : FileText;
82
+
83
+ // Clear error when file is cleared
84
+ useEffect(() => {
85
+ if (!selectedFile) {
86
+ setError(null);
87
+ }
88
+ }, [selectedFile]);
89
+
90
+ return (
91
+ <div className="w-full">
92
+ <AnimatePresence mode="wait">
93
+ {!selectedFile ? (
94
+ <motion.div
95
+ key="upload"
96
+ initial={{ opacity: 0, y: 10 }}
97
+ animate={{ opacity: 1, y: 0 }}
98
+ exit={{ opacity: 0, y: -10 }}
99
+ transition={{ duration: 0.2 }}
100
+ onDragOver={handleDragOver}
101
+ onDragLeave={handleDragLeave}
102
+ onDrop={handleDrop}
103
+ className={cn(
104
+ "relative group cursor-pointer",
105
+ "border-2 border-dashed rounded-2xl",
106
+ "transition-all duration-300 ease-out",
107
+ isDragging
108
+ ? "border-indigo-400 bg-indigo-50/50"
109
+ : "border-slate-200 hover:border-indigo-300 hover:bg-slate-50/50"
110
+ )}
111
+ >
112
+ <label className="flex flex-col items-center justify-center py-16 px-8 cursor-pointer">
113
+ <motion.div
114
+ animate={isDragging ? { scale: 1.1, y: -5 } : { scale: 1, y: 0 }}
115
+ className={cn(
116
+ "h-16 w-16 rounded-2xl flex items-center justify-center mb-6 transition-colors duration-300",
117
+ isDragging
118
+ ? "bg-indigo-100"
119
+ : "bg-gradient-to-br from-slate-100 to-slate-50 group-hover:from-indigo-100 group-hover:to-violet-50"
120
+ )}
121
+ >
122
+ <Upload
123
+ className={cn(
124
+ "h-7 w-7 transition-colors duration-300",
125
+ isDragging ? "text-indigo-600" : "text-slate-400 group-hover:text-indigo-500"
126
+ )}
127
+ />
128
+ </motion.div>
129
+
130
+ <div className="text-center">
131
+ <p className="text-lg font-semibold text-slate-700 mb-1">
132
+ {isDragging ? "Drop your file here" : "Drop your file here, or browse"}
133
+ </p>
134
+ <p className="text-sm text-slate-400">
135
+ Supports PDF, PNG, JPG, TIFF up to 4MB
136
+ </p>
137
+ </div>
138
+
139
+ <div className="flex items-center gap-2 mt-6">
140
+ <div className="flex -space-x-1">
141
+ {[
142
+ "bg-red-100 text-red-600",
143
+ "bg-blue-100 text-blue-600",
144
+ "bg-green-100 text-green-600",
145
+ "bg-amber-100 text-amber-600",
146
+ ].map((color, i) => (
147
+ <div
148
+ key={i}
149
+ className={`h-8 w-8 rounded-lg ${color.split(" ")[0]} flex items-center justify-center border-2 border-white`}
150
+ >
151
+ <FileText className={`h-4 w-4 ${color.split(" ")[1]}`} />
152
+ </div>
153
+ ))}
154
+ </div>
155
+ <span className="text-xs text-slate-400 ml-2">Multiple formats supported</span>
156
+ </div>
157
+
158
+ <input
159
+ type="file"
160
+ className="hidden"
161
+ accept=".pdf,.png,.jpg,.jpeg,.tiff,.tif"
162
+ onChange={(e) => {
163
+ const file = e.target.files[0];
164
+ if (file) {
165
+ handleFileSelect(file);
166
+ }
167
+ // Reset input so same file can be selected again after error
168
+ e.target.value = "";
169
+ }}
170
+ />
171
+ </label>
172
+
173
+ {/* Decorative gradient border on hover */}
174
+ <div className="absolute inset-0 -z-10 rounded-2xl bg-gradient-to-r from-indigo-500 via-violet-500 to-purple-500 opacity-0 group-hover:opacity-10 blur-xl transition-opacity duration-500" />
175
+ </motion.div>
176
+ ) : (
177
+ <motion.div
178
+ key="selected"
179
+ initial={{ opacity: 0, scale: 0.95 }}
180
+ animate={{ opacity: 1, scale: 1 }}
181
+ exit={{ opacity: 0, scale: 0.95 }}
182
+ className="grid grid-cols-1 lg:grid-cols-2 gap-3"
183
+ >
184
+ {/* File Info Box */}
185
+ <div className="relative bg-gradient-to-br from-indigo-50 to-violet-50 rounded-xl p-3 border border-indigo-100">
186
+ <div className="flex items-center gap-3">
187
+ <div className="h-10 w-10 rounded-lg bg-white shadow-sm flex items-center justify-center flex-shrink-0">
188
+ <FileIcon className="h-5 w-5 text-indigo-600" />
189
+ </div>
190
+ <div className="flex-1 min-w-0">
191
+ <p className="font-medium text-slate-800 truncate text-sm">{selectedFile.name}</p>
192
+ <div className="flex items-center gap-2 text-xs text-slate-500">
193
+ <span>{(selectedFile.size / 1024 / 1024).toFixed(2)} MB</span>
194
+ <span className="text-indigo-500">•</span>
195
+ <span className="text-indigo-600 flex items-center gap-1">
196
+ <Sparkles className="h-3 w-3" />
197
+ Ready for extraction
198
+ </span>
199
+ </div>
200
+ </div>
201
+ <button
202
+ onClick={onClear}
203
+ className="h-8 w-8 rounded-lg bg-white hover:bg-red-50 border border-slate-200 hover:border-red-200 flex items-center justify-center text-slate-400 hover:text-red-500 transition-colors"
204
+ >
205
+ <X className="h-4 w-4" />
206
+ </button>
207
+ </div>
208
+ </div>
209
+
210
+ {/* Key Fields Box */}
211
+ <div className="relative bg-white rounded-xl p-3 border border-slate-200">
212
+ <label className="block text-xs font-medium text-slate-600 mb-1.5">
213
+ <span className="font-bold">Key Fields</span> <span className="font-normal">(if required)</span>
214
+ </label>
215
+ <Input
216
+ type="text"
217
+ value={keyFields || ""}
218
+ onChange={(e) => {
219
+ if (onKeyFieldsChange) {
220
+ onKeyFieldsChange(e.target.value);
221
+ }
222
+ }}
223
+ placeholder="Invoice Number, Invoice Date, PO Number, Supplier Name, Total Amount, Payment terms, Additional Notes"
224
+ className="h-8 text-xs border-slate-200 focus:border-indigo-300 focus:ring-indigo-200"
225
+ />
226
+ </div>
227
+ </motion.div>
228
+ )}
229
+ </AnimatePresence>
230
+
231
+ {/* Error Message */}
232
+ {error && (
233
+ <motion.div
234
+ initial={{ opacity: 0, y: -10 }}
235
+ animate={{ opacity: 1, y: 0 }}
236
+ exit={{ opacity: 0, y: -10 }}
237
+ className="mt-3 p-3 bg-red-50 border border-red-200 rounded-xl flex items-start gap-2"
238
+ >
239
+ <AlertCircle className="h-4 w-4 text-red-600 flex-shrink-0 mt-0.5" />
240
+ <p className="text-sm text-red-700 flex-1">{error}</p>
241
+ <button
242
+ onClick={() => setError(null)}
243
+ className="text-red-600 hover:text-red-800 transition-colors"
244
+ >
245
+ <X className="h-4 w-4" />
246
+ </button>
247
+ </motion.div>
248
+ )}
249
+ </div>
250
+ );
251
+ }