Initial commit: TypoGenie - Markdown to Word document converter

This commit is contained in:
TypoGenie
2026-01-29 18:10:07 +02:00
commit e1e46015ea
28 changed files with 6545 additions and 0 deletions

92
components/FileUpload.tsx Normal file
View File

@@ -0,0 +1,92 @@
import React, { useCallback, useState } from 'react';
import { Upload, FileText, AlertCircle } from 'lucide-react';
interface FileUploadProps {
onFileLoaded: (content: string) => void;
}
export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
const [dragActive, setDragActive] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleFile = (file: File) => {
setError(null);
if (!file.name.endsWith('.md') && !file.name.endsWith('.txt') && !file.name.endsWith('.markdown')) {
setError('Please upload a Markdown (.md) or Text (.txt) file.');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result;
if (typeof text === 'string') {
onFileLoaded(text);
}
};
reader.onerror = () => setError('Error reading file.');
reader.readAsText(file);
};
const handleDrag = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true);
} else if (e.type === 'dragleave') {
setDragActive(false);
}
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleFile(e.dataTransfer.files[0]);
}
}, []);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
e.preventDefault();
if (e.target.files && e.target.files[0]) {
handleFile(e.target.files[0]);
}
};
return (
<div className="w-full max-w-xl mx-auto">
<div
className={`relative group flex flex-col items-center justify-center w-full h-64 border-2 border-dashed rounded-2xl transition-all duration-300 ease-in-out cursor-pointer overflow-hidden
${dragActive ? 'border-indigo-500 bg-indigo-500/10' : 'border-zinc-700 bg-zinc-900/50 hover:border-zinc-500 hover:bg-zinc-800/50'}`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
<input
type="file"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
onChange={handleChange}
accept=".md,.txt,.markdown"
/>
<div className="flex flex-col items-center justify-center pt-5 pb-6 text-zinc-400 group-hover:text-zinc-200 transition-colors">
<div className={`p-4 rounded-full mb-4 ${dragActive ? 'bg-indigo-500/20 text-indigo-400' : 'bg-zinc-800 text-zinc-500'}`}>
<Upload size={32} />
</div>
<p className="mb-2 text-lg font-medium">
<span className="font-semibold text-indigo-400">Click to upload</span> or drag and drop
</p>
<p className="text-sm text-zinc-500">Markdown or Plain Text files</p>
</div>
</div>
{error && (
<div className="mt-4 p-4 bg-red-900/20 border border-red-800 rounded-lg flex items-center gap-3 text-red-200 animate-in fade-in slide-in-from-top-2">
<AlertCircle size={20} />
<span>{error}</span>
</div>
)}
</div>
);
};

374
components/Preview.tsx Normal file
View File

