feat: port all template categories to JSON format

- Ported Minimalist templates to JSON (Swiss Grid, Brutalist, etc.)
- Ported Tech templates to JSON (SaaS, Terminal, Cyberpunk, etc.)
- Ported Creative templates to JSON (Art Gallery, Zine, Pop Art, etc.)
- Ported Industrial templates to JSON (Blueprint, Factory, Schematic, etc.)
- Ported Nature templates to JSON (Botanical, Ocean, Mountain, etc.)
- Ported Lifestyle templates to JSON (Cookbook, Travel, Coffee House, etc.)
- Ported Vintage templates to JSON (Art Deco, Medieval, Retro 80s, etc.)
- Updated README.md to reflect the new JSON-based style system (example configuration and contribution workflow)
- Completed migration of over 150 styles to the new architecture
This commit is contained in:
TypoGenie
2026-02-01 18:51:43 +02:00
parent da335734d3
commit a6f664088c
405 changed files with 69134 additions and 5936 deletions

View File

@@ -0,0 +1,272 @@
import React, { useRef, useEffect, useState, useCallback } from 'react';
interface CustomScrollbarProps {
children: React.ReactNode;
className?: string;
horizontal?: boolean;
}
export const CustomScrollbar: React.FC<CustomScrollbarProps> = ({
children,
className = '',
horizontal = false
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const thumbRef = useRef<HTMLDivElement>(null);
const [isHoveringContainer, setIsHoveringContainer] = useState(false);
const [isHoveringTrack, setIsHoveringTrack] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [thumbSize, setThumbSize] = useState(40);
// Drag state
const dragState = useRef({
isActive: false,
startMouse: 0,
startScroll: 0,
trackSize: 0,
maxScroll: 0
});
// Update thumb size
const updateMetrics = useCallback(() => {
const container = containerRef.current;
if (!container) return;
if (horizontal) {
const ratio = container.clientWidth / Math.max(container.scrollWidth, 1);
setThumbSize(Math.max(40, ratio * container.clientWidth));
} else {
const ratio = container.clientHeight / Math.max(container.scrollHeight, 1);
setThumbSize(Math.max(40, ratio * container.clientHeight));
}
updateThumbVisual();
}, [horizontal]);
// Update thumb position from scroll
const updateThumbVisual = useCallback(() => {
const container = containerRef.current;
const thumb = thumbRef.current;
if (!container || !thumb) return;
if (dragState.current.isActive) return;
if (horizontal) {
const maxScroll = container.scrollWidth - container.clientWidth;
const trackSize = container.clientWidth - thumbSize;
if (maxScroll <= 0 || trackSize <= 0) {
thumb.style.transform = 'translateX(0px)';
return;
}
const ratio = container.scrollLeft / maxScroll;
thumb.style.transform = `translateX(${ratio * trackSize}px)`;
} else {
const maxScroll = container.scrollHeight - container.clientHeight;
const trackSize = container.clientHeight - thumbSize;
if (maxScroll <= 0 || trackSize <= 0) {
thumb.style.transform = 'translateY(0px)';
return;
}
const ratio = container.scrollTop / maxScroll;
thumb.style.transform = `translateY(${ratio * trackSize}px)`;
}
}, [horizontal, thumbSize]);
// Setup
useEffect(() => {
updateMetrics();
const handleResize = () => updateMetrics();
window.addEventListener('resize', handleResize);
const observer = new MutationObserver(() => updateMetrics());
if (containerRef.current) {
observer.observe(containerRef.current, { childList: true, subtree: true });
}
return () => {
window.removeEventListener('resize', handleResize);
observer.disconnect();
};
}, [updateMetrics]);
// Scroll listener
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const onScroll = () => {
if (!dragState.current.isActive) {
updateThumbVisual();
}
};
container.addEventListener('scroll', onScroll, { passive: true });
return () => container.removeEventListener('scroll', onScroll);
}, [updateThumbVisual]);
// Drag handling - DIRECT synchronous updates
useEffect(() => {
if (!isDragging) {
// Restore transition when not dragging
if (thumbRef.current) {
thumbRef.current.style.transition = 'opacity 0.15s, width 0.15s, height 0.15s';
}
return;
}
const container = containerRef.current;
const thumb = thumbRef.current;
if (!container || !thumb) return;
const state = dragState.current;
state.isActive = true;
// REMOVE transition during drag for instant response
thumb.style.transition = 'none';
const handleMouseMove = (e: MouseEvent) => {
const mousePos = horizontal ? e.clientX : e.clientY;
const deltaMouse = mousePos - state.startMouse;
const scrollRatio = deltaMouse / state.trackSize;
const newScroll = state.startScroll + (scrollRatio * state.maxScroll);
const clampedScroll = Math.max(0, Math.min(newScroll, state.maxScroll));
if (horizontal) {
container.scrollLeft = clampedScroll;
const visualRatio = state.maxScroll > 0 ? clampedScroll / state.maxScroll : 0;
thumb.style.transform = `translateX(${visualRatio * state.trackSize}px)`;
} else {
container.scrollTop = clampedScroll;
const visualRatio = state.maxScroll > 0 ? clampedScroll / state.maxScroll : 0;
thumb.style.transform = `translateY(${visualRatio * state.trackSize}px)`;
}
};
const handleMouseUp = () => {
state.isActive = false;
setIsDragging(false);
// Transition will be restored by effect cleanup
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
state.isActive = false;
};
}, [isDragging, horizontal]);
const handleThumbMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const container = containerRef.current;
const thumb = thumbRef.current;
if (!container || !thumb) return;
const state = dragState.current;
if (horizontal) {
state.startMouse = e.clientX;
state.startScroll = container.scrollLeft;
state.trackSize = container.clientWidth - thumbSize;
state.maxScroll = container.scrollWidth - container.clientWidth;
} else {
state.startMouse = e.clientY;
state.startScroll = container.scrollTop;
state.trackSize = container.clientHeight - thumbSize;
state.maxScroll = container.scrollHeight - container.clientHeight;
}
// Remove transition immediately on mouse down
thumb.style.transition = 'none';
setIsDragging(true);
};
const handleTrackClick = (e: React.MouseEvent) => {
if (e.target === thumbRef.current) return;
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
if (horizontal) {
const clickX = e.clientX - rect.left;
const percentage = clickX / rect.width;
container.scrollTo({
left: percentage * (container.scrollWidth - container.clientWidth),
behavior: 'smooth'
});
} else {
const clickY = e.clientY - rect.top;
const percentage = clickY / rect.height;
container.scrollTo({
top: percentage * (container.scrollHeight - container.clientHeight),
behavior: 'smooth'
});
}
};
const isActive = isHoveringTrack || isDragging;
const opacity = isActive ? 0.6 : (isHoveringContainer ? 0.2 : 0);
const size = isActive ? '6px' : '2px';
return (
<div
className={`relative ${className}`}
onMouseEnter={() => setIsHoveringContainer(true)}
onMouseLeave={() => {
setIsHoveringContainer(false);
if (!isDragging) setIsHoveringTrack(false);
}}
>
<div
ref={containerRef}
className="h-full w-full overflow-auto scrollbar-hide"
style={{
scrollbarWidth: 'none',
msOverflowStyle: 'none',
overflowX: horizontal ? 'auto' : 'hidden',
overflowY: horizontal ? 'hidden' : 'auto'
}}
>
{children}
</div>
<div
className={`absolute cursor-pointer z-10 ${horizontal ? 'bottom-0 left-0 right-0 h-4' : 'top-0 right-0 bottom-0 w-4'}`}
style={{ opacity }}
onMouseEnter={() => setIsHoveringTrack(true)}
onMouseLeave={() => { if (!isDragging) setIsHoveringTrack(false); }}
onClick={handleTrackClick}
>
<div
ref={thumbRef}
className="absolute rounded-full bg-zinc-400 cursor-grab active:cursor-grabbing"
style={{
[horizontal ? 'left' : 'top']: 0,
[horizontal ? 'bottom' : 'right']: '4px',
[horizontal ? 'width' : 'height']: thumbSize,
[horizontal ? 'height' : 'width']: size,
opacity: isActive ? 0.9 : 0.5,
transition: 'opacity 0.15s, width 0.15s, height 0.15s',
transform: horizontal ? 'translateX(0px)' : 'translateY(0px)',
willChange: 'transform'
}}
onMouseDown={handleThumbMouseDown}
/>
</div>
<style>{`
.scrollbar-hide::-webkit-scrollbar {
display: none !important;
}
`}</style>
</div>
);
};

