feat(editor): add placeholder and JSON support to summary editor
- Add @tiptap/extension-placeholder dependency - Implement placeholder text in summary editor - Add JSON persistence for summary content - Enhance HTML sanitization to support safe links
This commit is contained in:
parent
48b48f8165
commit
3468cf7a43
14
cv-engine/package-lock.json
generated
14
cv-engine/package-lock.json
generated
@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.90.2",
|
"@tanstack/react-query": "^5.90.2",
|
||||||
|
"@tiptap/extension-placeholder": "^3.6.5",
|
||||||
"@tiptap/react": "^3.6.5",
|
"@tiptap/react": "^3.6.5",
|
||||||
"@tiptap/starter-kit": "^3.6.5",
|
"@tiptap/starter-kit": "^3.6.5",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
@ -2022,6 +2023,19 @@
|
|||||||
"@tiptap/core": "^3.6.5"
|
"@tiptap/core": "^3.6.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/extension-placeholder": {
|
||||||
|
"version": "3.6.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.6.5.tgz",
|
||||||
|
"integrity": "sha512-9CLixogEb/4UkEyuDr4JdOlLvphcOVfZMdNMKmUVQdqo4MuZCdTDyK5ypfTPQJl8aUo0oCiEhqE0bQerYlueJQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extensions": "^3.6.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tiptap/extension-strike": {
|
"node_modules/@tiptap/extension-strike": {
|
||||||
"version": "3.6.5",
|
"version": "3.6.5",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.6.5.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.6.5.tgz",
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.90.2",
|
"@tanstack/react-query": "^5.90.2",
|
||||||
|
"@tiptap/extension-placeholder": "^3.6.5",
|
||||||
"@tiptap/react": "^3.6.5",
|
"@tiptap/react": "^3.6.5",
|
||||||
"@tiptap/starter-kit": "^3.6.5",
|
"@tiptap/starter-kit": "^3.6.5",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
|
|||||||
@ -2,15 +2,22 @@ import React, { useEffect, useMemo } from 'react';
|
|||||||
import { useSummaryData, useCvStore } from '../store/cvStore';
|
import { useSummaryData, useCvStore } from '../store/cvStore';
|
||||||
import { EditorContent, useEditor } from '@tiptap/react';
|
import { EditorContent, useEditor } from '@tiptap/react';
|
||||||
import StarterKit from '@tiptap/starter-kit';
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import Placeholder from '@tiptap/extension-placeholder';
|
||||||
|
|
||||||
interface SummaryEditorProps {}
|
interface SummaryEditorProps {}
|
||||||
|
|
||||||
const SummaryEditor: React.FC<SummaryEditorProps> = () => {
|
const SummaryEditor: React.FC<SummaryEditorProps> = () => {
|
||||||
const summary = useSummaryData();
|
const summary = useSummaryData();
|
||||||
const { updateSummary } = useCvStore();
|
const { updateSummary, updateSummaryJson } = useCvStore();
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [StarterKit],
|
extensions: [
|
||||||
|
StarterKit,
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder: 'Briefly summarize your experience…',
|
||||||
|
includeChildren: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
content: summary || '',
|
content: summary || '',
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
@ -20,6 +27,12 @@ const SummaryEditor: React.FC<SummaryEditorProps> = () => {
|
|||||||
onUpdate: ({ editor }) => {
|
onUpdate: ({ editor }) => {
|
||||||
const html = editor.getHTML();
|
const html = editor.getHTML();
|
||||||
updateSummary(html);
|
updateSummary(html);
|
||||||
|
try {
|
||||||
|
const json = editor.getJSON();
|
||||||
|
updateSummaryJson(json);
|
||||||
|
} catch {
|
||||||
|
// ignore JSON persistence errors silently
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,21 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
|
// Initialize link-safety hook: force rel/target and restrict href schemes
|
||||||
|
// Note: Hooks may be registered multiple times during HMR; this hook is idempotent.
|
||||||
|
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||||
|
if (node && node.nodeName && node.nodeName.toLowerCase() === 'a') {
|
||||||
|
const href = node.getAttribute('href') || '';
|
||||||
|
const isSafe = /^(https?:\/\/|mailto:)/i.test(href);
|
||||||
|
if (!isSafe) {
|
||||||
|
node.removeAttribute('href');
|
||||||
|
}
|
||||||
|
// Enforce safe link attributes
|
||||||
|
node.setAttribute('rel', 'noopener noreferrer');
|
||||||
|
node.setAttribute('target', '_blank');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 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);
|
||||||
|
|
||||||
@ -69,6 +84,8 @@ export const CvSchema = z.object({
|
|||||||
date: z.string().optional()
|
date: z.string().optional()
|
||||||
})
|
})
|
||||||
).optional().default([]),
|
).optional().default([]),
|
||||||
|
// Optional ProseMirror JSON for summary rich content persistence
|
||||||
|
summaryJson: z.any().optional(),
|
||||||
templateId: z.string().default('ats'),
|
templateId: z.string().default('ats'),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -95,6 +112,7 @@ export const createEmptyCV = (): CV => ({
|
|||||||
skills: [],
|
skills: [],
|
||||||
languages: [],
|
languages: [],
|
||||||
certifications: [],
|
certifications: [],
|
||||||
|
summaryJson: undefined,
|
||||||
templateId: "ats",
|
templateId: "ats",
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -112,8 +130,9 @@ export const validateSection = <K extends keyof CV>(section: K, data: CV[K]) =>
|
|||||||
export const sanitizeHtml = (html: string): string => {
|
export const sanitizeHtml = (html: string): string => {
|
||||||
// Sanitize summary while preserving basic formatting elements
|
// Sanitize summary while preserving basic formatting elements
|
||||||
return DOMPurify.sanitize(html, {
|
return DOMPurify.sanitize(html, {
|
||||||
ALLOWED_TAGS: ['p', 'strong', 'em', 'ul', 'ol', 'li', 'br'],
|
ALLOWED_TAGS: ['p', 'strong', 'em', 'ul', 'ol', 'li', 'br', 'a'],
|
||||||
ALLOWED_ATTR: []
|
ALLOWED_ATTR: ['href', 'rel', 'target'],
|
||||||
|
ALLOW_UNKNOWN_PROTOCOLS: false
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,7 @@ interface CvState {
|
|||||||
updateEducation: (education: z.infer<typeof CvSchema>['education']) => void;
|
updateEducation: (education: z.infer<typeof CvSchema>['education']) => void;
|
||||||
updateSkills: (skills: z.infer<typeof CvSchema>['skills']) => void;
|
updateSkills: (skills: z.infer<typeof CvSchema>['skills']) => void;
|
||||||
updateSummary: (summary: string) => void;
|
updateSummary: (summary: string) => void;
|
||||||
|
updateSummaryJson: (json: unknown) => void;
|
||||||
updateTemplateId: (templateId: string) => void;
|
updateTemplateId: (templateId: string) => void;
|
||||||
|
|
||||||
// Work item operations
|
// Work item operations
|
||||||
@ -110,6 +111,13 @@ export const useCvStore = create<CvState>((set, get) => ({
|
|||||||
isDirty: true
|
isDirty: true
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateSummaryJson: (json) => {
|
||||||
|
set((state) => ({
|
||||||
|
cv: { ...state.cv, summaryJson: json },
|
||||||
|
isDirty: true
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
updateTemplateId: (templateId) => {
|
updateTemplateId: (templateId) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@ -354,6 +362,7 @@ export const useWorkData = () => useCvStore(state => state.cv.work);
|
|||||||
export const useEducationData = () => useCvStore(state => state.cv.education);
|
export const useEducationData = () => useCvStore(state => state.cv.education);
|
||||||
export const useSkillsData = () => useCvStore(state => state.cv.skills);
|
export const useSkillsData = () => useCvStore(state => state.cv.skills);
|
||||||
export const useSummaryData = () => useCvStore(state => state.cv.summary);
|
export const useSummaryData = () => useCvStore(state => state.cv.summary);
|
||||||
|
export const useSummaryJson = () => useCvStore(state => state.cv.summaryJson);
|
||||||
export const useTemplateId = () => useCvStore(state => state.cv.templateId);
|
export const useTemplateId = () => useCvStore(state => state.cv.templateId);
|
||||||
export const useActiveStep = () => useCvStore(state => state.activeStep);
|
export const useActiveStep = () => useCvStore(state => state.activeStep);
|
||||||
export const useIsDirty = () => useCvStore(state => state.isDirty);
|
export const useIsDirty = () => useCvStore(state => state.isDirty);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user