feat(summary): implement summary editor with validation and preview

Add summary editor component with rich text formatting capabilities
Add validation for summary section in cv store
Implement printable preview mode in preview panel
Replace placeholder with actual summary editor in App component
Update HTML sanitization to use DOMPurify with allowed tags
This commit is contained in:
geulah 2025-10-05 23:45:56 +01:00
parent 46e1f245f8
commit 48b48f8165
6 changed files with 330 additions and 13 deletions

View File

@ -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 <SkillsEditor />;
case 'summary':
return <div className="p-6 bg-white rounded-lg shadow-sm">Summary editor will be implemented in M3</div>;
return <SummaryEditor />;
case 'finalize':
return <div className="p-6 bg-white rounded-lg shadow-sm">Finalize step will be implemented in M5</div>;
default:

View File

@ -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<PreviewPanelProps> = ({ 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 (
<div className={`bg-gray-100 rounded-lg shadow-inner overflow-auto ${className}`}>
<div className="sticky top-0 bg-gray-200 p-3 border-b flex justify-between items-center">
<h2 className="text-lg font-medium text-gray-700">Preview</h2>
<div className="flex items-center gap-3">
<div className="text-sm text-gray-500">
Template: <span className="font-medium">{templateId === 'ats' ? 'ATS-Friendly' : templateId}</span>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => 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
</button>
<button
type="button"
onClick={() => 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
</button>
</div>
</div>
</div>
<div className="p-4">
{mode === 'inline' && (
<>
{/* Render the appropriate template based on templateId */}
{templateId === 'ats' && <ATSTemplate cv={cv} />}
{/* Add more template options here as they are implemented */}
</>
)}
{mode === 'printable' && (
<iframe
title="Printable Preview"
className="w-full h-[calc(100vh-320px)] bg-white"
sandbox="allow-same-origin"
srcDoc={printableHtml}
/>
)}
</div>
</div>
);

View File

@ -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<SummaryEditorProps> = () => {
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 (
<div className="p-6 bg-white rounded-lg shadow-sm">
<h2 className="text-xl font-semibold mb-4">Professional Summary</h2>
<p className="text-sm text-gray-600 mb-4">Keep it concise (600 characters). Use formatting for readability.</p>
{/* Toolbar */}
<div className="flex items-center gap-2 mb-3">
<button
type="button"
onClick={() => 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
</button>
<button
type="button"
onClick={() => 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
</button>
<button
type="button"
onClick={() => 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
</button>
<button
type="button"
onClick={() => 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
</button>
<button
type="button"
onClick={clearFormatting}
className="px-3 py-1 rounded-md text-sm border bg-gray-100 text-gray-800 border-gray-300"
>
Clear
</button>
<div className={`ml-auto text-xs ${isTooLong ? 'text-red-600' : 'text-gray-500'}`}>{textCount}/600</div>
</div>
<div className={`border rounded-md p-3 ${isTooLong ? 'border-red-500' : 'border-gray-300'}`}>
<EditorContent editor={editor} />
</div>
{isTooLong && (
<p className="mt-2 text-sm text-red-600">Summary exceeds 600 characters. Please shorten it.</p>
)}
</div>
);
};
export default SummaryEditor;

View File

@ -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 = <K extends keyof CV>(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(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, '')
.replace(/<[^>]*>/g, ''); // Remove all HTML tags for now
// Sanitize summary while preserving basic formatting elements
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p', 'strong', 'em', 'ul', 'ol', 'li', 'br'],
ALLOWED_ATTR: []
});
};
export const escapeText = (text: string): string => {

View File

@ -327,6 +327,20 @@ export const useCvStore = create<CvState>((set, get) => ({
set((state) => ({ errors: { ...state.errors, skills: [] } }));
}
}
if (activeStep === 'summary') {
const result = validateSection('summary', cv.summary);
isValid = result.success;
if (!result.success) {
set((state) => ({
errors: {
...state.errors,
summary: result.error.issues.map(i => i.message)
}
}));
} else {
set((state) => ({ errors: { ...state.errors, summary: [] } }));
}
}
return isValid;
},

View File

@ -0,0 +1,156 @@
import type { CV } from '../schema/cvSchema';
import { escapeText, sanitizeHtml } from '../schema/cvSchema';
const formatDate = (dateStr?: string): string => {
if (!dateStr) return '';
try {
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short' });
} catch {
return dateStr;
}
};
export const buildPrintableHtml = (cv: CV): string => {
const { personal, summary, work, education, skills, languages, certifications } = cv;
const summaryHtml = summary ? sanitizeHtml(summary) : '';
const styles = `
<style>
@page { size: A4; margin: 20mm; }
body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, Noto Sans, "Apple Color Emoji", "Segoe UI Emoji"; color: #1f2937; }
.page { width: 210mm; min-height: 297mm; margin: 0 auto; background: white; }
h1 { font-size: 24px; margin: 0 0 6px 0; }
h2 { font-size: 16px; margin: 16px 0 8px 0; padding-bottom: 4px; border-bottom: 1px solid #e5e7eb; color: #374151; }
h3 { font-size: 14px; margin: 0; }
.header { border-bottom: 1px solid #e5e7eb; padding-bottom: 12px; margin-bottom: 16px; }
.meta { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: #6b7280; }
.section { margin-bottom: 16px; }
.job, .edu { margin-bottom: 12px; }
.row { display: flex; justify-content: space-between; align-items: flex-start; }
.small { font-size: 12px; color: #6b7280; }
ul { padding-left: 20px; }
li { margin: 4px 0; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip { background: #f3f4f6; color: #1f2937; padding: 2px 8px; border-radius: 12px; font-size: 12px; }
</style>
`;
const headerLinks = (personal.links || [])
.map(l => `<a href="${escapeText(l.url)}" target="_blank" rel="noopener noreferrer">${escapeText(l.label || l.url)}</a>`)
.join(' · ');
const headerHtml = `
<div class="header">
<h1>${escapeText(personal.firstName)} ${escapeText(personal.lastName)}</h1>
<div class="meta">
${personal.email ? `<span>${escapeText(personal.email)}</span>` : ''}
${personal.phone ? `<span>${escapeText(personal.phone)}</span>` : ''}
${(personal.city || personal.country) ? `<span>${personal.city ? escapeText(personal.city) : ''}${personal.city && personal.country ? ', ' : ''}${personal.country ? escapeText(personal.country) : ''}</span>` : ''}
${headerLinks ? `<span>${headerLinks}</span>` : ''}
</div>
</div>
`;
const summarySection = summaryHtml ? `
<div class="section">
<h2>Professional Summary</h2>
<div>${summaryHtml}</div>
</div>
` : '';
const workSection = (work && work.length) ? `
<div class="section">
<h2>Work Experience</h2>
${work.map(job => `
<div class="job">
<div class="row">
<div>
<h3>${escapeText(job.title)}</h3>
<div class="small">${escapeText(job.company)}</div>
</div>
<div class="small">
${formatDate(job.startDate)} - ${job.endDate ? formatDate(job.endDate) : 'Present'}
${job.location ? `<div>${escapeText(job.location)}</div>` : ''}
</div>
</div>
${(job.bullets && job.bullets.length) ? `
<ul>
${job.bullets.map(b => `<li>${escapeText(b)}</li>`).join('')}
</ul>
` : ''}
</div>
`).join('')}
</div>
` : '';
const eduSection = (education && education.length) ? `
<div class="section">
<h2>Education</h2>
${education.map(edu => `
<div class="edu">
<div class="row">
<div>
<h3>${escapeText(edu.degree)}</h3>
<div class="small">${escapeText(edu.school)}</div>
</div>
<div class="small">
${formatDate(edu.startDate)} - ${edu.endDate ? formatDate(edu.endDate) : 'Present'}
</div>
</div>
${edu.notes ? `<div class="small">${escapeText(edu.notes)}</div>` : ''}
</div>
`).join('')}
</div>
` : '';
const skillsSection = (skills && skills.length) ? `
<div class="section">
<h2>Skills</h2>
<div class="chips">
${skills.map(s => `<span class="chip">${escapeText(s.name)}${s.level ? ` (${s.level})` : ''}</span>`).join('')}
</div>
</div>
` : '';
const languagesSection = (languages && languages.length) ? `
<div class="section">
<h2>Languages</h2>
<div class="chips">
${languages.map(l => `<span class="chip">${escapeText(l.name)}${l.level ? ` (${l.level})` : ''}</span>`).join('')}
</div>
</div>
` : '';
const certsSection = (certifications && certifications.length) ? `
<div class="section">
<h2>Certifications</h2>
<ul>
${certifications.map(c => `<li>${escapeText(c.name)}${c.issuer ? ` - ${escapeText(c.issuer)}` : ''}${c.date ? ` (${formatDate(c.date)})` : ''}</li>`).join('')}
</ul>
</div>
` : '';
return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Printable CV</title>
${styles}
</head>
<body>
<div class="page">
${headerHtml}
${summarySection}
${workSection}
${eduSection}
${skillsSection}
${languagesSection}
${certsSection}
</div>
</body>
</html>`;
};