View File

@@ -0,0 +1,145 @@
import { X } from 'lucide-react';
import { useState } from 'react';
interface ExportOptionsModalProps {
isOpen: boolean;
onClose: () => void;
onExport: (useTableHeaders: boolean) => void;
}
export default function ExportOptionsModal({ isOpen, onClose, onExport }: ExportOptionsModalProps) {
const [selectedMode, setSelectedMode] = useState<'table' | 'semantic'>('semantic');
if (!isOpen) return null;
const handleExport = () => {
onExport(selectedMode === 'table');
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-zinc-950/80 backdrop-blur-sm">
<div className="relative w-full max-w-2xl bg-zinc-900 rounded-2xl shadow-2xl m-4 overflow-hidden border border-zinc-700">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800 bg-zinc-900/50">
<h2 className="text-xl font-semibold text-white">Export Options</h2>
<button
onClick={onClose}
className="p-1 text-zinc-400 hover:text-white transition-colors rounded-md hover:bg-zinc-800"
aria-label="Close"
>
<X size={20} />
</button>
</div>
{/* Content */}
<div className="px-6 py-4 space-y-4 bg-zinc-900">
<p className="text-sm text-zinc-400 mb-6">
Choose how headers should be rendered in your Word document:
</p>
{/* Option 1: High-Fidelity */}
<label
className={`block p-4 border-2 rounded-lg cursor-pointer transition-all ${selectedMode === 'table'
? 'border-indigo-500 bg-indigo-500/10'
: 'border-zinc-700 hover:border-zinc-600 bg-zinc-800/50'
}`}
>
<div className="flex items-start">
<input
type="radio"
name="exportMode"
value="table"
checked={selectedMode === 'table'}
onChange={() => setSelectedMode('table')}
className="mt-1 mr-3 text-indigo-600 focus:ring-indigo-500 bg-zinc-700 border-zinc-600"
/>
<div className="flex-1">
<div className="font-semibold text-white mb-2">High-Fidelity Layout</div>
<div className="space-y-1 text-sm">
<div className="flex items-start">
<span className="text-green-500 mr-2"></span>
<span className="text-zinc-300">Perfect padding and border alignment</span>
</div>
<div className="flex items-start">
<span className="text-green-500 mr-2"></span>
<span className="text-zinc-300">Backgrounds contained precisely</span>
</div>
<div className="flex items-start">
<span className="text-red-500 mr-2"></span>
<span className="text-zinc-300">No automatic Table of Contents</span>
</div>
<div className="flex items-start">
<span className="text-red-500 mr-2"></span>
<span className="text-zinc-300">Document outline/navigation disabled</span>
</div>
</div>
<div className="mt-3 text-xs text-zinc-500 italic">
Best for: Portfolios, brochures, print-ready designs
</div>
</div>
</div>
</label>
{/* Option 2: Semantic */}
<label
className={`block p-4 border-2 rounded-lg cursor-pointer transition-all ${selectedMode === 'semantic'
? 'border-indigo-500 bg-indigo-500/10'
: 'border-zinc-700 hover:border-zinc-600 bg-zinc-800/50'
}`}
>
<div className="flex items-start">
<input
type="radio"
name="exportMode"
value="semantic"
checked={selectedMode === 'semantic'}
onChange={() => setSelectedMode('semantic')}
className="mt-1 mr-3 text-indigo-600 focus:ring-indigo-500 bg-zinc-700 border-zinc-600"
/>
<div className="flex-1">
<div className="font-semibold text-white mb-2">Semantic Structure</div>
<div className="space-y-1 text-sm">
<div className="flex items-start">
<span className="text-green-500 mr-2"></span>
<span className="text-zinc-300">Auto-generated Table of Contents</span>
</div>
<div className="flex items-start">
<span className="text-green-500 mr-2"></span>
<span className="text-zinc-300">Document navigation panel works</span>
</div>
<div className="flex items-start">
<span className="text-green-500 mr-2"></span>
<span className="text-zinc-300">Screen reader accessible</span>
</div>
<div className="flex items-start">
<span className="text-yellow-500 mr-2"></span>
<span className="text-zinc-300">Minor padding/border alignment issues</span>
</div>
</div>
<div className="mt-3 text-xs text-zinc-500 italic">
Best for: Academic papers, reports, accessible documents
</div>
</div>
</div>
</label>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-zinc-800 bg-zinc-900">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-zinc-300 bg-zinc-800 border border-zinc-700 rounded-md hover:bg-zinc-700 transition-colors"
>
Cancel
</button>
<button
onClick={handleExport}
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-500 transition-colors"
>
Export to Word
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,13 +1,19 @@
import React, { useCallback, useState } from 'react';
import React, { useCallback, useState, useRef, useEffect } from 'react';
import { motion } from 'motion/react';
import { Upload, FileText, AlertCircle } from 'lucide-react';
import { useKeyboardNavigation } from '../hooks/useKeyboardNavigation';
interface FileUploadProps {
onFileLoaded: (content: string) => void;
onFileLoaded: (content: string, fileName: string) => void;
}
export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
const [dragActive, setDragActive] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const dropzoneRef = useRef<HTMLDivElement>(null);
const dragCounter = useRef(0);
const handleFile = (file: File) => {
setError(null);
@@ -20,7 +26,9 @@ export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
reader.onload = (e) => {
const text = e.target?.result;
if (typeof text === 'string') {
onFileLoaded(text);
// Extract filename without extension
const fileName = file.name.replace(/\.[^/.]+$/, '');
onFileLoaded(text, fileName);
}
};
reader.onerror = () => setError('Error reading file.');
@@ -30,16 +38,24 @@ export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
const handleDrag = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === 'dragenter' || e.type === 'dragover') {
if (e.type === 'dragenter') {
dragCounter.current += 1;
setDragActive(true);
} else if (e.type === 'dragleave') {
setDragActive(false);
dragCounter.current -= 1;
if (dragCounter.current === 0) {
setDragActive(false);
}
} else if (e.type === 'dragover') {
// Keep drag active during dragover
setDragActive(true);
}
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current = 0;
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleFile(e.dataTransfer.files[0]);
@@ -53,40 +69,158 @@ export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
}
};
const openFilePicker = useCallback(() => {
inputRef.current?.click();
}, []);
// Keyboard navigation for the dropzone
useKeyboardNavigation({
onEnter: () => {
if (isFocused) {
openFilePicker();
}
},
onSpace: () => {
if (isFocused) {
openFilePicker();
}
},
}, [isFocused]);
// Clear error after 5 seconds
useEffect(() => {
if (error) {
const timer = setTimeout(() => setError(null), 5000);
return () => clearTimeout(timer);
}
}, [error]);
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'}`}
<motion.div
className="w-full max-w-xl mx-auto"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5, duration: 0.5 }}
role="region"
aria-label="File upload"
>
<motion.div
ref={dropzoneRef}
role="button"
tabIndex={0}
aria-label="Drop zone. Click or press Enter to select a file."
className={`relative group flex flex-col items-center justify-center w-full h-64 border-2 border-dashed rounded-2xl cursor-pointer overflow-hidden outline-none transition-all
${dragActive ? 'border-indigo-500 bg-indigo-500/10' : 'border-zinc-700 bg-zinc-900/50'}
${isFocused ? 'ring-2 ring-indigo-500 ring-offset-2 ring-offset-zinc-950 border-indigo-400' : ''}
`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
onClick={openFilePicker}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openFilePicker();
}
}}
whileHover={{
scale: 1.02,
borderColor: '#6366f1',
backgroundColor: 'rgba(99, 102, 241, 0.05)'
}}
whileTap={{ scale: 0.98 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
>
<input
ref={inputRef}
type="file"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
className="hidden"
onChange={handleChange}
accept=".md,.txt,.markdown"
aria-label="Select file"
/>
<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'}`}>
<motion.div
className="flex flex-col items-center justify-center pt-5 pb-6 text-zinc-400 group-hover:text-zinc-200 transition-colors"
animate={dragActive ? { y: [0, -5, 0] } : {}}
transition={{ duration: 0.3 }}
>
<motion.div
className={`p-4 rounded-full mb-4 ${dragActive ? 'bg-indigo-500/20 text-indigo-400' : 'bg-zinc-800 text-zinc-500'}`}
animate={dragActive ? {
scale: [1, 1.2, 1],
rotate: [0, 10, -10, 0]
} : {}}
transition={{ duration: 0.5 }}
whileHover={{ scale: 1.1, rotate: 5 }}
>
<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>
</motion.div>
<motion.p
className="mb-2 text-lg font-medium"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.6 }}
>
<motion.span
className="font-semibold text-indigo-400"
whileHover={{ color: '#818cf8' }}
>
Click to upload
</motion.span>{' '}
or drag and drop
</motion.p>
<motion.p
className="text-sm text-zinc-500"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.7 }}
>
Markdown or Plain Text files
</motion.p>
<motion.p
className="text-xs text-zinc-600 mt-2"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8 }}
>
Press Enter to browse
</motion.p>
</motion.div>
{/* Animated border gradient on hover */}
<motion.div
className="absolute inset-0 rounded-2xl pointer-events-none"
style={{
background: 'linear-gradient(90deg, transparent, rgba(99, 102, 241, 0.1), transparent)',
}}
initial={{ x: '-100%' }}
whileHover={{ x: '100%' }}
transition={{ duration: 0.6, ease: 'easeInOut' }}
/>
</motion.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} />
<motion.div
initial={{ opacity: 0, y: -10, height: 0 }}
animate={{ opacity: 1, y: 0, height: 'auto' }}
exit={{ opacity: 0, y: -10, height: 0 }}
className="mt-4 p-4 bg-red-900/20 border border-red-800 rounded-lg flex items-center gap-3 text-red-200"
role="alert"
aria-live="polite"
>
<motion.div
animate={{ rotate: [0, 10, -10, 0] }}
transition={{ duration: 0.5 }}
>
<AlertCircle size={20} />
</motion.div>
<span>{error}</span>
</div>
</motion.div>
)}
</div>
</motion.div>
);
};

