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:
parent
46e1f245f8
commit
48b48f8165
@ -4,6 +4,7 @@ import PersonalEditor from './editors/PersonalEditor';
|
|||||||
import WorkEditor from './editors/WorkEditor';
|
import WorkEditor from './editors/WorkEditor';
|
||||||
import EducationEditor from './editors/EducationEditor';
|
import EducationEditor from './editors/EducationEditor';
|
||||||
import SkillsEditor from './editors/SkillsEditor';
|
import SkillsEditor from './editors/SkillsEditor';
|
||||||
|
import SummaryEditor from './editors/SummaryEditor';
|
||||||
import PreviewPanel from './components/PreviewPanel';
|
import PreviewPanel from './components/PreviewPanel';
|
||||||
import { useActiveStep } from './store/cvStore';
|
import { useActiveStep } from './store/cvStore';
|
||||||
|
|
||||||
@ -22,7 +23,7 @@ const App: React.FC = () => {
|
|||||||
case 'skills':
|
case 'skills':
|
||||||
return <SkillsEditor />;
|
return <SkillsEditor />;
|
||||||
case 'summary':
|
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':
|
case 'finalize':
|
||||||
return <div className="p-6 bg-white rounded-lg shadow-sm">Finalize step will be implemented in M5</div>;
|
return <div className="p-6 bg-white rounded-lg shadow-sm">Finalize step will be implemented in M5</div>;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { useCvStore } from '../store/cvStore';
|
import { useCvStore } from '../store/cvStore';
|
||||||
import ATSTemplate from '../templates/ATSTemplate';
|
import ATSTemplate from '../templates/ATSTemplate';
|
||||||
|
import { buildPrintableHtml } from '../utils/printable';
|
||||||
|
|
||||||
interface PreviewPanelProps {
|
interface PreviewPanelProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -9,20 +10,53 @@ interface PreviewPanelProps {
|
|||||||
const PreviewPanel: React.FC<PreviewPanelProps> = ({ className = '' }) => {
|
const PreviewPanel: React.FC<PreviewPanelProps> = ({ className = '' }) => {
|
||||||
const cv = useCvStore(state => state.cv);
|
const cv = useCvStore(state => state.cv);
|
||||||
const templateId = useCvStore(state => state.cv.templateId);
|
const templateId = useCvStore(state => state.cv.templateId);
|
||||||
|
const [mode, setMode] = useState<'inline' | 'printable'>('inline');
|
||||||
|
const printableHtml = useMemo(() => buildPrintableHtml(cv), [cv]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`bg-gray-100 rounded-lg shadow-inner overflow-auto ${className}`}>
|
<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">
|
<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>
|
<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">
|
<div className="text-sm text-gray-500">
|
||||||
Template: <span className="font-medium">{templateId === 'ats' ? 'ATS-Friendly' : templateId}</span>
|
Template: <span className="font-medium">{templateId === 'ats' ? 'ATS-Friendly' : templateId}</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
|
{mode === 'inline' && (
|
||||||
|
<>
|
||||||
{/* Render the appropriate template based on templateId */}
|
{/* Render the appropriate template based on templateId */}
|
||||||
{templateId === 'ats' && <ATSTemplate cv={cv} />}
|
{templateId === 'ats' && <ATSTemplate cv={cv} />}
|
||||||
{/* Add more template options here as they are implemented */}
|
{/* 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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
112
cv-engine/src/editors/SummaryEditor.tsx
Normal file
112
cv-engine/src/editors/SummaryEditor.tsx
Normal 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;
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
// Helper function to generate unique IDs
|
// Helper function to generate unique IDs
|
||||||
export const generateId = () => Math.random().toString(36).substring(2, 9);
|
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
|
// Sanitization helpers
|
||||||
export const sanitizeHtml = (html: string): string => {
|
export const sanitizeHtml = (html: string): string => {
|
||||||
// In a real implementation, we would use DOMPurify here
|
// Sanitize summary while preserving basic formatting elements
|
||||||
// This is a simple placeholder
|
return DOMPurify.sanitize(html, {
|
||||||
return html
|
ALLOWED_TAGS: ['p', 'strong', 'em', 'ul', 'ol', 'li', 'br'],
|
||||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
ALLOWED_ATTR: []
|
||||||
.replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, '')
|
});
|
||||||
.replace(/<[^>]*>/g, ''); // Remove all HTML tags for now
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const escapeText = (text: string): string => {
|
export const escapeText = (text: string): string => {
|
||||||
|
|||||||
@ -327,6 +327,20 @@ export const useCvStore = create<CvState>((set, get) => ({
|
|||||||
set((state) => ({ errors: { ...state.errors, skills: [] } }));
|
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;
|
return isValid;
|
||||||
},
|
},
|
||||||
|
|||||||
156
cv-engine/src/utils/printable.ts
Normal file
156
cv-engine/src/utils/printable.ts
Normal 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>`;
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user