diff --git a/cv-engine/src/App.tsx b/cv-engine/src/App.tsx
index 0986189..f485b54 100644
--- a/cv-engine/src/App.tsx
+++ b/cv-engine/src/App.tsx
@@ -4,6 +4,7 @@ import PersonalEditor from './editors/PersonalEditor';
import WorkEditor from './editors/WorkEditor';
import EducationEditor from './editors/EducationEditor';
import SkillsEditor from './editors/SkillsEditor';
+import SummaryEditor from './editors/SummaryEditor';
import PreviewPanel from './components/PreviewPanel';
import { useActiveStep } from './store/cvStore';
@@ -22,7 +23,7 @@ const App: React.FC = () => {
case 'skills':
return ;
case 'summary':
- return
Summary editor will be implemented in M3
;
+ return ;
case 'finalize':
return Finalize step will be implemented in M5
;
default:
diff --git a/cv-engine/src/components/PreviewPanel.tsx b/cv-engine/src/components/PreviewPanel.tsx
index 5d29758..c751e88 100644
--- a/cv-engine/src/components/PreviewPanel.tsx
+++ b/cv-engine/src/components/PreviewPanel.tsx
@@ -1,6 +1,7 @@
-import React from 'react';
+import React, { useMemo, useState } from 'react';
import { useCvStore } from '../store/cvStore';
import ATSTemplate from '../templates/ATSTemplate';
+import { buildPrintableHtml } from '../utils/printable';
interface PreviewPanelProps {
className?: string;
@@ -9,20 +10,53 @@ interface PreviewPanelProps {
const PreviewPanel: React.FC = ({ className = '' }) => {
const cv = useCvStore(state => state.cv);
const templateId = useCvStore(state => state.cv.templateId);
+ const [mode, setMode] = useState<'inline' | 'printable'>('inline');
+ const printableHtml = useMemo(() => buildPrintableHtml(cv), [cv]);
return (
Preview
-
- Template:
{templateId === 'ats' ? 'ATS-Friendly' : templateId}
+
+
+ Template: {templateId === 'ats' ? 'ATS-Friendly' : templateId}
+
+
+ setMode('inline')}
+ className={`px-2 py-1 rounded-md text-xs border ${mode === 'inline' ? 'bg-blue-600 text-white border-blue-600' : 'bg-gray-100 text-gray-800 border-gray-300'}`}
+ >
+ Inline
+
+ setMode('printable')}
+ className={`px-2 py-1 rounded-md text-xs border ${mode === 'printable' ? 'bg-blue-600 text-white border-blue-600' : 'bg-gray-100 text-gray-800 border-gray-300'}`}
+ >
+ Printable
+
+
- {/* Render the appropriate template based on templateId */}
- {templateId === 'ats' &&
}
- {/* Add more template options here as they are implemented */}
+ {mode === 'inline' && (
+ <>
+ {/* Render the appropriate template based on templateId */}
+ {templateId === 'ats' &&
}
+ {/* Add more template options here as they are implemented */}
+ >
+ )}
+
+ {mode === 'printable' && (
+
+ )}
);
diff --git a/cv-engine/src/editors/SummaryEditor.tsx b/cv-engine/src/editors/SummaryEditor.tsx
new file mode 100644
index 0000000..6150fc9
--- /dev/null
+++ b/cv-engine/src/editors/SummaryEditor.tsx
@@ -0,0 +1,112 @@
+import React, { useEffect, useMemo } from 'react';
+import { useSummaryData, useCvStore } from '../store/cvStore';
+import { EditorContent, useEditor } from '@tiptap/react';
+import StarterKit from '@tiptap/starter-kit';
+
+interface SummaryEditorProps {}
+
+const SummaryEditor: React.FC
= () => {
+ const summary = useSummaryData();
+ const { updateSummary } = useCvStore();
+
+ const editor = useEditor({
+ extensions: [StarterKit],
+ content: summary || '',
+ editorProps: {
+ attributes: {
+ class: 'prose prose-sm max-w-none focus:outline-none',
+ },
+ },
+ onUpdate: ({ editor }) => {
+ const html = editor.getHTML();
+ updateSummary(html);
+ },
+ });
+
+ // Keep editor content in sync if summary changes externally
+ useEffect(() => {
+ if (editor && summary !== editor.getHTML()) {
+ editor.commands.setContent(summary || '');
+ }
+ }, [summary, editor]);
+
+ const textCount = useMemo(() => {
+ return editor ? editor.getText().length : (summary || '').replace(/<[^>]*>/g, '').length;
+ }, [editor, summary]);
+
+ const isTooLong = textCount > 600;
+
+ const toggleMark = (mark: 'bold' | 'italic') => {
+ if (!editor) return;
+ if (mark === 'bold') editor.chain().focus().toggleBold().run();
+ if (mark === 'italic') editor.chain().focus().toggleItalic().run();
+ };
+
+ const toggleList = (type: 'bullet' | 'ordered') => {
+ if (!editor) return;
+ if (type === 'bullet') editor.chain().focus().toggleBulletList().run();
+ if (type === 'ordered') editor.chain().focus().toggleOrderedList().run();
+ };
+
+ const clearFormatting = () => {
+ if (!editor) return;
+ editor.chain().focus().clearNodes().unsetAllMarks().run();
+ };
+
+ return (
+
+
Professional Summary
+
Keep it concise (≤600 characters). Use formatting for readability.
+
+ {/* Toolbar */}
+
+
toggleMark('bold')}
+ className={`px-3 py-1 rounded-md text-sm border ${editor?.isActive('bold') ? 'bg-blue-600 text-white border-blue-600' : 'bg-gray-100 text-gray-800 border-gray-300'}`}
+ >
+ Bold
+
+
toggleMark('italic')}
+ className={`px-3 py-1 rounded-md text-sm border ${editor?.isActive('italic') ? 'bg-blue-600 text-white border-blue-600' : 'bg-gray-100 text-gray-800 border-gray-300'}`}
+ >
+ Italic
+
+
toggleList('bullet')}
+ className={`px-3 py-1 rounded-md text-sm border ${editor?.isActive('bulletList') ? 'bg-blue-600 text-white border-blue-600' : 'bg-gray-100 text-gray-800 border-gray-300'}`}
+ >
+ Bullets
+
+
toggleList('ordered')}
+ className={`px-3 py-1 rounded-md text-sm border ${editor?.isActive('orderedList') ? 'bg-blue-600 text-white border-blue-600' : 'bg-gray-100 text-gray-800 border-gray-300'}`}
+ >
+ Numbered
+
+
+ Clear
+
+
{textCount}/600
+
+
+
+
+
+
+ {isTooLong && (
+
Summary exceeds 600 characters. Please shorten it.
+ )}
+
+ );
+};
+
+export default SummaryEditor;
\ No newline at end of file
diff --git a/cv-engine/src/schema/cvSchema.ts b/cv-engine/src/schema/cvSchema.ts
index 8ec56e2..bfa094a 100644
--- a/cv-engine/src/schema/cvSchema.ts
+++ b/cv-engine/src/schema/cvSchema.ts
@@ -1,4 +1,5 @@
import { z } from 'zod';
+import DOMPurify from 'dompurify';
// Helper function to generate unique IDs
export const generateId = () => Math.random().toString(36).substring(2, 9);
@@ -109,12 +110,11 @@ export const validateSection = (section: K, data: CV[K]) =>
// Sanitization helpers
export const sanitizeHtml = (html: string): string => {
- // In a real implementation, we would use DOMPurify here
- // This is a simple placeholder
- return html
- .replace(/