View File

@@ -1,374 +1,434 @@
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';
import { motion, AnimatePresence } from 'motion/react';
import { ArrowLeft, Download, FileText, CheckCircle2, ExternalLink, Loader2, ZoomIn, ZoomOut } from 'lucide-react';
import { PaperSize } from '../types';
import { StyleOption } from '../types';
import { getPreviewCss } from '../services/templateRenderer';
import { generateDocxDocument } from '../utils/docxConverter';
import ExportOptionsModal from './ExportOptionsModal';
import { open } from '@tauri-apps/plugin-shell';
import { save } from '@tauri-apps/plugin-dialog';
import { writeFile } from '@tauri-apps/plugin-fs';
import { useKeyboardNavigation } from '../hooks/useKeyboardNavigation';
interface PreviewProps {
htmlContent: string;
onBack: () => void;
paperSize: PaperSize;
selectedStyleId?: string | null;
inputFileName?: string;
uiZoom: number;
onZoomChange: (zoom: number) => void;
templates: StyleOption[];
}
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);
// Zoom Control Component
const ZoomControl: React.FC<{ zoom: number; onZoomChange: (zoom: number) => void }> = ({ zoom, onZoomChange }) => {
const decreaseZoom = () => onZoomChange(Math.max(50, zoom - 10));
const increaseZoom = () => onZoomChange(Math.min(200, zoom + 10));
// Get current style
const style = TYPOGRAPHY_STYLES.find(s => s.id === selectedStyleId) || TYPOGRAPHY_STYLES[0];
return (
<div className="flex items-center gap-2 bg-zinc-900/80 rounded-lg border border-zinc-800 px-2 py-1">
<motion.button
onClick={decreaseZoom}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="p-1 text-zinc-400 hover:text-white transition-colors"
aria-label="Zoom out"
>
<ZoomOut size={16} />
</motion.button>
<span className="text-xs font-medium text-zinc-300 min-w-[3rem] text-center">{zoom}%</span>
<motion.button
onClick={increaseZoom}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="p-1 text-zinc-400 hover:text-white transition-colors"
aria-label="Zoom in"
>
<ZoomIn size={16} />
</motion.button>
</div>
);
};
// 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);
// Font download component
const FontList: React.FC<{ fonts: string[] }> = ({ fonts }) => {
const [downloadingFont, setDownloadingFont] = useState<string | null>(null);
const [downloadStatus, setDownloadStatus] = useState<Record<string, string>>({});
// 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 downloadFont = async (fontName: string) => {
setDownloadingFont(fontName);
setDownloadStatus(prev => ({ ...prev, [fontName]: 'Downloading...' }));
try {
const encodedName = encodeURIComponent(fontName);
const downloadUrl = `https://fonts.google.com/download?family=${encodedName}`;
await open(downloadUrl);
setDownloadStatus(prev => ({ ...prev, [fontName]: 'Opened in browser' }));
setTimeout(() => {
setDownloadStatus(prev => { const newStatus = { ...prev }; delete newStatus[fontName]; return newStatus; });
}, 3000);
} catch (err) {
setDownloadStatus(prev => ({ ...prev, [fontName]: 'Failed' }));
} finally {
setDownloadingFont(null);
}
};
const generateDocx = async (styleId: string) => {
const openGoogleFonts = async (fontName: string) => {
try {
const url = `https://fonts.google.com/specimen/${fontName.replace(/\s+/g, '+')}`;
await open(url);
} catch (err) {
window.open(`https://fonts.google.com/specimen/${fontName.replace(/\s+/g, '+')}`, '_blank');
}
};
return (
<div className="flex items-center gap-2 text-sm text-zinc-500">
<span className="hidden md:inline">Fonts:</span>
<div className="flex gap-2 flex-wrap">
{fonts.map((font, index) => (
<motion.div key={font} className="relative group" initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} transition={{ delay: index * 0.05 }}>
<div className="flex items-center gap-1">
<motion.button
onClick={() => downloadFont(font)}
disabled={downloadingFont === font}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="px-2 py-1 bg-zinc-900 border border-zinc-800 hover:border-zinc-600 rounded-l text-zinc-300 hover:text-white transition-all flex items-center gap-1.5 text-xs font-medium"
>
{downloadingFont === font ? <Loader2 size={10} className="animate-spin" /> : <Download size={10} />}
{font}
</motion.button>
<motion.button
onClick={() => openGoogleFonts(font)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="px-1.5 py-1 bg-zinc-900 border border-l-0 border-zinc-800 hover:border-zinc-600 rounded-r text-zinc-400 hover:text-white transition-all"
>
<ExternalLink size={10} />
</motion.button>
</div>
{downloadStatus[font] && (
<motion.div className="absolute top-full left-0 mt-1 px-2 py-1 bg-zinc-800 border border-zinc-700 rounded text-xs whitespace-nowrap z-50" initial={{ opacity: 0, y: -5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -5 }}>
{downloadStatus[font]}
</motion.div>
)}
</motion.div>
))}
</div>
</div>
);
};
export const Preview: React.FC<PreviewProps> = ({
htmlContent,
onBack,
paperSize,
selectedStyleId,
inputFileName = 'document',
uiZoom,
onZoomChange,
templates
}) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const backButtonRef = useRef<HTMLButtonElement>(null);
const saveButtonRef = useRef<HTMLButtonElement>(null);
const [isExporting, setIsExporting] = useState(false);
const [successMsg, setSuccessMsg] = useState(false);
const [showExportModal, setShowExportModal] = useState(false);
const [focusedElement, setFocusedElement] = useState<'back' | 'fonts' | 'save'>('save');
// Get current style from templates
const style = templates.find(s => s.id === selectedStyleId) || templates[0] || null;
// Extract used fonts for display
const usedFonts = style ? Array.from(new Set([
style.typography?.fonts?.heading || style.wordConfig?.heading1?.font || 'Arial',
style.typography?.fonts?.body || style.wordConfig?.body?.font || 'Arial'
])).filter(Boolean) : [];
useKeyboardNavigation({
onEscape: () => onBack(),
onArrowLeft: () => { if (focusedElement === 'save') { setFocusedElement('back'); backButtonRef.current?.focus(); } },
onArrowRight: () => { if (focusedElement === 'back') { setFocusedElement('save'); saveButtonRef.current?.focus(); } },
onEnter: () => { if (focusedElement === 'back') onBack(); else if (focusedElement === 'save' && !isExporting) handleSave(); },
onCtrlEnter: () => { if (!isExporting) handleSave(); },
}, [focusedElement, isExporting, onBack]);
useEffect(() => {
setTimeout(() => saveButtonRef.current?.focus(), 100);
}, []);
const handleSave = async () => {
setShowExportModal(true);
};
const handleExportConfirm = async (useTableHeaders: boolean) => {
setShowExportModal(false);
const sid = selectedStyleId || 'swiss-grid';
await generateDocx(sid, useTableHeaders);
};
const generateDocx = async (styleId: string, useTableHeaders: boolean = false) => {
setIsExporting(true);
try {
const style = TYPOGRAPHY_STYLES.find(s => s.id === styleId) || TYPOGRAPHY_STYLES[0];
const cfg = style.wordConfig;
const template = templates.find(s => s.id === styleId) || templates[0];
// PARSE HTML
const parser = new DOMParser();
const doc = parser.parseFromString(htmlContent, 'text/html');
const nodes = Array.from(doc.body.childNodes);
// Get base config from wordConfig
const cfg = template.wordConfig || {
heading1: { font: 'Arial', size: 24, color: '000000', align: 'left' },
heading2: { font: 'Arial', size: 18, color: '333333', align: 'left' },
body: { font: 'Arial', size: 11, color: '000000', align: 'left' },
accentColor: '000000'
};
const docxChildren = [];
// Extract page background from template
const pageBackground = template.typography?.colors?.background;
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;
};
// Get fonts from template
const codeFont = template.typography?.fonts?.code || template.typography?.fonts?.body || 'Consolas';
// Detect dark theme
const isDarkTheme = pageBackground ? parseInt(pageBackground, 16) < 0x444444 : false;
// --- 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;
// DEBUG: Specific markdown constructs for DOCX
console.log('=== PREVIEW CALLING DOCX (h3, strong, table, ul/ol, li) ===');
console.log('wordConfig h3:', JSON.stringify({ font: cfg.heading2?.font, size: cfg.body?.size, color: template.elements?.h3?.color }));
console.log('elements h3:', JSON.stringify(template.elements?.h3));
console.log('elements strong:', JSON.stringify(template.elements?.strong));
console.log('elements table:', JSON.stringify(template.elements?.table));
console.log('elements th:', JSON.stringify(template.elements?.th));
console.log('elements td:', JSON.stringify(template.elements?.td));
console.log('elements ul:', JSON.stringify(template.elements?.ul));
console.log('elements ol:', JSON.stringify(template.elements?.ol));
console.log('elements li:', JSON.stringify(template.elements?.li));
console.log('useTableHeaders:', useTableHeaders);
console.log('=== END PREVIEW CALL ===');
// 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 generateDocxDocument(htmlContent, {
paperSize,
heading1: cfg.heading1,
heading2: cfg.heading2,
body: cfg.body,
accentColor: cfg.accentColor,
pageBackground,
codeFont,
isDarkTheme,
elements: template.elements,
fonts: template.typography?.fonts,
palette: template.typography?.colors,
id: template.id,
page: template.page,
useTableHeaders
});
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);
const arrayBuffer = await blob.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
const defaultName = `${inputFileName}.docx`;
const savePath = await save({
defaultPath: defaultName,
filters: [{ name: 'Word Document', extensions: ['docx'] }]
});
if (savePath) {
await writeFile(savePath, uint8Array);
setSuccessMsg(true);
setTimeout(() => setSuccessMsg(false), 3000);
}
} catch (e) {
console.error("Docx Gen Error", e);
alert("Failed to generate DOCX");
alert("Failed to generate DOCX: " + e);
} finally {
setIsExporting(false);
}
};
// Track blob URL for cleanup
const blobUrlRef = useRef<string | null>(null);
// Render preview whenever dependencies change
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];
if (!iframeRef.current || !style) 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="${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};
// DEBUG: Specific markdown constructs for Preview
console.log('=== PREVIEW DEBUG (h3, strong, table, ul/ol, li) ===');
console.log('h3:', JSON.stringify(style.elements?.h3));
console.log('strong:', JSON.stringify(style.elements?.strong));
console.log('table:', JSON.stringify(style.elements?.table));
console.log('th:', JSON.stringify(style.elements?.th));
console.log('td:', JSON.stringify(style.elements?.td));
console.log('ul:', JSON.stringify(style.elements?.ul));
console.log('ol:', JSON.stringify(style.elements?.ol));
console.log('li:', JSON.stringify(style.elements?.li));
console.log('=== END PREVIEW DEBUG ===');
/* 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();
// Cleanup old blob URL
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
}, [htmlContent, paperSize, selectedStyleId]);
// Get CSS from template - this generates from elements structure
const templateCss = getPreviewCss(style);
console.log('Generated preview CSS (first 500 chars):', templateCss.substring(0, 500));
// Get page background from template
const pageBg = style.typography?.colors?.background || 'ffffff';
// Build the complete CSS - template CSS now includes .page styles
const allCss = [
/* Reset styles first */
'::-webkit-scrollbar { width: 6px !important; height: 6px !important; }',
'::-webkit-scrollbar-track { background: transparent !important; }',
'::-webkit-scrollbar-thumb { background: #71717a !important; border-radius: 3px !important; }',
'* { scrollbar-width: thin; scrollbar-color: #71717a transparent; box-sizing: border-box; }',
'html, body { margin: 0; padding: 0; min-height: 100%; }',
/* Dark outer background */
'body { background-color: #18181b; padding: 40px 20px; }',
/* Template CSS - includes .page styles with fonts, colors, etc. */
templateCss,
/* Page dimensions (not included in template CSS) */
`.page {`,
` 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;`,
` margin: 0 auto;`,
`}`,
/* Utilities */
'.page img { max-width: 100%; }'
].join('\n');
// Inject CSS directly as inline style tag
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 || 'https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap'}" rel="stylesheet">
<style>
${allCss}
</style>
</head>
<body>
<div class="page">
${htmlContent}
<script>
console.log('--- STAGE 2: PREVIEW COMPUTED STYLES ---');
setTimeout(() => {
['h1', 'h2', 'h3', 'p', 'table', 'th', 'td', 'ul', 'ol', 'li', 'blockquote', 'code', 'pre'].forEach(tag => {
const el = document.querySelector('.page ' + tag);
if (el) {
const style = window.getComputedStyle(el);
// Get borders specifically
const borderTop = style.borderTopWidth + ' ' + style.borderTopStyle + ' ' + style.borderTopColor;
const borderBottom = style.borderBottomWidth + ' ' + style.borderBottomStyle + ' ' + style.borderBottomColor;
const borderLeft = style.borderLeftWidth + ' ' + style.borderLeftStyle + ' ' + style.borderLeftColor;
const borderRight = style.borderRightWidth + ' ' + style.borderRightStyle + ' ' + style.borderRightColor;
console.log(\`PREVIEW \${tag.toUpperCase()}: \`, {
fontFamily: style.fontFamily,
fontSize: style.fontSize,
color: style.color,
backgroundColor: style.backgroundColor,
fontWeight: style.fontWeight,
border: \`T:\${borderTop} R:\${borderRight} B:\${borderBottom} L:\${borderLeft}\`,
padding: style.padding,
margin: style.margin
});
} else {
console.log(\`PREVIEW \${tag.toUpperCase()}: Not found\`);
}
});
}, 500);
</script>
</div>
</body>
</html>
`;
// Create blob URL for CSP compliance
const blob = new Blob([html], { type: 'text/html' });
const blobUrl = URL.createObjectURL(blob);
blobUrlRef.current = blobUrl;
iframeRef.current.src = blobUrl;
// Cleanup on unmount
return () => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, [htmlContent, paperSize, selectedStyleId, templates, style]);
if (!style) {
return <div className="h-screen flex items-center justify-center text-white">Loading...</div>;
}
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">
<motion.div className="h-screen flex flex-col bg-zinc-950" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.3 }}>
<motion.div className="sticky top-0 z-50 bg-zinc-950/90 backdrop-blur-md border-b border-zinc-800 p-4" initial={{ y: -20, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }}>
<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"
>
<motion.button ref={backButtonRef} onClick={onBack} onFocus={() => setFocusedElement('back')} whileHover={{ x: -3 }} whileTap={{ scale: 0.95 }} className="flex items-center gap-2 text-zinc-400 hover:text-white transition-colors outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-zinc-950 rounded-lg px-2 py-1">
<ArrowLeft size={20} />
<span>Back to Editor</span>
</button>
</motion.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>
<FontList fonts={usedFonts} />
<div className="h-4 w-px bg-zinc-800 hidden sm:block" />
<ZoomControl zoom={uiZoom} onZoomChange={onZoomChange} />
<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);
}}
<span className="text-zinc-500 text-sm hidden sm:inline">Format: {paperSize}</span>
<motion.button
ref={saveButtonRef}
onClick={handleSave}
onFocus={() => setFocusedElement('save')}
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' : ''}`}
whileHover={{ scale: 1.05, boxShadow: '0 0 20px rgba(59, 130, 246, 0.4)' }}
whileTap={{ scale: 0.95 }}
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>
<AnimatePresence mode="wait">
{successMsg ? (
<motion.div key="success" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }} className="flex items-center gap-2">
<CheckCircle2 size={18} />
<span>Saved!</span>
</motion.div>
) : (
<motion.div key="default" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }} className="flex items-center gap-2">
{isExporting ? <Loader2 size={18} className="animate-spin" /> : <FileText size={18} />}
<span>{isExporting ? 'Generating...' : 'Save Word Doc'}</span>
</motion.div>
)}
</AnimatePresence>
</motion.button>
</div>
</div>
</div>
</motion.div>
<div className="flex-grow relative bg-zinc-950 overflow-hidden preview-container">
<iframe ref={iframeRef} className="w-full h-full border-0 block" title="Report Preview" />
</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>
<ExportOptionsModal
isOpen={showExportModal}
onClose={() => setShowExportModal(false)}
onExport={handleExportConfirm}
/>
</motion.div>
);
};
};

