Spaces:
Sleeping
Sleeping
| <script> | |
| import { onMount } from 'svelte'; | |
| import { Sparkles, Download, Settings, FileText, Loader2, Copy, Check } from 'lucide-svelte'; | |
| import { fade, slide, fly } from 'svelte/transition'; | |
| import { formatWithAI } from './utils/aiFormatter'; | |
| import { exportToDocx } from './utils/docxExporter'; | |
| import './app.css'; | |
| let inputText = ''; | |
| let formattedElements = []; | |
| let isFormatting = false; | |
| let apiKey = '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'; | |
| let showConfig = false; | |
| let provider = 'groq'; | |
| let isCopied = false; | |
| let docType = 'rfp'; // 'rfp' or 'contract' | |
| let logoBase64 = null; | |
| let contractAddress = ''; | |
| let formattingProgress = ''; | |
| const handleLogoUpload = (e) => { | |
| const file = e.target.files[0]; | |
| if (file) { | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| logoBase64 = e.target.result; | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| }; | |
| async function handleFormat() { | |
| if (!inputText.trim()) return; | |
| isFormatting = true; | |
| formattingProgress = 'Starting...'; | |
| try { | |
| const result = await formatWithAI(inputText, apiKey, provider, (current, total) => { | |
| formattingProgress = total > 1 ? `Processing Part ${current}/${total}...` : 'Formatting...'; | |
| }); | |
| formattedElements = result; | |
| } catch (error) { | |
| alert(error.message || 'Formatting failed. Please try again.'); | |
| } finally { | |
| isFormatting = false; | |
| formattingProgress = ''; | |
| } | |
| } | |
| const handleExport = () => { | |
| if (formattedElements.length === 0) return; | |
| exportToDocx(formattedElements, 'Formatted_Document.docx', docType, logoBase64, contractAddress); | |
| }; | |
| async function handleCopyText() { | |
| if (formattedElements.length === 0) return; | |
| function formatText(text) { | |
| if (typeof text !== 'string') return text; | |
| return text | |
| .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') | |
| .replace(/\*(.*?)\*/g, '<em>$1</em>'); | |
| } | |
| function stripPrefix(text) { | |
| if (typeof text !== 'string') return text; | |
| // Removes things like (a), 1., a., -, • from the start | |
| return text.replace(/^(\([a-zA-Z0-9]+\)|[a-zA-Z0-9]+[\.\)]|[\-\•])\s*/, ''); | |
| } | |
| // Convert formatted elements to rich HTML | |
| const htmlContent = formattedElements.map(el => { | |
| const isRfp = docType === 'rfp'; | |
| const align = el.alignment === 'center' ? 'text-align: center;' : 'text-align: left;'; | |
| const spacing = isRfp ? '0 0 12pt 0' : '24pt 0 16pt 0'; // More spacing for contract mode | |
| if (el.type === 'heading1') { | |
| const bg = isRfp ? 'background-color: #FFC000; padding: 5px;' : ''; | |
| return `<h1 style="font-size: 11pt; font-weight: bold; margin: ${spacing}; color: #000000; ${bg} ${align} line-height: 1.5; font-family: 'Times New Roman', serif;">${formatText(el.content)}</h1>`; | |
| } else if (el.type === 'heading2') { | |
| const h2Spacing = isRfp ? '12pt 0 8pt 0' : '20pt 0 12pt 0'; | |
| return `<h2 style="font-size: 11pt; font-weight: bold; margin: ${h2Spacing}; color: #000000; ${align} line-height: 1.5; font-family: 'Times New Roman', serif;">${formatText(el.content)}</h2>`; | |
| } else if (el.type === 'heading3') { | |
| const h3Spacing = isRfp ? '10pt 0 6pt 0' : '16pt 0 10pt 0'; | |
| return `<h3 style="font-size: 11pt; font-weight: bold; margin: ${h3Spacing}; color: #000000; ${align} line-height: 1.5; font-family: 'Times New Roman', serif;">${formatText(el.content)}</h3>`; | |
| } else if (el.type === 'bulletList' || el.type === 'numberedList') { | |
| const tag = el.type === 'bulletList' ? 'ul' : 'ol'; | |
| const listStyle = el.type === 'numberedList' && el.listType === 'lowerAlpha' ? 'list-style-type: lower-alpha;' : ''; | |
| const listSpacing = isRfp ? '4px 0' : '8px 0'; | |
| const items = el.content.map(item => `<li style="margin: 0; padding: 0; line-height: 1.5; font-family: 'Times New Roman', serif; color: #000000;">${formatText(stripPrefix(item))}</li>`).join(''); | |
| return `<${tag} style="margin: ${listSpacing}; padding-left: 30px; line-height: 1.5; ${listStyle} color: #000000;">${items}</${tag}>`; | |
| } else if (el.type === 'paragraph') { | |
| const pSpacing = isRfp ? '4px 0' : '12px 0'; | |
| return `<p style="margin: ${pSpacing}; color: #000000; ${align} line-height: 1.5; font-family: 'Times New Roman', serif;">${formatText(el.content)}</p>`; | |
| } else if (el.type === 'table') { | |
| const rows = el.content.map(row => { | |
| const cells = row.map(cell => `<td style="border: 1px solid black; padding: 6px 10px; font-family: 'Times New Roman', serif; font-size: 11pt; background-color: #FFFFFF; color: #000000; line-height: 1.5;">${formatText(cell)}</td>`).join(''); | |
| return `<tr>${cells}</tr>`; | |
| }).join(''); | |
| return `<table style="border-collapse: collapse; width: 100%; margin: 8px 0; border: 1px solid black; line-height: 1.5; color: #000000;">${rows}</table>`; | |
| } | |
| return ''; | |
| }).join(''); | |
| // Plain text fallback | |
| const plainText = formattedElements.map(el => { | |
| if (el.type === 'bulletList' || el.type === 'numberedList') { | |
| const isAlpha = el.type === 'numberedList' && el.listType === 'lowerAlpha'; | |
| const prefix = el.type === 'bulletList' ? '• ' : (isAlpha ? 'a. ' : '1. '); | |
| return el.content.map((item, i) => { | |
| let currentPrefix = prefix; | |
| if (isAlpha) { | |
| currentPrefix = String.fromCharCode(97 + i) + '. '; | |
| } else if (el.type === 'numberedList') { | |
| currentPrefix = (i + 1) + '. '; | |
| } | |
| return `${currentPrefix}${String(stripPrefix(item)).replace(/\*\*(.*?)\*\*/g, '$1').replace(/\*(.*?)\*/g, '$1')}`; | |
| }).join('\n'); | |
| } | |
| if (el.type === 'table') { | |
| return el.content.map(row => row.join(' | ')).join('\n'); | |
| } | |
| const text = typeof el.content === 'string' ? el.content : String(el.content); | |
| return text.replace(/\*\*(.*?)\*\*/g, '$1').replace(/\*(.*?)\*/g, '$1'); | |
| }).join('\n\n'); | |
| try { | |
| const blob = new Blob([htmlContent], { type: 'text/html' }); | |
| const textBlob = new Blob([plainText], { type: 'text/plain' }); | |
| await navigator.clipboard.write([ | |
| new ClipboardItem({ | |
| 'text/html': blob, | |
| 'text/plain': textBlob | |
| }) | |
| ]); | |
| isCopied = true; | |
| setTimeout(() => { | |
| isCopied = false; | |
| }, 2000); | |
| } catch (error) { | |
| alert('Failed to copy text to clipboard'); | |
| } | |
| } | |
| function renderRichText(text) { | |
| if (typeof text !== 'string') return text; | |
| const parts = []; | |
| const regex = /(\*\*.*?\*\*|\*.*?\*)/g; | |
| let lastIndex = 0; | |
| let match; | |
| while ((match = regex.exec(text)) !== null) { | |
| if (match.index > lastIndex) { | |
| parts.push({ type: 'text', content: text.substring(lastIndex, match.index) }); | |
| } | |
| const content = match[0]; | |
| if (content.startsWith('**')) { | |
| parts.push({ type: 'bold', content: content.slice(2, -2) }); | |
| } else if (content.startsWith('*')) { | |
| parts.push({ type: 'italic', content: content.slice(1, -1) }); | |
| } | |
| lastIndex = regex.lastIndex; | |
| } | |
| if (lastIndex < text.length) { | |
| parts.push({ type: 'text', content: text.substring(lastIndex) }); | |
| } | |
| return parts.length > 0 ? parts : [{ type: 'text', content: text }]; | |
| } | |
| function stripPrefix(text) { | |
| if (typeof text !== 'string') return text; | |
| return text.replace(/^(\([a-zA-Z0-9]+\)|[a-zA-Z0-9]+[\.\)]|[\-\•])\s*/, ''); | |
| } | |
| </script> | |
| <div class="app-container" in:fade={{ duration: 800 }}> | |
| <header> | |
| <h1 in:fly={{ y: -20, duration: 500 }}>AI Doc Formatter</h1> | |
| <p class="subtitle">Transform messy Google Docs copies into professional Word documents instantly.</p> | |
| </header> | |
| <div class="main-grid"> | |
| <!-- Input Card --> | |
| <div class="card" in:fly={{ x: -20, duration: 500, delay: 200 }}> | |
| <div class="card-header"> | |
| <span class="card-title">Raw Content</span> | |
| </div> | |
| <textarea | |
| placeholder="Paste your messy text here..." | |
| bind:value={inputText} | |
| /> | |
| <button | |
| class="btn btn-primary" | |
| on:click={handleFormat} | |
| disabled={isFormatting || !inputText.trim()} | |
| > | |
| {#if isFormatting} | |
| <Loader2 class="animate-spin" size={20} /> | |
| {formattingProgress} | |
| {:else} | |
| <Sparkles size={20} /> | |
| Format with AI | |
| {/if} | |
| </button> | |
| <div class="doc-type-toggle"> | |
| <button | |
| class="toggle-btn {docType === 'rfp' ? 'active' : ''}" | |
| on:click={() => docType = 'rfp'} | |
| > | |
| RFP Format | |
| </button> | |
| <button | |
| class="toggle-btn {docType === 'contract' ? 'active' : ''}" | |
| on:click={() => docType = 'contract'} | |
| > | |
| General Contract | |
| </button> | |
| </div> | |
| {#if docType === 'contract'} | |
| <div class="logo-upload-container" style="margin-top: 1rem; display: flex; align-items: center; gap: 1rem;"> | |
| <label class="btn btn-outline" style="cursor: pointer;"> | |
| <input type="file" accept="image/*" on:change={handleLogoUpload} style="display: none;"> | |
| {#if logoBase64} | |
| Change Logo | |
| {:else} | |
| Upload Logo | |
| {/if} | |
| </label> | |
| {#if logoBase64} | |
| <span style="font-size: 0.9rem; color: green;">Logo Uploaded ✓</span> | |
| <button class="btn-text" on:click={() => logoBase64 = null} style="color: red; font-size: 0.8rem; margin-left: auto;">Remove</button> | |
| {/if} | |
| </div> | |
| <div class="input-container" style="margin-top: 1rem;"> | |
| <textarea | |
| bind:value={contractAddress} | |
| placeholder="Company Address (Optional) Line 2 Line 3" | |
| class="text-input" | |
| rows="3" | |
| style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; resize: vertical; min-height: 80px; font-family: inherit;" | |
| ></textarea> | |
| </div> | |
| {/if} | |
| </div> | |
| <!-- Preview Card --> | |
| <div class="card" in:fly={{ x: 20, duration: 500, delay: 400 }}> | |
| <div class="card-header"> | |
| <span class="card-title">Formatted Preview</span> | |
| <div style="display: flex; gap: 0.5rem;"> | |
| <button | |
| class="btn btn-outline" | |
| on:click={handleCopyText} | |
| disabled={formattedElements.length === 0} | |
| > | |
| {#if isCopied} | |
| <Check size={18} /> | |
| Copied! | |
| {:else} | |
| <Copy size={18} /> | |
| Copy Text | |
| {/if} | |
| </button> | |
| <button | |
| class="btn btn-outline" | |
| on:click={handleExport} | |
| disabled={formattedElements.length === 0} | |
| > | |
| <Download size={18} /> | |
| Export .docx | |
| </button> | |
| </div> | |
| </div> | |
| <div class="preview-area {docType}"> | |
| {#if logoBase64 || contractAddress} | |
| <div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 2rem; border-bottom: 1px solid transparent;"> | |
| <div style="flex: 0 0 35%; text-align: left;"> | |
| {#if logoBase64} | |
| <img src={logoBase64} alt="Document Logo" style="max-height: 60px; max-width: 200px;"> | |
| {/if} | |
| </div> | |
| <div style="flex: 0 0 65%; text-align: right;"> | |
| {#if contractAddress} | |
| <div style="font-size: 10pt; color: #000000; font-family: 'Times New Roman', serif; white-space: pre-wrap; line-height: 1.4;">{contractAddress}</div> | |
| {/if} | |
| </div> | |
| </div> | |
| {/if} | |
| {#if isFormatting} | |
| <div class="skeleton-container"> | |
| <div class="skeleton skeleton-h1"></div> | |
| <div class="skeleton skeleton-p"></div> | |
| <div class="skeleton skeleton-p"></div> | |
| <div class="skeleton skeleton-h2"></div> | |
| <div class="skeleton skeleton-p"></div> | |
| </div> | |
| {:else if formattedElements.length === 0} | |
| <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: var(--text-muted)"> | |
| <FileText size={48} style="margin-bottom: 1rem; opacity: 0.5" /> | |
| <p>Formatted content will appear here</p> | |
| </div> | |
| {:else} | |
| {#each formattedElements as el} | |
| {#if el.type === 'heading1'} | |
| <h1 class="preview-h1" style="{el.alignment === 'center' ? 'text-align: center;' : ''} {docType === 'rfp' ? 'background-color: #FFC000; padding: 5px;' : ''}"> | |
| {#each renderRichText(el.content) as part} | |
| {#if part.type === 'bold'}<strong>{part.content}</strong>{:else if part.type === 'italic'}<em>{part.content}</em>{:else}{part.content}{/if} | |
| {/each} | |
| </h1> | |
| {:else if el.type === 'heading2'} | |
| <h2 class="preview-h2" style="{el.alignment === 'center' ? 'text-align: center' : ''}"> | |
| {#each renderRichText(el.content) as part} | |
| {#if part.type === 'bold'}<strong>{part.content}</strong>{:else if part.type === 'italic'}<em>{part.content}</em>{:else}{part.content}{/if} | |
| {/each} | |
| </h2> | |
| {:else if el.type === 'heading3'} | |
| <h3 class="preview-h3" style="{el.alignment === 'center' ? 'text-align: center' : ''}"> | |
| {#each renderRichText(el.content) as part} | |
| {#if part.type === 'bold'}<strong>{part.content}</strong>{:else if part.type === 'italic'}<em>{part.content}</em>{:else}{part.content}{/if} | |
| {/each} | |
| </h3> | |
| {:else if el.type === 'bulletList' || el.type === 'numberedList'} | |
| <svelte:element | |
| this={el.type === 'bulletList' ? 'ul' : 'ol'} | |
| class="preview-ul" | |
| style="{(el.type === 'numberedList' && el.listType === 'lowerAlpha' ? 'list-style-type: lower-alpha;' : '') + (el.alignment === 'center' ? 'text-align: center;' : '')}" | |
| > | |
| {#each el.content as item} | |
| <li class="preview-li"> | |
| {#each renderRichText(stripPrefix(item)) as part} | |
| {#if part.type === 'bold'}<strong>{part.content}</strong>{:else if part.type === 'italic'}<em>{part.content}</em>{:else}{part.content}{/if} | |
| {/each} | |
| </li> | |
| {/each} | |
| </svelte:element> | |
| {:else if el.type === 'table'} | |
| <table class="preview-table"> | |
| {#each el.content as row} | |
| <tr> | |
| {#each row as cell} | |
| <td> | |
| {#each renderRichText(cell) as part} | |
| {#if part.type === 'bold'}<strong>{part.content}</strong>{:else if part.type === 'italic'}<em>{part.content}</em>{:else}{part.content}{/if} | |
| {/each} | |
| </td> | |
| {/each} | |
| </tr> | |
| {/each} | |
| </table> | |
| {:else if el.type === 'paragraph'} | |
| <p class="preview-p" style="{el.alignment === 'center' ? 'text-align: center' : ''}"> | |
| {#each renderRichText(el.content) as part} | |
| {#if part.type === 'bold'}<strong>{part.content}</strong>{:else if part.type === 'italic'}<em>{part.content}</em>{:else}{part.content}{/if} | |
| {/each} | |
| </p> | |
| {/if} | |
| {/each} | |
| {/if} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <style> | |
| /* Local styles if needed, but we use App.css */ | |
| :global(.animate-spin) { | |
| animation: spin 1s linear infinite; | |
| } | |
| @keyframes spin { | |
| from { transform: rotate(0deg); } | |
| to { transform: rotate(360deg); } | |
| } | |
| </style> | |