AI_Formatter_V02 / src /App.svelte
Varun10000's picture
Update src/App.svelte
3c7721d verified
Raw
History Blame Contribute Delete
16.1 kB
<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)&#10;Line 2&#10;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>