View File

@@ -1,111 +1,281 @@
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';
import { PaperSize, StyleOption } from '../types';
import { Check, Type, Printer, Search, Heart } from 'lucide-react';
import { motion } from 'motion/react';
import { getPreviewCss } from '../services/templateRenderer';
interface StyleSelectorProps {
templates: StyleOption[];
categories: string[];
selectedStyle: string | null;
onSelectStyle: (id: string) => void;
selectedPaperSize: PaperSize;
onSelectPaperSize: (size: PaperSize) => void;
onGenerate: () => void;
isLoading?: boolean;
error?: string | null;
}
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>
<p>Typography is the art and technique of arranging type to make written language <strong>legible</strong>, <em>readable</em>, and <u>appealing</u> when displayed. The arrangement involves selecting typefaces, point sizes, line lengths, 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>
<h3>Key Principles</h3>
<p>Effective typography follows timeless principles. Each element serves a purpose, creating harmony between form and function.</p>
<h4>Micro-Typography</h4>
<p>The smallest details matter most. Kerning, tracking, and leading work together to create <code>readable</code> text that flows naturally.</p>
<blockquote>
"Design is not just what it looks like and feels like. Design is how it works."
"Design is not just what it looks like and feels like. Design is how it works." — Steve Jobs
</blockquote>
<h2>2. Key Elements</h2>
<h2>2. Essential Elements</h2>
<h3>Unordered Principles</h3>
<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>
<li><strong>Typeface:</strong> The personality of your document</li>
<li><strong>Contrast:</strong> Distinguishing elements effectively</li>
<li><strong>Consistency:</strong> Maintaining a coherent structure</li>
<li><strong>Whitespace:</strong> Breathing room for the eyes</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>
<h3>Process Steps</h3>
<ol>
<li>Analyze your content and audience</li>
<li>Choose appropriate typefaces</li>
<li>Establish hierarchy and rhythm</li>
<li>Test and refine the layout</li>
</ol>
<hr>
<h2>3. Technical Implementation</h2>
<p>When implementing typography in code, precision is essential. Here's a basic example:</p>
<pre>function applyTypography(element) {
element.style.fontFamily = 'Georgia, serif';
element.style.lineHeight = '1.6';
element.style.letterSpacing = '0.02em';
}</pre>
<h3>Font Properties Comparison</h3>
<table>
<tr>
<th>Property</th>
<th>Serif</th>
<th>Sans-Serif</th>
</tr>
<tr>
<td>Readability</td>
<td>High (print)</td>
<td>High (screen)</td>
</tr>
<tr>
<td>Personality</td>
<td>Classic</td>
<td>Modern</td>
</tr>
<tr>
<td>Use Case</td>
<td>Body text</td>
<td>Headings</td>
</tr>
</table>
<p>Good typography is invisible. It should not distract the reader but enhance the experience. Visit <a href="https://typography.com">typography.com</a> to learn more about professional typesetting.</p>
<h5>Advanced Techniques</h5>
<p>Mastering typography requires understanding both the rules and when to break them creatively.</p>
<h6>Final Thoughts</h6>
<p>Typography transforms ordinary text into extraordinary communication. Every choice matters.</p>
`;
export const StyleSelector: React.FC<StyleSelectorProps> = ({
templates,
categories,
selectedStyle,
onSelectStyle,
selectedPaperSize,
onSelectPaperSize,
onGenerate
onGenerate,
isLoading,
error
}) => {
const [activeCategory, setActiveCategory] = useState<StyleCategory | 'All'>('All');
const [activeCategory, setActiveCategory] = useState<string>('All');
const [searchQuery, setSearchQuery] = useState('');
const [favorites, setFavorites] = useState<string[]>(() => {
try {
const saved = localStorage.getItem('favoriteTemplates');
return saved ? JSON.parse(saved) : [];
} catch (e) {
console.error('Failed to parse favorites', e);
return [];
}
});
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();
}, []);
// Persist favorites
useEffect(() => {
localStorage.setItem('favoriteTemplates', JSON.stringify(favorites));
}, [favorites]);
const toggleFavorite = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
setFavorites(prev =>
prev.includes(id) ? prev.filter(fid => fid !== id) : [...prev, id]
);
};
const filteredStyles = useMemo(() => {
let styles = TYPOGRAPHY_STYLES;
if (activeCategory !== 'All') {
styles = TYPOGRAPHY_STYLES.filter(style => style.category === activeCategory);
let styles = templates;
// Filter by category
if (activeCategory === 'fav') {
styles = styles.filter(style => favorites.includes(style.id));
} else if (activeCategory !== 'All') {
styles = templates.filter(style => style.category === activeCategory);
}
// Filter by search
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
styles = styles.filter(style =>
style.name.toLowerCase().includes(query) ||
style.description.toLowerCase().includes(query) ||
style.category.toLowerCase().includes(query)
);
}
// Always sort alphabetically by name
return [...styles].sort((a, b) => a.name.localeCompare(b.name));
}, [activeCategory]);
}, [templates, activeCategory, searchQuery, favorites]);
const currentStyleObj = useMemo(() =>
TYPOGRAPHY_STYLES.find(s => s.id === selectedStyle),
[selectedStyle]);
const currentStyleObj = useMemo(() =>
templates.find(s => s.id === selectedStyle),
[templates, selectedStyle]);
// Track blob URL for cleanup
const blobUrlRef = useRef<string | null>(null);
// 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();
// Cleanup old blob URL
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
}, [currentStyleObj]);
// Get CSS from template
const templateCss = getPreviewCss(currentStyleObj);
// DEBUG: Specific markdown constructs for StyleSelector
console.log('=== STYLESELECTOR DEBUG (h3, strong, table, ul/ol, li) ===');
console.log('h3:', JSON.stringify(currentStyleObj.elements?.h3));
console.log('strong:', JSON.stringify(currentStyleObj.elements?.strong));
console.log('table:', JSON.stringify(currentStyleObj.elements?.table));
console.log('th:', JSON.stringify(currentStyleObj.elements?.th));
console.log('td:', JSON.stringify(currentStyleObj.elements?.td));
console.log('ul:', JSON.stringify(currentStyleObj.elements?.ul));
console.log('ol:', JSON.stringify(currentStyleObj.elements?.ol));
console.log('li:', JSON.stringify(currentStyleObj.elements?.li));
console.log('=== END STYLESELECTOR DEBUG ===');
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>
/* Reset */
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
/* App background (dark) */
body {
background-color: #18181b;
padding: 40px;
min-height: 100vh;
}
/* Template CSS - includes .page styles */
${templateCss}
/* Page dimensions (not included in template) */
.page {
width: ${selectedPaperSize === 'A4' ? '210mm' : '8.5in'};
min-height: ${selectedPaperSize === 'A4' ? '297mm' : '11in'};
margin: 0 auto;
padding: 25mm;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5);
}
/* Scrollbar */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #71717a; border-radius: 4px; }
</style>
</head>
<body>
<div class="page">
${SAMPLE_CONTENT}
</div>
</body>
</html>
`;
// Create blob URL for CSP compliance
const blob = new Blob([html], { type: 'text/html' });
const blobUrl = URL.createObjectURL(blob);
blobUrlRef.current = blobUrl;
iframeRef.current.src = blobUrl;
// Cleanup on unmount
return () => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, [currentStyleObj, selectedPaperSize]);
if (isLoading) {
return (
<div className="w-full h-full flex items-center justify-center">
<motion.div className="text-center" initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
<motion.div className="w-12 h-12 border-4 border-indigo-500/30 border-t-indigo-500 rounded-full mx-auto mb-4" animate={{ rotate: 360 }} transition={{ duration: 1, repeat: Infinity, ease: "linear" }} />
<p className="text-zinc-400">Loading templates...</p>
</motion.div>
</div>
);
}
if (error) {
return (
<div className="w-full h-full flex flex-col items-center justify-center p-8 text-center">
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="max-w-md">
<div className="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-2xl"></span>
</div>
<h3 className="text-xl font-semibold text-white mb-2">Template Error</h3>
<p className="text-zinc-400 mb-6">{error}</p>
</motion.div>
</div>
);
}
return (
<div className="w-full h-[calc(100vh-140px)] min-h-[600px] flex flex-col gap-6 max-w-[1600px] mx-auto">
<div className="w-full h-full 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">
@@ -113,26 +283,25 @@ export const StyleSelector: React.FC<StyleSelectorProps> = ({
<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>
<h2 className="text-2xl font-bold text-zinc-100">Select Typography Style</h2>
<p className="text-zinc-400 text-sm">Browse {templates.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">
<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'
}`}
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>
@@ -143,59 +312,99 @@ export const StyleSelector: React.FC<StyleSelectorProps> = ({
{/* 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 => (
<div className="flex flex-col border-b border-zinc-800/50 bg-zinc-900/20">
<div className="p-4 overflow-x-auto no-scrollbar pb-2">
<div className="flex gap-2">
<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'
}`}
onClick={() => setActiveCategory('fav')}
className={`px-3 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap transition-all flex items-center gap-1.5 ${activeCategory === 'fav'
? 'bg-rose-500 text-white shadow-lg shadow-rose-500/20'
: 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200'
}`}
>
{cat}
<Heart size={12} className={activeCategory === 'fav' ? 'fill-current' : ''} />
fav
</button>
))}
<div className="w-px h-6 bg-zinc-800 mx-1 self-center" />
<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>
{/* Search Input */}
<div className="px-4 pb-4">
<div className="relative group">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500 group-focus-within:text-indigo-400 transition-colors" />
<input
type="text"
placeholder="Search templates..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-zinc-900 border border-zinc-700 rounded-xl py-2 pl-9 pr-4 text-sm text-zinc-200 placeholder:text-zinc-600 focus:outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500/50 transition-all"
/>
</div>
</div>
</div>
{/* Scrollable List */}
<div className="flex-1 overflow-y-auto p-4 space-y-3 custom-scrollbar">
{filteredStyles.map((style) => (
{filteredStyles.length === 0 ? (
<div className="text-center py-8 text-zinc-500">
<p className="text-sm">No templates found</p>
</div>
) : 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)]'
${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 className="flex justify-between items-start mb-1 gap-2">
<h3 className={`font-bold text-sm ${selectedStyle === style.id ? 'text-white' : 'text-zinc-300'}`}>
{style.name}
</h3>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={(e) => toggleFavorite(e, style.id)}
className={`p-1.5 rounded-full transition-all ${favorites.includes(style.id)
? 'text-rose-400 bg-rose-500/10 hover:bg-rose-500/20'
: 'text-zinc-600 hover:text-zinc-400 hover:bg-zinc-700/50'
}`}
>
<Heart size={14} className={favorites.includes(style.id) ? 'fill-current' : ''} />
</button>
{selectedStyle === style.id && (
<div className="bg-indigo-500 rounded-full p-0.5">
<Check size={12} className="text-white" />
</div>
)}
</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">
@@ -211,7 +420,7 @@ export const StyleSelector: React.FC<StyleSelectorProps> = ({
{/* 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">
@@ -247,17 +456,17 @@ export const StyleSelector: React.FC<StyleSelectorProps> = ({
{/* 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>
<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>
);
};
};

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { motion } from 'motion/react';
import { ZoomIn, ZoomOut } from 'lucide-react';
interface ZoomControlProps {
zoom: number;
onZoomChange: (zoom: number) => void;
}
export const ZoomControl: React.FC<ZoomControlProps> = ({ zoom, onZoomChange }) => {
const decreaseZoom = () => onZoomChange(Math.max(50, zoom - 10));
const increaseZoom = () => onZoomChange(Math.min(200, zoom + 10));
return (
<div className="flex items-center gap-2 bg-zinc-900/80 rounded-lg border border-zinc-800 px-2 py-1">
<motion.button
onClick={decreaseZoom}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="p-1 text-zinc-400 hover:text-white transition-colors"
aria-label="Zoom out"
>
<ZoomOut size={16} />
</motion.button>
<span className="text-xs font-medium text-zinc-300 min-w-[3rem] text-center">
{zoom}%
</span>
<motion.button
onClick={increaseZoom}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="p-1 text-zinc-400 hover:text-white transition-colors"
aria-label="Zoom in"
>
<ZoomIn size={16} />
</motion.button>
</div>
);
};