@@ -0,0 +1,374 @@
import React, { useEffect, useRef, useState } from 'react';
import { ArrowLeft, Download, FileText, CheckCircle2, ExternalLink } from 'lucide-react';
import { PaperSize, DocxStyleConfig, DocxBorder } from '../types';
import { TYPOGRAPHY_STYLES } from '../constants';
// @ts-ignore
import * as docx from 'docx';
interface PreviewProps {
htmlContent: string;
onBack: () => void;
paperSize: PaperSize;
selectedStyleId?: string | null;
}
export const Preview: React.FC<PreviewProps> = ({ htmlContent, onBack, paperSize, selectedStyleId }) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [isExporting, setIsExporting] = useState(false);
const [successMsg, setSuccessMsg] = useState(false);
// Get current style
const style = TYPOGRAPHY_STYLES.find(s => s.id === selectedStyleId) || TYPOGRAPHY_STYLES[0];
// Extract unique fonts for display
const usedFonts = Array.from(new Set([
style.wordConfig.heading1.font,
style.wordConfig.heading2.font,
style.wordConfig.body.font
])).filter(Boolean);
// Helper to convert Points to Half-Points (docx standard)
const pt = (points: number) => points * 2;
// Helper to convert Inches/MM to Twips
const inchesToTwips = (inches: number) => Math.round(inches * 1440);
const mmToTwips = (mm: number) => Math.round(mm * (1440 / 25.4));
// Helper to map Border config to Docx Border
const mapBorder = (b?: DocxBorder) => {
if (!b) return undefined;
let style = docx.BorderStyle.SINGLE;
if (b.style === 'double') style = docx.BorderStyle.DOUBLE;
if (b.style === 'dotted') style = docx.BorderStyle.DOTTED;
if (b.style === 'dashed') style = docx.BorderStyle.DASHED;
return {
color: b.color,
space: b.space,
style: style,
size: b.size
};
};
const generateDocx = async (styleId: string) => {
setIsExporting(true);
try {
const style = TYPOGRAPHY_STYLES.find(s => s.id === styleId) || TYPOGRAPHY_STYLES[0];
const cfg = style.wordConfig;
// PARSE HTML
const parser = new DOMParser();
const doc = parser.parseFromString(htmlContent, 'text/html');
const nodes = Array.from(doc.body.childNodes);
const docxChildren = [];
for (const node of nodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;
const el = node as HTMLElement;
const tagName = el.tagName.toLowerCase();
// --- Run Parser (Inline Styles) ---
const parseRuns = (element: HTMLElement, baseConfig: DocxStyleConfig) => {
const runs = [];
for (const child of Array.from(element.childNodes)) {
if (child.nodeType === Node.TEXT_NODE) {
runs.push(new docx.TextRun({
text: child.textContent || '',
font: baseConfig.font,
size: pt(baseConfig.size),
color: baseConfig.color,
underline: baseConfig.underline ? { type: docx.UnderlineType.SINGLE, color: baseConfig.color } : undefined,
}));
} else if (child.nodeType === Node.ELEMENT_NODE) {
const childEl = child as HTMLElement;
const isBold = childEl.tagName === 'STRONG' || childEl.tagName === 'B';
const isItalic = childEl.tagName === 'EM' || childEl.tagName === 'I';
runs.push(new docx.TextRun({
text: childEl.textContent || '',
bold: isBold,
italics: isItalic,
font: baseConfig.font,
size: pt(baseConfig.size),
color: baseConfig.color,
underline: baseConfig.underline ? { type: docx.UnderlineType.SINGLE, color: baseConfig.color } : undefined,
}));
}
}
return runs;
};
// --- Block Parser ---
// 1. HEADINGS
if (tagName.match(/^h[1-6]$/)) {
const level = parseInt(tagName.replace('h', ''));
// Select config based on level (simplified to H1 or H2, fallback H2 for others)
const hCfg = level === 1 ? cfg.heading1 : cfg.heading2;
const headingLevel = level === 1 ? docx.HeadingLevel.HEADING_1 : docx.HeadingLevel.HEADING_2;
// Border Mapping
const borderConfig: any = {};
if (hCfg.border) {
if (hCfg.border.top) borderConfig.top = mapBorder(hCfg.border.top);
if (hCfg.border.bottom) borderConfig.bottom = mapBorder(hCfg.border.bottom);
if (hCfg.border.left) borderConfig.left = mapBorder(hCfg.border.left);
if (hCfg.border.right) borderConfig.right = mapBorder(hCfg.border.right);
}
// Alignment Mapping
let align = docx.AlignmentType.LEFT;
if (hCfg.align === 'center') align = docx.AlignmentType.CENTER;
if (hCfg.align === 'right') align = docx.AlignmentType.RIGHT;
if (hCfg.align === 'both') align = docx.AlignmentType.BOTH;
docxChildren.push(new docx.Paragraph({
children: [
new docx.TextRun({
text: el.textContent || '',
font: hCfg.font,
bold: hCfg.bold,
italics: hCfg.italic,
underline: hCfg.underline ? { type: docx.UnderlineType.SINGLE, color: hCfg.color } : undefined,
size: pt(hCfg.size),
color: hCfg.color,
allCaps: hCfg.allCaps,
smallCaps: hCfg.smallCaps,
characterSpacing: hCfg.tracking
})
],
heading: headingLevel,
alignment: align,
spacing: {
before: hCfg.spacing?.before,
after: hCfg.spacing?.after,
line: hCfg.spacing?.line
},
border: borderConfig,
shading: hCfg.shading ? {
fill: hCfg.shading.fill,
color: hCfg.shading.color,
type: docx.ShadingType.CLEAR // usually clear to show fill
} : undefined,
keepNext: true,
keepLines: true
}));
}
// 2. PARAGRAPHS
else if (tagName === 'p') {
let align = docx.AlignmentType.LEFT;
if (cfg.body.align === 'center') align = docx.AlignmentType.CENTER;
if (cfg.body.align === 'right') align = docx.AlignmentType.RIGHT;
if (cfg.body.align === 'both') align = docx.AlignmentType.BOTH;
docxChildren.push(new docx.Paragraph({
children: parseRuns(el, cfg.body),
spacing: {
before: cfg.body.spacing?.before,
after: cfg.body.spacing?.after,
line: cfg.body.spacing?.line,
lineRule: docx.LineRuleType.AUTO
},
alignment: align
}));
}
// 3. BLOCKQUOTES
else if (tagName === 'blockquote') {
docxChildren.push(new docx.Paragraph({
children: parseRuns(el, { ...cfg.body, size: cfg.body.size + 1, color: cfg.accentColor, italic: true } as DocxStyleConfig),
indent: { left: 720 }, // 0.5 inch
border: { left: { color: cfg.accentColor, space: 10, style: docx.BorderStyle.SINGLE, size: 24 } },
shading: { fill: "F8F8F8", type: docx.ShadingType.CLEAR, color: "auto" }, // Default light grey background for quotes
spacing: { before: 200, after: 200, line: 300 }
}));
}
// 4. LISTS
else if (tagName === 'ul' || tagName === 'ol') {
const listItems = Array.from(el.children);
for (const li of listItems) {
docxChildren.push(new docx.Paragraph({
children: parseRuns(li as HTMLElement, cfg.body),
bullet: { level: 0 },
spacing: { before: 80, after: 80 }
}));
}
}
}
// Create Document
const docxFile = new docx.Document({
sections: [{
properties: {
page: {
size: {
width: paperSize === 'A4' ? mmToTwips(210) : inchesToTwips(8.5),
height: paperSize === 'A4' ? mmToTwips(297) : inchesToTwips(11),
},
margin: {
top: inchesToTwips(1),
right: inchesToTwips(1.2),
bottom: inchesToTwips(1),
left: inchesToTwips(1.2),
}
}
},
children: docxChildren
}]
});
const blob = await docx.Packer.toBlob(docxFile);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `typogenie-${style.name.replace(/\s+/g, '-').toLowerCase()}.docx`;
a.click();
window.URL.revokeObjectURL(url);
setSuccessMsg(true);
setTimeout(() => setSuccessMsg(false), 3000);
} catch (e) {
console.error("Docx Gen Error", e);
alert("Failed to generate DOCX");
} finally {
setIsExporting(false);
}
};
useEffect(() => {
if (!iframeRef.current) return;
// We already have 'style' from the component scope, but useEffect needs to be robust
const currentStyle = TYPOGRAPHY_STYLES.find(s => s.id === selectedStyleId) || TYPOGRAPHY_STYLES[0];
const doc = iframeRef.current.contentDocument;
if (doc) {
const html = `
<!DOCTYPE html>
<html>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="${currentStyle.googleFontsImport}" rel="stylesheet">
<style>
body {
background-color: #52525b;
display: flex;
justify-content: center;
padding: 40px;
margin: 0;
font-family: sans-serif;
}
.page {
background: white;
width: ${paperSize === 'A4' ? '210mm' : '8.5in'};
min-height: ${paperSize === 'A4' ? '297mm' : '11in'};
padding: 25mm;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.4);
box-sizing: border-box;
/* Inject default body color to ensure visibility in dark themes */
color: #${currentStyle.wordConfig.body.color};
/* User Selected Typography */
${currentStyle.previewCss}
}
.page * { box-sizing: border-box; }
.page img { max-width: 100%; }
.page table { width: 100%; border-collapse: collapse; margin-bottom: 1em; }
.page th, .page td { border: 1px solid #ddd; padding: 8px; text-align: left; }
</style>
</head>
<body>
<div class="page">
${htmlContent}
</div>
</body>
</html>
`;
doc.open();
doc.write(html);
doc.close();
}
}, [htmlContent, paperSize, selectedStyleId]);
return (
<div className="h-screen flex flex-col bg-zinc-900">
{/* Toolbar */}
<div className="sticky top-0 z-50 bg-zinc-950/90 backdrop-blur-md border-b border-zinc-800 p-4">
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row justify-between items-center gap-4">
<button
onClick={onBack}
className="flex items-center gap-2 text-zinc-400 hover:text-white transition-colors"
>
<ArrowLeft size={20} />
<span>Back to Editor</span>
</button>
<div className="flex flex-col sm:flex-row items-center gap-6">
{/* Font List */}
<div className="flex items-center gap-2 text-sm text-zinc-500">
<span className="hidden md:inline">Fonts:</span>
<div className="flex gap-2">
{usedFonts.map(font => (
<a
key={font}
href={`https://fonts.google.com/specimen/${font.replace(/\s+/g, '+')}`}
target="_blank"
rel="noopener noreferrer"
className="px-2 py-1 bg-zinc-900 border border-zinc-800 hover:border-zinc-600 rounded text-zinc-300 hover:text-white transition-all flex items-center gap-1.5 text-xs font-medium"
title={`Download ${font} from Google Fonts`}
>
{font}
<ExternalLink size={10} />
</a>
))}
</div>
</div>
<div className="h-4 w-px bg-zinc-800 hidden sm:block" />
<div className="flex items-center gap-4">
<span className="text-zinc-500 text-sm hidden sm:inline">
Format: {paperSize}
</span>
<button
onClick={() => {
// Ensure we pass a string for styleId
const sid = selectedStyleId || 'swiss-grid';
generateDocx(sid);
}}
disabled={isExporting}
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg font-semibold transition-colors shadow-lg
${successMsg ? 'bg-emerald-600 text-white' : 'bg-blue-600 text-white hover:bg-blue-500'}
${isExporting ? 'opacity-50 cursor-wait' : ''}`}
>
{successMsg ? (
<><CheckCircle2 size={18} /><span>Downloaded!</span></>
) : (
<><FileText size={18} /><span>{isExporting ? 'Generating...' : 'Download Word Doc'}</span></>
)}
</button>
</div>
</div>
</div>
</div>
<div className="flex-grow relative bg-zinc-800 overflow-hidden">
<iframe
ref={iframeRef}
className="w-full h-full border-0 block"
title="Report Preview"
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,128 @@
import React, { useEffect, useRef } from 'react';
import { X } from 'lucide-react';
import { StyleOption } from '../types';
interface StylePreviewModalProps {
style: StyleOption;
onClose: () => void;
}
const SAMPLE_CONTENT = `
<h1>The Art of Typography</h1>
<p>Typography is the art and technique of arranging type to make written language legible, readable, and appealing when displayed. The arrangement of type involves selecting typefaces, point sizes, line lengths, line-spacing, and letter-spacing.</p>
<h2>1. Visual Hierarchy</h2>
<p>Visual hierarchy enables the reader to understand the importance of different sections. By using size, weight, and color, we guide the eye through the document in a logical flow.</p>
<blockquote>
"Design is not just what it looks like and feels like. Design is how it works."
</blockquote>
<h2>2. Key Elements</h2>
<ul>
<li><strong>Typeface:</strong> The design of the letters.</li>
<li><strong>Contrast:</strong> Distinguishing elements effectively.</li>
<li><strong>Consistency:</strong> Maintaining a coherent structure.</li>
</ul>
<p>Good typography is invisible. It should not distract the reader but rather enhance the reading experience, ensuring the message is delivered clearly and effectively.</p>
`;
export const StylePreviewModal: React.FC<StylePreviewModalProps> = ({ style, onClose }) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [onClose]);
useEffect(() => {
if (!iframeRef.current) return;
const doc = iframeRef.current.contentDocument;
if (doc) {
const html = `
<!DOCTYPE html>
<html>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="${style.googleFontsImport}" rel="stylesheet">
<style>
body {
background-color: #fff;
margin: 0;
padding: 40px;
/* Inject default body color from config */
color: #${style.wordConfig.body.color};
/* Apply Sample CSS */
${style.previewCss}
}
/* Override body margins from preview css to fit modal better */
body { width: 100%; height: 100%; box-sizing: border-box; overflow-y: auto; }
h1 { margin-top: 0 !important; }
</style>
</head>
<body>
${SAMPLE_CONTENT}
</body>
</html>
`;
doc.open();
doc.write(html);
doc.close();
}
}, [style]);
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm animate-in fade-in duration-200"
onClick={(e) => {
// Close if clicking the background overlay directly
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<div className="relative w-full max-w-3xl bg-zinc-900 rounded-2xl border border-zinc-700 shadow-2xl overflow-hidden flex flex-col h-[85vh]">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-zinc-800 bg-zinc-900/50">
<div>
<h3 className="text-xl font-bold text-white">{style.name}</h3>
<p className="text-sm text-zinc-400">{style.description}</p>
</div>
<button
onClick={onClose}
className="p-2 text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-full transition-colors"
>
<X size={24} />
</button>
</div>
{/* Content */}
<div className="flex-grow bg-zinc-800 p-1 overflow-hidden relative">
<div className="w-full h-full bg-white rounded-lg overflow-hidden shadow-inner">
<iframe
ref={iframeRef}
title="Style Preview"
className="w-full h-full border-0"
/>
</div>
</div>
{/* Footer */}
<div className="p-4 border-t border-zinc-800 bg-zinc-900 flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg transition-colors"
>
Close Preview
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,263 @@
import React, { useState, useMemo, useRef, useEffect } from 'react';
import { TYPOGRAPHY_STYLES } from '../constants';
import { StyleOption, PaperSize, StyleCategory } from '../types';
import { Check, Type, Printer, Search } from 'lucide-react';
interface StyleSelectorProps {
selectedStyle: string | null;
onSelectStyle: (id: string) => void;
selectedPaperSize: PaperSize;
onSelectPaperSize: (size: PaperSize) => void;
onGenerate: () => void;
}
const SAMPLE_CONTENT = `
<h1>The Art of Typography</h1>
<p>Typography is the art and technique of arranging type to make written language legible, readable, and appealing when displayed. The arrangement of type involves selecting typefaces, point sizes, line lengths, line-spacing, and letter-spacing.</p>
<h2>1. Visual Hierarchy</h2>
<p>Visual hierarchy enables the reader to understand the importance of different sections. By using size, weight, and color, we guide the eye through the document in a logical flow.</p>
<blockquote>
"Design is not just what it looks like and feels like. Design is how it works."
</blockquote>
<h2>2. Key Elements</h2>
<ul>
<li><strong>Typeface:</strong> The design of the letters.</li>
<li><strong>Contrast:</strong> Distinguishing elements effectively.</li>
<li><strong>Consistency:</strong> Maintaining a coherent structure.</li>
</ul>
<p>Good typography is invisible. It should not distract the reader but rather enhance the reading experience, ensuring the message is delivered clearly and effectively.</p>
`;
export const StyleSelector: React.FC<StyleSelectorProps> = ({
selectedStyle,
onSelectStyle,
selectedPaperSize,
onSelectPaperSize,
onGenerate
}) => {
const [activeCategory, setActiveCategory] = useState<StyleCategory | 'All'>('All');
const iframeRef = useRef<HTMLIFrameElement>(null);
// Dynamically extract categories from styles and sort them
const categories = useMemo(() => {
const cats = new Set(TYPOGRAPHY_STYLES.map(s => s.category));
return Array.from(cats).sort();
}, []);
const filteredStyles = useMemo(() => {
let styles = TYPOGRAPHY_STYLES;
if (activeCategory !== 'All') {
styles = TYPOGRAPHY_STYLES.filter(style => style.category === activeCategory);
}
// Always sort alphabetically by name
return [...styles].sort((a, b) => a.name.localeCompare(b.name));
}, [activeCategory]);
const currentStyleObj = useMemo(() =>
TYPOGRAPHY_STYLES.find(s => s.id === selectedStyle),
[selectedStyle]);
// Update preview when style changes
useEffect(() => {
if (!iframeRef.current || !currentStyleObj) return;
const doc = iframeRef.current.contentDocument;
if (doc) {
const html = `
<!DOCTYPE html>
<html>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="${currentStyleObj.googleFontsImport}" rel="stylesheet">
<style>
body {
background-color: #ffffff;
margin: 0;
padding: 40px;
/* Inject default body color from config to ensure lists/quotes inherit correctly */
color: #${currentStyleObj.wordConfig.body.color};
font-family: sans-serif;
/* Apply Selected Style CSS */
${currentStyleObj.previewCss}
}
/* Scrollbar styling for the preview iframe */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: #f1f1f1; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
</style>
</head>
<body>
${SAMPLE_CONTENT}
</body>
</html>
`;
doc.open();
doc.write(html);
doc.close();
}
}, [currentStyleObj]);
return (
<div className="w-full h-[calc(100vh-140px)] min-h-[600px] flex flex-col gap-6 max-w-[1600px] mx-auto">
{/* Top Controls */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 px-1">
<div className="flex items-center gap-3">
<div className="p-2 bg-indigo-500/10 rounded-lg text-indigo-400">
<Type size={24} />
</div>
<div>
<h2 className="text-2xl font-bold text-zinc-100">Select Typography Style</h2>
<p className="text-zinc-400 text-sm">Browse {TYPOGRAPHY_STYLES.length} professional templates</p>
</div>
</div>
<div className="flex items-center gap-4 bg-zinc-900/50 p-1.5 rounded-xl border border-zinc-800/50">
<div className="flex items-center gap-2 text-zinc-400 px-3">
<Printer size={16} />
<span className="text-xs font-semibold uppercase tracking-wider">Format</span>
</div>
<div className="flex gap-1">
{(['Letter', 'A4'] as PaperSize[]).map((size) => (
<button
key={size}
onClick={() => onSelectPaperSize(size)}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
selectedPaperSize === size
? 'bg-zinc-700 text-white shadow-sm'
: 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800'
}`}
>
{size}
</button>
))}
</div>
</div>
</div>
{/* Main Split Layout */}
<div className="flex-1 grid grid-cols-1 lg:grid-cols-12 gap-6 min-h-0">
{/* LEFT COLUMN: Style List */}
<div className="lg:col-span-4 flex flex-col gap-4 min-h-0 bg-zinc-900/30 rounded-2xl border border-zinc-800/50 overflow-hidden">
{/* Category Filter Tabs */}
<div className="p-4 border-b border-zinc-800/50 overflow-x-auto no-scrollbar">
<div className="flex gap-2">
<button
onClick={() => setActiveCategory('All')}
className={`px-3 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap transition-all ${
activeCategory === 'All'
? 'bg-white text-black'
: 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200'
}`}
>
All
</button>
{categories.map(cat => (
<button
key={cat}
onClick={() => setActiveCategory(cat)}
className={`px-3 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap transition-all ${
activeCategory === cat
? 'bg-indigo-500 text-white shadow-lg shadow-indigo-500/20'
: 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200'
}`}
>
{cat}
</button>
))}
</div>
</div>
{/* Scrollable List */}
<div className="flex-1 overflow-y-auto p-4 space-y-3 custom-scrollbar">
{filteredStyles.map((style) => (
<div
key={style.id}
onClick={() => onSelectStyle(style.id)}
className={`p-4 rounded-xl border transition-all cursor-pointer group relative
${selectedStyle === style.id
? 'border-indigo-500 bg-indigo-500/10 shadow-[0_0_15px_rgba(99,102,241,0.15)]'
: 'border-zinc-800 bg-zinc-900/40 hover:border-zinc-700 hover:bg-zinc-800'}`}
>
<div className="flex justify-between items-start mb-1">
<h3 className={`font-bold text-sm ${selectedStyle === style.id ? 'text-white' : 'text-zinc-300'}`}>
{style.name}
</h3>
{selectedStyle === style.id && (
<div className="bg-indigo-500 rounded-full p-0.5">
<Check size={12} className="text-white" />
</div>
)}
</div>
<p className="text-xs text-zinc-500 line-clamp-2 leading-relaxed mb-2">{style.description}</p>
<div className="flex items-center gap-2">
<span className={`text-[10px] uppercase font-mono tracking-wider px-1.5 py-0.5 rounded
${selectedStyle === style.id ? 'bg-indigo-500/20 text-indigo-300' : 'bg-zinc-800 text-zinc-500'}`}>
{style.category}
</span>
</div>
</div>
))}
</div>
</div>
{/* RIGHT COLUMN: Preview Window */}
<div className="lg:col-span-8 flex flex-col min-h-0 bg-zinc-950 rounded-2xl border border-zinc-800 shadow-2xl relative overflow-hidden">
{/* Preview Header */}
<div className="h-12 border-b border-zinc-800 bg-zinc-900/50 flex items-center px-4 justify-between">
<div className="flex items-center gap-2">
<div className="flex gap-1.5">
<div className="w-3 h-3 rounded-full bg-red-500/20 border border-red-500/50"></div>
<div className="w-3 h-3 rounded-full bg-amber-500/20 border border-amber-500/50"></div>
<div className="w-3 h-3 rounded-full bg-emerald-500/20 border border-emerald-500/50"></div>
</div>
<div className="h-4 w-px bg-zinc-800 mx-2"></div>
<span className="text-xs text-zinc-500 font-medium">Live Preview</span>
</div>
{currentStyleObj && (
<span className="text-xs text-indigo-400 font-mono">{currentStyleObj.name}</span>
)}
</div>
{/* Iframe Container */}
<div className="flex-1 relative bg-zinc-900/50">
{selectedStyle ? (
<iframe
ref={iframeRef}
className="w-full h-full border-0 block"
title="Style Preview"
/>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center text-zinc-600">
<Search size={48} className="mb-4 opacity-20" />
<p className="text-lg font-medium">Select a style to preview</p>
<p className="text-sm">Choose from the list on the left</p>
</div>
)}
</div>
{/* Bottom Action Bar */}
<div className="p-4 border-t border-zinc-800 bg-zinc-900/50 flex justify-end items-center gap-4">
<button
onClick={onGenerate}
disabled={!selectedStyle}
className="flex items-center gap-2 px-6 py-3 bg-indigo-600 hover:bg-indigo-500 text-white font-bold rounded-xl transition-all shadow-lg hover:shadow-indigo-500/25 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span>Apply Style & Convert</span>
<Printer size={18} />
</button>
</div>
</div>
</div>
</div>
);
};