feat(templates): add color theme support and template selection step
Implement color theme system with 9 color options and default theme Add template selection as first step in CV creation flow Update all templates to support dynamic color theming Create ThemeProvider component to apply theme styles Add template thumbnails with color theme variations Extend CV schema with colorTheme field Update store to handle template selection and color theme state
This commit is contained in:
parent
81712044c2
commit
3a9591fb48
@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import Stepper from './components/Stepper';
|
||||
import TemplateSelectionEditor from './editors/TemplateSelectionEditor';
|
||||
import PersonalEditor from './editors/PersonalEditor';
|
||||
import WorkEditor from './editors/WorkEditor';
|
||||
import EducationEditor from './editors/EducationEditor';
|
||||
@ -17,6 +18,8 @@ const App: React.FC = () => {
|
||||
// Render the appropriate editor based on the active step
|
||||
const renderEditor = () => {
|
||||
switch (activeStep) {
|
||||
case 'template':
|
||||
return <TemplateSelectionEditor />;
|
||||
case 'personal':
|
||||
return <PersonalEditor />;
|
||||
case 'work':
|
||||
@ -30,7 +33,7 @@ const App: React.FC = () => {
|
||||
case 'finalize':
|
||||
return <div className="p-6 bg-white rounded-lg shadow-sm">Finalize step will be implemented in M5</div>;
|
||||
default:
|
||||
return <PersonalEditor />;
|
||||
return <TemplateSelectionEditor />;
|
||||
}
|
||||
};
|
||||
|
||||
@ -48,17 +51,25 @@ const App: React.FC = () => {
|
||||
<main className="max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
|
||||
<Stepper className="mb-8" />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Editor Panel */}
|
||||
<div className="lg:col-span-1">
|
||||
{activeStep === 'template' ? (
|
||||
// Full-screen template selection
|
||||
<div className="w-full">
|
||||
{renderEditor()}
|
||||
</div>
|
||||
) : (
|
||||
// Two-column layout for other steps
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Editor Panel */}
|
||||
<div className="lg:col-span-1">
|
||||
{renderEditor()}
|
||||
</div>
|
||||
|
||||
{/* Preview Panel */}
|
||||
<div className="lg:col-span-1 h-[calc(100vh-240px)]">
|
||||
<PreviewPanel className="h-full" />
|
||||
{/* Preview Panel */}
|
||||
<div className="lg:col-span-1 h-[calc(100vh-240px)]">
|
||||
<PreviewPanel className="h-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="bg-white mt-12 py-6 border-t">
|
||||
|
||||
@ -1,10 +1,46 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="96" viewBox="0 0 160 96">
|
||||
<rect x="1" y="1" width="158" height="94" rx="6" fill="#ffffff" stroke="#e5e7eb"/>
|
||||
<rect x="12" y="14" width="96" height="10" fill="#d1d5db"/>
|
||||
<rect x="12" y="30" width="136" height="8" fill="#e5e7eb"/>
|
||||
<rect x="12" y="42" width="136" height="8" fill="#e5e7eb"/>
|
||||
<rect x="12" y="54" width="120" height="8" fill="#e5e7eb"/>
|
||||
<rect x="12" y="66" width="120" height="8" fill="#e5e7eb"/>
|
||||
<rect x="12" y="78" width="104" height="8" fill="#e5e7eb"/>
|
||||
<text x="12" y="12" font-size="10" fill="#6b7280" font-family="Inter, Arial">ATS-Friendly</text>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="100" viewBox="0 0 160 100">
|
||||
<!-- Background frame -->
|
||||
<rect x="1" y="1" width="158" height="98" rx="6" fill="#ffffff" stroke="#e5e7eb"/>
|
||||
|
||||
<!-- Header: Name centered -->
|
||||
<text x="80" y="15" font-size="8" font-weight="bold" fill="#000" font-family="Times New Roman, serif" text-anchor="middle">FIRST LAST</text>
|
||||
<text x="80" y="22" font-size="3" fill="#000" font-family="Times New Roman, serif" text-anchor="middle">Bay Area, California • +1-234-456-789 • professionalemail@resumeworded.com • linkedin.com/in/username</text>
|
||||
|
||||
<!-- Section: PROFESSIONAL EXPERIENCE -->
|
||||
<text x="12" y="32" font-size="4" font-weight="bold" fill="#000" font-family="Times New Roman, serif">PROFESSIONAL EXPERIENCE</text>
|
||||
<line x1="12" y1="33" x2="148" y2="33" stroke="#000" stroke-width="0.6"/>
|
||||
|
||||
<!-- Job 1 -->
|
||||
<text x="12" y="39" font-size="3.5" font-weight="bold" fill="#000" font-family="Times New Roman, serif">Resume Worded</text>
|
||||
<text x="52" y="39" font-size="3.5" fill="#000" font-family="Times New Roman, serif">, New York, NY</text>
|
||||
<text x="148" y="39" font-size="3.5" fill="#000" font-family="Times New Roman, serif" text-anchor="end">Jun 2018 – Present</text>
|
||||
<text x="12" y="43" font-size="3.5" font-style="italic" fill="#000" font-family="Times New Roman, serif">Human Resources Manager</text>
|
||||
<circle cx="14" cy="46" r="0.45" fill="#000"/>
|
||||
<text x="17" y="47" font-size="3" fill="#000" font-family="Times New Roman, serif">Structured and implemented programs and policies; reduced recruiting costs by 70%.</text>
|
||||
<circle cx="14" cy="50" r="0.45" fill="#000"/>
|
||||
<text x="17" y="51" font-size="3" fill="#000" font-family="Times New Roman, serif">Led HR strategy for 150+ franchises; generated over $40M in revenue.</text>
|
||||
<circle cx="14" cy="54" r="0.45" fill="#000"/>
|
||||
<text x="17" y="55" font-size="3" fill="#000" font-family="Times New Roman, serif">Played integral role in company reorganization and franchise oversight.</text>
|
||||
|
||||
<!-- Job 2 -->
|
||||
<text x="12" y="61" font-size="3.5" font-weight="bold" fill="#000" font-family="Times New Roman, serif">Second Company</text>
|
||||
<text x="58" y="61" font-size="3.5" fill="#000" font-family="Times New Roman, serif">, New York, NY</text>
|
||||
<text x="148" y="61" font-size="3.5" fill="#000" font-family="Times New Roman, serif" text-anchor="end">Jan 2015 – May 2018</text>
|
||||
<text x="12" y="65" font-size="3.5" font-style="italic" fill="#000" font-family="Times New Roman, serif">Human Resources Manager</text>
|
||||
<circle cx="14" cy="68" r="0.45" fill="#000"/>
|
||||
<text x="17" y="69" font-size="3" fill="#000" font-family="Times New Roman, serif">Implemented employee referral program; reduced cost per hire by 35%.</text>
|
||||
<circle cx="14" cy="72" r="0.45" fill="#000"/>
|
||||
<text x="17" y="73" font-size="3" fill="#000" font-family="Times New Roman, serif">Administered benefits for 350+ union and non-union employees.</text>
|
||||
|
||||
<!-- Section: EDUCATION -->
|
||||
<text x="12" y="80" font-size="4" font-weight="bold" fill="#000" font-family="Times New Roman, serif">EDUCATION</text>
|
||||
<line x1="12" y1="81" x2="148" y2="81" stroke="#000" stroke-width="0.6"/>
|
||||
<text x="12" y="86" font-size="3.5" font-weight="bold" fill="#000" font-family="Times New Roman, serif">Resume Worded University</text>
|
||||
<text x="90" y="86" font-size="3.5" fill="#000" font-family="Times New Roman, serif">, San Francisco, CA</text>
|
||||
<text x="148" y="86" font-size="3.5" fill="#000" font-family="Times New Roman, serif" text-anchor="end">May 2010</text>
|
||||
<text x="12" y="90" font-size="3.5" font-style="italic" fill="#000" font-family="Times New Roman, serif">Bachelor of Science in Human Resource Management</text>
|
||||
|
||||
<!-- Section: SKILLS -->
|
||||
<text x="12" y="95" font-size="4" font-weight="bold" fill="#000" font-family="Times New Roman, serif">SKILLS</text>
|
||||
<line x1="12" y1="96" x2="148" y2="96" stroke="#000" stroke-width="0.6"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 648 B After Width: | Height: | Size: 3.9 KiB |
@ -1,9 +1,10 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useCvStore } from '../store/cvStore';
|
||||
import { useCvStore, useColorTheme } from '../store/cvStore';
|
||||
import { templatesMap } from '../templates/registry';
|
||||
import { buildPrintableHtml } from '../utils/printable';
|
||||
import ExportControls from './ExportControls';
|
||||
import TemplateGallery from './TemplateGallery';
|
||||
import ThemeProvider from './ThemeProvider';
|
||||
|
||||
interface PreviewPanelProps {
|
||||
className?: string;
|
||||
@ -12,6 +13,7 @@ interface PreviewPanelProps {
|
||||
const PreviewPanel: React.FC<PreviewPanelProps> = ({ className = '' }) => {
|
||||
const cv = useCvStore(state => state.cv);
|
||||
const templateId = useCvStore(state => state.cv.templateId);
|
||||
const colorTheme = useColorTheme();
|
||||
const [mode, setMode] = useState<'inline' | 'printable'>('inline');
|
||||
const printableHtml = useMemo(() => buildPrintableHtml(cv, templateId), [cv, templateId]);
|
||||
|
||||
@ -50,7 +52,11 @@ const PreviewPanel: React.FC<PreviewPanelProps> = ({ className = '' }) => {
|
||||
{/* Render component via registry */}
|
||||
{(() => {
|
||||
const Comp = templatesMap[templateId]?.component;
|
||||
return Comp ? <Comp cv={cv} /> : null;
|
||||
return Comp ? (
|
||||
<ThemeProvider theme={colorTheme}>
|
||||
<Comp cv={cv} />
|
||||
</ThemeProvider>
|
||||
) : null;
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -10,6 +10,7 @@ const Stepper: React.FC<StepperProps> = ({ className = '' }) => {
|
||||
const { setActiveStep, validateActiveSection, nextStep, prevStep } = useCvStore();
|
||||
|
||||
const steps: { id: EditorStep; label: string }[] = [
|
||||
{ id: 'template', label: 'Choose Template' },
|
||||
{ id: 'personal', label: 'Personal Details' },
|
||||
{ id: 'work', label: 'Work Experience' },
|
||||
{ id: 'education', label: 'Education' },
|
||||
@ -86,10 +87,10 @@ const Stepper: React.FC<StepperProps> = ({ className = '' }) => {
|
||||
<div className="flex justify-between mt-8">
|
||||
<button
|
||||
onClick={prevStep}
|
||||
disabled={activeStep === 'personal'}
|
||||
disabled={activeStep === 'template'}
|
||||
className={`
|
||||
px-4 py-2 rounded-md text-sm font-medium
|
||||
${activeStep === 'personal' ? 'bg-gray-200 text-gray-400 cursor-not-allowed' : 'bg-gray-200 text-gray-700 hover:bg-gray-300'}
|
||||
${activeStep === 'template' ? 'bg-gray-200 text-gray-400 cursor-not-allowed' : 'bg-gray-200 text-gray-700 hover:bg-gray-300'}
|
||||
`}
|
||||
>
|
||||
Previous
|
||||
|
||||
24
cv-engine/src/components/ThemeProvider.tsx
Normal file
24
cv-engine/src/components/ThemeProvider.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { getThemeCSS, type ColorTheme } from '../types/colors';
|
||||
|
||||
interface ThemeProviderProps {
|
||||
theme: ColorTheme;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ThemeProvider: React.FC<ThemeProviderProps> = ({ theme, children, className = '' }) => {
|
||||
const themeStyles = getThemeCSS(theme);
|
||||
const themeClass = theme ? `cv-theme-${theme}` : 'cv-theme-default';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${themeClass} ${className}`}
|
||||
style={themeStyles}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeProvider;
|
||||
167
cv-engine/src/editors/TemplateSelectionEditor.tsx
Normal file
167
cv-engine/src/editors/TemplateSelectionEditor.tsx
Normal file
@ -0,0 +1,167 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { templatesRegistry, getTemplateThumbnail, type TemplateCategory } from '../templates/registry';
|
||||
import { useCvStore, useColorTheme } from '../store/cvStore';
|
||||
import { colorThemes, colorThemeOrder, type ColorTheme } from '../types/colors';
|
||||
|
||||
const TemplateSelectionEditor: React.FC = () => {
|
||||
const templateId = useCvStore(state => state.cv.templateId);
|
||||
const updateTemplateId = useCvStore(state => state.updateTemplateId);
|
||||
const colorTheme = useColorTheme();
|
||||
const updateColorTheme = useCvStore(state => state.updateColorTheme);
|
||||
const [filter, setFilter] = useState<'All' | TemplateCategory>('All');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return templatesRegistry.filter(t => filter === 'All' ? true : t.category === filter);
|
||||
}, [filter]);
|
||||
|
||||
const handleTemplateSelect = (id: string) => {
|
||||
updateTemplateId(id);
|
||||
};
|
||||
|
||||
const handleColorSelect = (color: ColorTheme) => {
|
||||
updateColorTheme(color);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
What do you want your CV to look like?
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Scroll to view all styles and click to select a specific style.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Color selection */}
|
||||
<div className="flex justify-center gap-2 mb-8">
|
||||
{colorThemeOrder.map(color => {
|
||||
if (color === null) {
|
||||
// Default/None option
|
||||
return (
|
||||
<button
|
||||
key="default"
|
||||
type="button"
|
||||
onClick={() => handleColorSelect(null)}
|
||||
className={`w-6 h-6 rounded-full border-2 border-gray-300 bg-white transition-all duration-200 hover:scale-110 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 flex items-center justify-center ${
|
||||
colorTheme === null
|
||||
? 'ring-2 ring-offset-2 ring-gray-400 scale-110'
|
||||
: 'hover:ring-1 hover:ring-offset-1 hover:ring-gray-300'
|
||||
}`}
|
||||
aria-label="Select default color theme"
|
||||
>
|
||||
<span className="text-xs text-gray-500 font-medium">/</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const palette = colorThemes[color];
|
||||
return (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => handleColorSelect(color)}
|
||||
className={`w-6 h-6 rounded-full transition-all duration-200 hover:scale-110 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${
|
||||
colorTheme === color
|
||||
? 'ring-2 ring-offset-2 ring-gray-400 scale-110'
|
||||
: 'hover:ring-1 hover:ring-offset-1 hover:ring-gray-300'
|
||||
}`}
|
||||
style={{ backgroundColor: palette.primary }}
|
||||
aria-label={`Select ${color} color theme`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Filter buttons */}
|
||||
<div className="flex justify-center gap-4 mb-8">
|
||||
{(['All', 'ATS', 'Visual'] as const).map(category => (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
onClick={() => setFilter(category)}
|
||||
className={`px-6 py-2 rounded-full text-sm font-medium transition-colors ${
|
||||
filter === category
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Template grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-6 max-w-7xl mx-auto">
|
||||
{filtered.map(template => (
|
||||
<div key={template.id} className="flex flex-col items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTemplateSelect(template.id)}
|
||||
className={`group relative w-full aspect-[3/4] border-2 rounded-lg overflow-hidden transition-all duration-200 hover:shadow-lg ${
|
||||
templateId === template.id
|
||||
? 'border-blue-600 ring-2 ring-blue-600 ring-opacity-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{/* Template thumbnail */}
|
||||
<div className="w-full h-full bg-gray-50 flex items-center justify-center">
|
||||
<img
|
||||
src={getTemplateThumbnail(template.id, colorTheme)}
|
||||
alt={`${template.name} template preview`}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Selection indicator */}
|
||||
{templateId === template.id && (
|
||||
<div className="absolute top-2 right-2 w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Template info */}
|
||||
<div className="mt-3 text-center">
|
||||
<h3 className="font-semibold text-gray-900">{template.name}</h3>
|
||||
{template.category === 'ATS' && (
|
||||
<span className="inline-block mt-1 px-2 py-1 text-xs bg-green-100 text-green-800 rounded-full">
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
{template.category === 'Visual' && template.name === 'Smart' && (
|
||||
<span className="inline-block mt-1 px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded-full">
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
{template.category === 'Visual' && template.name === 'Traditional 2' && (
|
||||
<span className="inline-block mt-1 px-2 py-1 text-xs bg-purple-100 text-purple-800 rounded-full">
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Additional templates section */}
|
||||
<div className="mt-12 text-center">
|
||||
<p className="text-gray-500 text-sm mb-4">
|
||||
Need more options? Additional templates are available
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 max-w-2xl mx-auto opacity-60">
|
||||
{/* Placeholder for additional templates */}
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<div key={i} className="aspect-[3/4] bg-gray-100 rounded-lg border-2 border-dashed border-gray-300 flex items-center justify-center">
|
||||
<span className="text-gray-400 text-xs">Coming Soon</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateSelectionEditor;
|
||||
@ -88,6 +88,7 @@ export const CvSchema = z.object({
|
||||
// Optional ProseMirror JSON for summary rich content persistence
|
||||
summaryJson: z.any().optional(),
|
||||
templateId: z.string().default('ats'),
|
||||
colorTheme: z.enum(['gray', 'dark-gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'black'] as const).nullable().default(null),
|
||||
});
|
||||
|
||||
// Type inference from the schema
|
||||
@ -116,6 +117,7 @@ export const createEmptyCV = (): CV => ({
|
||||
certifications: [],
|
||||
summaryJson: undefined,
|
||||
templateId: "ats",
|
||||
colorTheme: null,
|
||||
});
|
||||
|
||||
// Helper functions for validation
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { create } from 'zustand';
|
||||
import { z } from 'zod';
|
||||
import { CvSchema, createEmptyCV, validateSection } from '../schema/cvSchema';
|
||||
import type { ColorTheme } from '../types/colors';
|
||||
|
||||
// Define the steps for the CV editor
|
||||
export type EditorStep = 'personal' | 'work' | 'education' | 'skills' | 'summary' | 'finalize';
|
||||
export type EditorStep = 'template' | 'personal' | 'work' | 'education' | 'skills' | 'summary' | 'finalize';
|
||||
|
||||
// Define the store state
|
||||
interface CvState {
|
||||
@ -27,6 +28,7 @@ interface CvState {
|
||||
updateSummary: (summary: string) => void;
|
||||
updateSummaryJson: (json: unknown) => void;
|
||||
updateTemplateId: (templateId: string) => void;
|
||||
updateColorTheme: (colorTheme: ColorTheme) => void;
|
||||
|
||||
// Work item operations
|
||||
addWorkItem: () => void;
|
||||
@ -65,7 +67,7 @@ interface CvState {
|
||||
export const useCvStore = create<CvState>((set, get) => ({
|
||||
// Initial state
|
||||
cv: createEmptyCV(),
|
||||
activeStep: 'personal',
|
||||
activeStep: 'template',
|
||||
isDirty: false,
|
||||
lastSaved: null,
|
||||
saveStatus: 'idle',
|
||||
@ -131,12 +133,19 @@ export const useCvStore = create<CvState>((set, get) => ({
|
||||
},
|
||||
|
||||
updateTemplateId: (templateId) => {
|
||||
set((state) => ({
|
||||
set(state => ({
|
||||
cv: { ...state.cv, templateId },
|
||||
isDirty: true
|
||||
}));
|
||||
},
|
||||
|
||||
updateColorTheme: (colorTheme) => {
|
||||
set(state => ({
|
||||
cv: { ...state.cv, colorTheme },
|
||||
isDirty: true
|
||||
}));
|
||||
},
|
||||
|
||||
// Work item operations
|
||||
addWorkItem: () => {
|
||||
const { cv } = get();
|
||||
@ -277,7 +286,7 @@ export const useCvStore = create<CvState>((set, get) => ({
|
||||
|
||||
nextStep: () => {
|
||||
const { activeStep } = get();
|
||||
const steps: EditorStep[] = ['personal', 'work', 'education', 'skills', 'summary', 'finalize'];
|
||||
const steps: EditorStep[] = ['template', 'personal', 'work', 'education', 'skills', 'summary', 'finalize'];
|
||||
const currentIndex = steps.indexOf(activeStep);
|
||||
if (currentIndex < steps.length - 1) {
|
||||
set({ activeStep: steps[currentIndex + 1] });
|
||||
@ -286,7 +295,7 @@ export const useCvStore = create<CvState>((set, get) => ({
|
||||
|
||||
prevStep: () => {
|
||||
const { activeStep } = get();
|
||||
const steps: EditorStep[] = ['personal', 'work', 'education', 'skills', 'summary', 'finalize'];
|
||||
const steps: EditorStep[] = ['template', 'personal', 'work', 'education', 'skills', 'summary', 'finalize'];
|
||||
const currentIndex = steps.indexOf(activeStep);
|
||||
if (currentIndex > 0) {
|
||||
set({ activeStep: steps[currentIndex - 1] });
|
||||
@ -306,6 +315,20 @@ export const useCvStore = create<CvState>((set, get) => ({
|
||||
// Map step to CV section
|
||||
let isValid = true;
|
||||
|
||||
if (activeStep === 'template') {
|
||||
// Ensure a template is selected
|
||||
isValid = !!cv.templateId;
|
||||
if (!isValid) {
|
||||
set((state) => ({
|
||||
errors: {
|
||||
...state.errors,
|
||||
template: ['Please select a template to continue']
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
set((state) => ({ errors: { ...state.errors, template: [] } }));
|
||||
}
|
||||
}
|
||||
if (activeStep === 'personal') {
|
||||
const result = validateSection('personal', cv.personal);
|
||||
isValid = result.success;
|
||||
@ -389,6 +412,7 @@ export const useSkillsData = () => useCvStore(state => state.cv.skills);
|
||||
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 useColorTheme = () => useCvStore(state => state.cv.colorTheme);
|
||||
export const useActiveStep = () => useCvStore(state => state.activeStep);
|
||||
export const useIsDirty = () => useCvStore(state => state.isDirty);
|
||||
export const useLastSaved = () => useCvStore(state => state.lastSaved);
|
||||
|
||||
@ -36,95 +36,63 @@ const ATSTemplate: React.FC<ATSTemplateProps> = ({ cv, className = '' }) => {
|
||||
const { personal, summary, work, education, skills, languages, certifications } = cv;
|
||||
|
||||
return (
|
||||
<div className={`bg-white text-gray-800 p-8 max-w-4xl mx-auto ${className}`}>
|
||||
<div
|
||||
className={`p-8 max-w-4xl mx-auto ${className}`}
|
||||
style={{
|
||||
backgroundColor: 'var(--theme-background, #ffffff)',
|
||||
color: 'var(--theme-text, #1f2937)',
|
||||
fontFamily: 'serif',
|
||||
lineHeight: 'normal'
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<header className="mb-6 border-b pb-6">
|
||||
<h1 className="text-3xl font-bold mb-1">
|
||||
<header className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold mb-1">
|
||||
{escapeText(personal.firstName)} {escapeText(personal.lastName)}
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-wrap gap-3 text-sm mt-2">
|
||||
{personal.email && (
|
||||
<div className="flex items-center">
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>{escapeText(personal.email)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{personal.phone && (
|
||||
<div className="flex items-center">
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
<span>{escapeText(personal.phone)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(personal.city || personal.country) && (
|
||||
<div className="flex items-center">
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span>
|
||||
{personal.city && escapeText(personal.city)}
|
||||
{personal.city && personal.country && ', '}
|
||||
{personal.country && escapeText(personal.country)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{personal.links.map((link) => (
|
||||
<div key={link.id} className="flex items-center">
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
{escapeText(link.label || link.url)}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
<div className="text-xs">
|
||||
{[
|
||||
personal.city && personal.country && `${escapeText(personal.city)}, ${escapeText(personal.country)}`,
|
||||
personal.phone && escapeText(personal.phone),
|
||||
personal.email && escapeText(personal.email),
|
||||
...personal.links.map(link => escapeText(link.label || link.url))
|
||||
].filter(Boolean).join(' • ')}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Summary */}
|
||||
{summary && (
|
||||
<section className="mb-6">
|
||||
<h2 className="text-xl font-bold mb-2 text-gray-700 border-b pb-1">Professional Summary</h2>
|
||||
<p className="text-gray-700" dangerouslySetInnerHTML={{ __html: sanitizeHtml(summary) }} />
|
||||
<section className="mb-4">
|
||||
<h2 className="text-sm font-bold mb-2 pb-1 border-b border-black uppercase">
|
||||
Professional Summary
|
||||
</h2>
|
||||
<p className="text-xs leading-normal" dangerouslySetInnerHTML={{ __html: sanitizeHtml(summary) }} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Work Experience */}
|
||||
{work.length > 0 && (
|
||||
<section className="mb-6">
|
||||
<h2 className="text-xl font-bold mb-3 text-gray-700 border-b pb-1">Work Experience</h2>
|
||||
<section className="mb-4">
|
||||
<h2 className="text-sm font-bold mb-2 pb-1 border-b border-black uppercase">Professional Experience</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{work.map((job) => (
|
||||
<div key={job.id} className="mb-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div key={job.id} className="mb-3">
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{escapeText(job.title)}</h3>
|
||||
<p className="text-gray-700">{escapeText(job.company)}</p>
|
||||
<h3 className="font-bold text-xs">{escapeText(job.company)}, {formatLocation(job.location)}</h3>
|
||||
<p className="text-xs italic">{escapeText(job.title)}</p>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{formatDate(job.startDate)} - {job.endDate ? formatDate(job.endDate) : 'Present'}
|
||||
{job.location && <div>{formatLocation(job.location)}</div>}
|
||||
<div className="text-xs">
|
||||
{formatDate(job.startDate)} – {job.endDate ? formatDate(job.endDate) : 'Present'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{job.bullets.length > 0 && (
|
||||
<ul className="list-disc pl-5 mt-2 space-y-1">
|
||||
<ul className="list-disc pl-4 mt-1 space-y-0.5">
|
||||
{job.bullets.map((bullet, index) => (
|
||||
<li key={index} className="text-gray-700">{escapeText(bullet)}</li>
|
||||
<li key={index} className="text-xs leading-normal">{escapeText(bullet)}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
@ -136,23 +104,23 @@ const ATSTemplate: React.FC<ATSTemplateProps> = ({ cv, className = '' }) => {
|
||||
|
||||
{/* Education */}
|
||||
{education.length > 0 && (
|
||||
<section className="mb-6">
|
||||
<h2 className="text-xl font-bold mb-3 text-gray-700 border-b pb-1">Education</h2>
|
||||
<section className="mb-4">
|
||||
<h2 className="text-sm font-bold mb-2 pb-1 border-b border-black uppercase">Education</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
{education.map((edu) => (
|
||||
<div key={edu.id} className="mb-4">
|
||||
<div key={edu.id}>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{escapeText(edu.degree)}</h3>
|
||||
<p className="text-gray-700">{escapeText(edu.school)}</p>
|
||||
<h3 className="font-bold text-xs">{escapeText(edu.school)}</h3>
|
||||
<p className="text-xs italic">{escapeText(edu.degree)}</p>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{formatDate(edu.startDate)} - {edu.endDate ? formatDate(edu.endDate) : 'Present'}
|
||||
<div className="text-xs">
|
||||
{edu.endDate ? formatDate(edu.endDate) : formatDate(edu.startDate)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{edu.notes && <p className="mt-1 text-gray-700">{escapeText(edu.notes)}</p>}
|
||||
{edu.notes && <p className="mt-1 text-xs">{escapeText(edu.notes)}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -161,18 +129,15 @@ const ATSTemplate: React.FC<ATSTemplateProps> = ({ cv, className = '' }) => {
|
||||
|
||||
{/* Skills */}
|
||||
{skills.length > 0 && (
|
||||
<section className="mb-6">
|
||||
<h2 className="text-xl font-bold mb-3 text-gray-700 border-b pb-1">Skills</h2>
|
||||
<section className="mb-4">
|
||||
<h2 className="text-sm font-bold mb-2 pb-1 border-b border-black uppercase">Skills</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
{skills.map((skill) => (
|
||||
<span
|
||||
key={skill.id}
|
||||
className="bg-gray-100 text-gray-800 px-3 py-1 rounded-full text-sm"
|
||||
>
|
||||
{escapeText(skill.name)}
|
||||
{skill.level && <span className="ml-1 text-gray-500">({skill.level})</span>}
|
||||
</span>
|
||||
<div key={skill.id} className="flex items-center">
|
||||
<span className="text-xs font-bold mr-1">•</span>
|
||||
<span className="text-xs">{escapeText(skill.name)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -4,10 +4,11 @@ import { sanitizeHtml } from '../schema/cvSchema';
|
||||
|
||||
interface ClassicTemplateProps {
|
||||
cv: CV;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Simple classic visual template for inline preview
|
||||
const ClassicTemplate: React.FC<ClassicTemplateProps> = ({ cv }) => {
|
||||
const ClassicTemplate: React.FC<ClassicTemplateProps> = ({ cv, className = '' }) => {
|
||||
const personal = cv.personal || {};
|
||||
const summary = cv.summary || '';
|
||||
const work = cv.work || [];
|
||||
@ -17,8 +18,17 @@ const ClassicTemplate: React.FC<ClassicTemplateProps> = ({ cv }) => {
|
||||
const certifications = cv.certifications || [];
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-md shadow text-gray-800">
|
||||
<header className="border-b pb-3 mb-4">
|
||||
<div
|
||||
className={`p-6 rounded-md shadow ${className}`}
|
||||
style={{
|
||||
backgroundColor: 'var(--theme-background, #ffffff)',
|
||||
color: 'var(--theme-text, #1f2937)'
|
||||
}}
|
||||
>
|
||||
<header
|
||||
className="pb-3 mb-4"
|
||||
style={{ borderBottomColor: 'var(--theme-primary, #2563eb)', borderBottomWidth: '2px' }}
|
||||
>
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{personal.firstName} {personal.lastName}
|
||||
</h1>
|
||||
@ -35,14 +45,24 @@ const ClassicTemplate: React.FC<ClassicTemplateProps> = ({ cv }) => {
|
||||
|
||||
{summary && (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-lg font-medium text-gray-700 mb-2">Professional Summary</h2>
|
||||
<h2
|
||||
className="text-lg font-medium mb-2"
|
||||
style={{ color: 'var(--theme-primary, #2563eb)' }}
|
||||
>
|
||||
Professional Summary
|
||||
</h2>
|
||||
<div className="prose max-w-none" dangerouslySetInnerHTML={{ __html: sanitizeHtml(summary) }} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{work.length > 0 && (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-lg font-medium text-gray-700 mb-2">Work Experience</h2>
|
||||
<h2
|
||||
className="text-lg font-medium mb-2"
|
||||
style={{ color: 'var(--theme-primary, #2563eb)' }}
|
||||
>
|
||||
Work Experience
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{work.map((job) => (
|
||||
<article key={job.id} className="">
|
||||
@ -71,7 +91,12 @@ const ClassicTemplate: React.FC<ClassicTemplateProps> = ({ cv }) => {
|
||||
|
||||
{education.length > 0 && (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-lg font-medium text-gray-700 mb-2">Education</h2>
|
||||
<h2
|
||||
className="text-lg font-medium mb-2"
|
||||
style={{ color: 'var(--theme-primary, #2563eb)' }}
|
||||
>
|
||||
Education
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{education.map((ed) => (
|
||||
<article key={ed.id}>
|
||||
@ -93,10 +118,22 @@ const ClassicTemplate: React.FC<ClassicTemplateProps> = ({ cv }) => {
|
||||
|
||||
{skills.length > 0 && (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-lg font-medium text-gray-700 mb-2">Skills</h2>
|
||||
<h2
|
||||
className="text-lg font-medium mb-2"
|
||||
style={{ color: 'var(--theme-primary, #2563eb)' }}
|
||||
>
|
||||
Skills
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{skills.map((s) => (
|
||||
<span key={s.id} className="px-2 py-1 rounded-full bg-gray-100 text-gray-800 text-sm">
|
||||
<span
|
||||
key={s.id}
|
||||
className="px-2 py-1 rounded-full text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--theme-accent, #eff6ff)',
|
||||
color: 'var(--theme-primary, #2563eb)'
|
||||
}}
|
||||
>
|
||||
{s.name}{s.level ? ` — ${s.level}` : ''}
|
||||
</span>
|
||||
))}
|
||||
@ -106,7 +143,12 @@ const ClassicTemplate: React.FC<ClassicTemplateProps> = ({ cv }) => {
|
||||
|
||||
{languages.length > 0 && (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-lg font-medium text-gray-700 mb-2">Languages</h2>
|
||||
<h2
|
||||
className="text-lg font-medium mb-2"
|
||||
style={{ color: 'var(--theme-primary, #2563eb)' }}
|
||||
>
|
||||
Languages
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{languages.map((l) => (
|
||||
<span key={l.id} className="px-2 py-1 rounded-full bg-gray-100 text-gray-800 text-sm">
|
||||
|
||||
@ -9,7 +9,14 @@ const MinimalTemplate: React.FC<MinimalTemplateProps> = ({ cv, className = '' })
|
||||
const { personal, summary, work = [], education = [], skills = [], languages = [], certifications = [] } = cv;
|
||||
|
||||
return (
|
||||
<div className={`bg-white text-gray-900 max-w-3xl mx-auto px-8 py-6 ${className}`} style={{ fontFamily: 'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial' }}>
|
||||
<div
|
||||
className={`max-w-3xl mx-auto px-8 py-6 ${className}`}
|
||||
style={{
|
||||
fontFamily: 'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial',
|
||||
backgroundColor: 'var(--theme-background, #ffffff)',
|
||||
color: 'var(--theme-text, #1f2937)'
|
||||
}}
|
||||
>
|
||||
<header className="mb-6">
|
||||
<h1 className="text-3xl font-serif tracking-tight">{(personal?.firstName || '')} {(personal?.lastName || '')}</h1>
|
||||
<div className="mt-2 text-sm text-gray-600 flex flex-wrap gap-2">
|
||||
@ -25,14 +32,24 @@ const MinimalTemplate: React.FC<MinimalTemplateProps> = ({ cv, className = '' })
|
||||
|
||||
{summary && (
|
||||
<section className="mb-6">
|
||||
<h2 className="text-xl font-serif mb-2">Summary</h2>
|
||||
<h2
|
||||
className="text-xl font-serif mb-2"
|
||||
style={{ color: 'var(--theme-primary, #2563eb)' }}
|
||||
>
|
||||
Summary
|
||||
</h2>
|
||||
<div className="prose max-w-none" dangerouslySetInnerHTML={{ __html: sanitizeHtml(summary) }} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{work.length > 0 && (
|
||||
<section className="mb-6">
|
||||
<h2 className="text-xl font-serif mb-2">Experience</h2>
|
||||
<h2
|
||||
className="text-xl font-serif mb-2"
|
||||
style={{ color: 'var(--theme-primary, #2563eb)' }}
|
||||
>
|
||||
Experience
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{work.map((job) => (
|
||||
<article key={job.id}>
|
||||
@ -61,7 +78,12 @@ const MinimalTemplate: React.FC<MinimalTemplateProps> = ({ cv, className = '' })
|
||||
|
||||
{education.length > 0 && (
|
||||
<section className="mb-6">
|
||||
<h2 className="text-xl font-serif mb-2">Education</h2>
|
||||
<h2
|
||||
className="text-xl font-serif mb-2"
|
||||
style={{ color: 'var(--theme-primary, #2563eb)' }}
|
||||
>
|
||||
Education
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{education.map((ed) => (
|
||||
<article key={ed.id}>
|
||||
@ -82,10 +104,22 @@ const MinimalTemplate: React.FC<MinimalTemplateProps> = ({ cv, className = '' })
|
||||
<section className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{skills.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-base font-serif mb-2">Skills</h3>
|
||||
<h3
|
||||
className="text-base font-serif mb-2"
|
||||
style={{ color: 'var(--theme-primary, #2563eb)' }}
|
||||
>
|
||||
Skills
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{skills.map((s) => (
|
||||
<span key={s.id} className="px-2 py-1 rounded bg-gray-100 text-gray-800 text-sm">
|
||||
<span
|
||||
key={s.id}
|
||||
className="px-2 py-1 rounded text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--theme-accent, #eff6ff)',
|
||||
color: 'var(--theme-primary, #2563eb)'
|
||||
}}
|
||||
>
|
||||
{s.name}{s.level ? ` — ${s.level}` : ''}
|
||||
</span>
|
||||
))}
|
||||
@ -94,10 +128,22 @@ const MinimalTemplate: React.FC<MinimalTemplateProps> = ({ cv, className = '' })
|
||||
)}
|
||||
{languages.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-base font-serif mb-2">Languages</h3>
|
||||
<h3
|
||||
className="text-base font-serif mb-2"
|
||||
style={{ color: 'var(--theme-primary, #2563eb)' }}
|
||||
>
|
||||
Languages
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{languages.map((l) => (
|
||||
<span key={l.id} className="px-2 py-1 rounded bg-gray-100 text-gray-800 text-sm">
|
||||
<span
|
||||
key={l.id}
|
||||
className="px-2 py-1 rounded text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--theme-secondary, #3b82f6)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
{l.name}{l.level ? ` — ${l.level}` : ''}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@ -9,8 +9,17 @@ const ModernTemplate: React.FC<ModernTemplateProps> = ({ cv, className = '' }) =
|
||||
const { personal, summary, work = [], education = [], skills = [], languages = [], certifications = [] } = cv;
|
||||
|
||||
return (
|
||||
<div className={`bg-white text-gray-800 max-w-4xl mx-auto ${className}`}>
|
||||
<div className="bg-blue-600 text-white p-6 flex items-center gap-4">
|
||||
<div
|
||||
className={`max-w-4xl mx-auto ${className}`}
|
||||
style={{
|
||||
backgroundColor: 'var(--theme-background, #ffffff)',
|
||||
color: 'var(--theme-text, #1f2937)'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="text-white p-6 flex items-center gap-4"
|
||||
style={{ backgroundColor: 'var(--theme-primary, #2563eb)' }}
|
||||
>
|
||||
{personal?.photoUrl && (
|
||||
<img
|
||||
src={personal.photoUrl}
|
||||
@ -37,14 +46,24 @@ const ModernTemplate: React.FC<ModernTemplateProps> = ({ cv, className = '' }) =
|
||||
<div className="md:col-span-2">
|
||||
{summary && (
|
||||
<section className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-700 mb-2">Professional Summary</h2>
|
||||
<h2
|
||||
className="text-xl font-semibold mb-2"
|
||||
style={{ color: 'var(--theme-primary, #2563eb)' }}
|
||||
>
|
||||
Professional Summary
|
||||
</h2>
|
||||
<div className="prose max-w-none" dangerouslySetInnerHTML={{ __html: sanitizeHtml(summary) }} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{work.length > 0 && (
|
||||
<section className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-700 mb-3">Work Experience</h2>
|
||||
<h2
|
||||
className="text-xl font-semibold mb-3"
|
||||
style={{ color: 'var(--theme-primary, #2563eb)' }}
|
||||
>
|
||||
Work Experience
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{work.map((job) => (
|
||||
<article key={job.id} className="">
|
||||
@ -73,7 +92,12 @@ const ModernTemplate: React.FC<ModernTemplateProps> = ({ cv, className = '' }) =
|
||||
|
||||
{education.length > 0 && (
|
||||
<section className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-700 mb-3">Education</h2>
|
||||
<h2
|
||||
className="text-xl font-semibold mb-3"
|
||||
style={{ color: 'var(--theme-primary, #2563eb)' }}
|
||||
>
|
||||
Education
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{education.map((ed) => (
|
||||
<article key={ed.id}>
|
||||
@ -94,10 +118,22 @@ const ModernTemplate: React.FC<ModernTemplateProps> = ({ cv, className = '' }) =
|
||||
<div>
|
||||
{skills.length > 0 && (
|
||||
<section className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-700 mb-2">Skills</h2>
|
||||
<h2
|
||||
className="text-lg font-semibold mb-2"
|
||||
style={{ color: 'var(--theme-primary, #2563eb)' }}
|
||||
>
|
||||
Skills
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{skills.map((s) => (
|
||||
<span key={s.id} className="px-2 py-1 rounded-full bg-blue-50 text-blue-700 text-sm">
|
||||
<span
|
||||
key={s.id}
|
||||
className="px-2 py-1 rounded-full text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--theme-accent, #eff6ff)',
|
||||
color: 'var(--theme-primary, #2563eb)'
|
||||
}}
|
||||
>
|
||||
{s.name}{s.level ? ` — ${s.level}` : ''}
|
||||
</span>
|
||||
))}
|
||||
@ -107,10 +143,22 @@ const ModernTemplate: React.FC<ModernTemplateProps> = ({ cv, className = '' }) =
|
||||
|
||||
{languages.length > 0 && (
|
||||
<section className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-700 mb-2">Languages</h2>
|
||||
<h2
|
||||
className="text-lg font-semibold mb-2"
|
||||
style={{ color: 'var(--theme-primary, #2563eb)' }}
|
||||
>
|
||||
Languages
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{languages.map((l) => (
|
||||
<span key={l.id} className="px-2 py-1 rounded-full bg-green-50 text-green-700 text-sm">
|
||||
<span
|
||||
key={l.id}
|
||||
className="px-2 py-1 rounded-full text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--theme-secondary, #3b82f6)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
{l.name}{l.level ? ` — ${l.level}` : ''}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@ -27,10 +27,22 @@ const TimelineTemplate: React.FC<TimelineTemplateProps> = ({ cv, className = ''
|
||||
const headline = (personal.extras && personal.extras[0]) ? personal.extras[0] : '';
|
||||
|
||||
return (
|
||||
<div className={`bg-white text-gray-900 max-w-4xl mx-auto px-6 py-6 ${className}`} style={{ fontFamily: 'Arial, sans-serif' }}>
|
||||
<div
|
||||
className={`max-w-4xl mx-auto px-6 py-6 ${className}`}
|
||||
style={{
|
||||
fontFamily: 'Arial, sans-serif',
|
||||
backgroundColor: 'var(--theme-background, #ffffff)',
|
||||
color: 'var(--theme-text, #1f2937)'
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-gray-800 uppercase">{escapeText(personal.firstName || '')} {escapeText(personal.lastName || '')}</h1>
|
||||
<h1
|
||||
className="text-3xl font-bold tracking-tight uppercase"
|
||||
style={{ color: 'var(--theme-primary, #2563eb)' }}
|
||||
>
|
||||
{escapeText(personal.firstName || '')} {escapeText(personal.lastName || '')}
|
||||
</h1>
|
||||
{headline && <div className="mt-1 text-sm tracking-widest text-gray-600 uppercase">{escapeText(headline)}</div>}
|
||||
<div className="mt-4 border-t border-gray-300" />
|
||||
</header>
|
||||
@ -41,7 +53,10 @@ const TimelineTemplate: React.FC<TimelineTemplateProps> = ({ cv, className = ''
|
||||
<aside className="space-y-6" style={{ width: '30%' }}>
|
||||
{/* Contact */}
|
||||
<section>
|
||||
<h2 className="text-xs font-bold tracking-widest text-gray-800 uppercase">
|
||||
<h2
|
||||
className="text-xs font-bold tracking-widest uppercase"
|
||||
style={{ color: 'var(--theme-primary, #2563eb)' }}
|
||||
>
|
||||
Contact
|
||||
</h2>
|
||||
<div className="mt-2 border-t border-gray-300" />
|
||||
@ -85,7 +100,10 @@ const TimelineTemplate: React.FC<TimelineTemplateProps> = ({ cv, className = ''
|
||||
{/* Skills */}
|
||||
{skills.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-xs font-bold tracking-widest text-gray-800 uppercase">
|
||||
<h2
|
||||
className="text-xs font-bold tracking-widest uppercase"
|
||||
style={{ color: 'var(--theme-primary, #2563eb)' }}
|
||||
>
|
||||
Skills
|
||||
</h2>
|
||||
<div className="mt-2 border-t border-gray-300" />
|
||||
@ -105,7 +123,10 @@ const TimelineTemplate: React.FC<TimelineTemplateProps> = ({ cv, className = ''
|
||||
{/* Languages */}
|
||||
{languages.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-xs font-bold tracking-widest text-gray-800 uppercase">
|
||||
<h2
|
||||
className="text-xs font-bold tracking-widest uppercase"
|
||||
style={{ color: 'var(--theme-primary, #2563eb)' }}
|
||||
>
|
||||
Languages
|
||||
</h2>
|
||||
<div className="mt-2 border-t border-gray-300" />
|
||||
@ -122,7 +143,10 @@ const TimelineTemplate: React.FC<TimelineTemplateProps> = ({ cv, className = ''
|
||||
|
||||
{/* Reference */}
|
||||
<section>
|
||||
<h2 className="text-xs font-bold tracking-widest text-gray-800 uppercase">
|
||||
<h2
|
||||
className="text-xs font-bold tracking-widest uppercase"
|
||||
style={{ color: 'var(--theme-primary, #2563eb)' }}
|
||||
>
|
||||
Reference
|
||||
</h2>
|
||||
<div className="mt-2 border-t border-gray-300" />
|
||||
@ -137,7 +161,12 @@ const TimelineTemplate: React.FC<TimelineTemplateProps> = ({ cv, className = ''
|
||||
{/* Certifications (optional sidebar) */}
|
||||
{certifications.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-bold tracking-widest text-gray-800 uppercase">Certifications</h2>
|
||||
<h2
|
||||
className="text-sm font-bold tracking-widest uppercase"
|
||||
style={{ color: 'var(--theme-primary, #2563eb)' }}
|
||||
>
|
||||
Certifications
|
||||
</h2>
|
||||
<ul className="mt-2 list-disc pl-5 text-gray-800 space-y-1">
|
||||
{certifications.map(c => (
|
||||
<li key={c.id}>{escapeText(c.name)}{c.issuer ? ` - ${escapeText(c.issuer)}` : ''}{c.date ? ` (${escapeText(c.date)})` : ''}</li>
|
||||
@ -157,7 +186,12 @@ const TimelineTemplate: React.FC<TimelineTemplateProps> = ({ cv, className = ''
|
||||
<section className="mb-8 relative">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="absolute -left-[21px] w-3 h-3 rounded-full bg-gray-800 border-2 border-white" />
|
||||
<h2 className="text-sm font-bold text-gray-800 uppercase tracking-widest">Profile</h2>
|
||||
<h2
|
||||
className="text-sm font-bold uppercase tracking-widest"
|
||||
style={{ color: 'var(--theme-primary, #2563eb)' }}
|
||||
>
|
||||
Profile
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-xs text-gray-700 leading-relaxed" dangerouslySetInnerHTML={{ __html: sanitizeHtml(summary) }} />
|
||||
</section>
|
||||
@ -168,7 +202,12 @@ const TimelineTemplate: React.FC<TimelineTemplateProps> = ({ cv, className = ''
|
||||
<section className="mb-8 relative">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="absolute -left-[21px] w-3 h-3 rounded-full bg-gray-800 border-2 border-white" />
|
||||
<h2 className="text-sm font-bold text-gray-800 uppercase tracking-widest">Work Experience</h2>
|
||||
<h2
|
||||
className="text-sm font-bold uppercase tracking-widest"
|
||||
style={{ color: 'var(--theme-primary, #2563eb)' }}
|
||||
>
|
||||
Work Experience
|
||||
</h2>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{work.map(job => (
|
||||
@ -203,7 +242,12 @@ const TimelineTemplate: React.FC<TimelineTemplateProps> = ({ cv, className = ''
|
||||
<section className="relative">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="absolute -left-[21px] w-3 h-3 rounded-full bg-gray-800 border-2 border-white" />
|
||||
<h2 className="text-sm font-bold text-gray-800 uppercase tracking-widest">Education</h2>
|
||||
<h2
|
||||
className="text-sm font-bold uppercase tracking-widest"
|
||||
style={{ color: 'var(--theme-primary, #2563eb)' }}
|
||||
>
|
||||
Education
|
||||
</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{education.map(ed => (
|
||||
|
||||
@ -9,6 +9,8 @@ import modernThumb from '../assets/templates/modern.svg';
|
||||
import minimalThumb from '../assets/templates/minimal.svg';
|
||||
import timelineThumb from '../assets/templates/timeline.svg';
|
||||
import type { CV } from '../schema/cvSchema';
|
||||
import type { ColorTheme } from '../types/colors';
|
||||
import { generateThumbnailDataUrl, type TemplateId } from '../utils/thumbnailGenerator';
|
||||
import React from 'react';
|
||||
|
||||
export type TemplateCategory = 'ATS' | 'Visual';
|
||||
@ -31,3 +33,25 @@ export const templatesRegistry: TemplateMeta[] = [
|
||||
];
|
||||
|
||||
export const templatesMap = Object.fromEntries(templatesRegistry.map(t => [t.id, t]));
|
||||
|
||||
// Function to get template thumbnail with color theme applied
|
||||
export const getTemplateThumbnail = (templateId: string, colorTheme: ColorTheme): string => {
|
||||
const template = templatesMap[templateId];
|
||||
if (!template) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// For now, use dynamic generation for supported templates
|
||||
const supportedTemplates: TemplateId[] = ['ats', 'classic', 'modern', 'minimal', 'timeline'];
|
||||
if (supportedTemplates.includes(templateId as TemplateId)) {
|
||||
try {
|
||||
return generateThumbnailDataUrl(templateId as TemplateId, colorTheme);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to generate thumbnail for ${templateId}:`, error);
|
||||
return template.thumbnail; // Fallback to static thumbnail
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to static thumbnail for unsupported templates
|
||||
return template.thumbnail;
|
||||
};
|
||||
157
cv-engine/src/types/colors.ts
Normal file
157
cv-engine/src/types/colors.ts
Normal file
@ -0,0 +1,157 @@
|
||||
export type ColorTheme =
|
||||
| 'gray'
|
||||
| 'dark-gray'
|
||||
| 'red'
|
||||
| 'orange'
|
||||
| 'yellow'
|
||||
| 'green'
|
||||
| 'blue'
|
||||
| 'indigo'
|
||||
| 'black'
|
||||
| null;
|
||||
|
||||
export interface ColorPalette {
|
||||
primary: string; // Main accent color
|
||||
secondary: string; // Secondary accent color
|
||||
accent: string; // Light accent/background
|
||||
text: string; // Primary text color
|
||||
textSecondary: string; // Secondary text color
|
||||
border: string; // Border color
|
||||
background: string; // Background color
|
||||
}
|
||||
|
||||
export const colorThemes: Record<Exclude<ColorTheme, null>, ColorPalette> = {
|
||||
'gray': {
|
||||
primary: '#6b7280',
|
||||
secondary: '#9ca3af',
|
||||
accent: '#f3f4f6',
|
||||
text: '#1f2937',
|
||||
textSecondary: '#6b7280',
|
||||
border: '#e5e7eb',
|
||||
background: '#ffffff'
|
||||
},
|
||||
'dark-gray': {
|
||||
primary: '#374151',
|
||||
secondary: '#6b7280',
|
||||
accent: '#f9fafb',
|
||||
text: '#111827',
|
||||
textSecondary: '#4b5563',
|
||||
border: '#d1d5db',
|
||||
background: '#ffffff'
|
||||
},
|
||||
'red': {
|
||||
primary: '#dc2626',
|
||||
secondary: '#ef4444',
|
||||
accent: '#fef2f2',
|
||||
text: '#1f2937',
|
||||
textSecondary: '#6b7280',
|
||||
border: '#fecaca',
|
||||
background: '#ffffff'
|
||||
},
|
||||
'orange': {
|
||||
primary: '#ea580c',
|
||||
secondary: '#f97316',
|
||||
accent: '#fff7ed',
|
||||
text: '#1f2937',
|
||||
textSecondary: '#6b7280',
|
||||
border: '#fed7aa',
|
||||
background: '#ffffff'
|
||||
},
|
||||
'yellow': {
|
||||
primary: '#d97706',
|
||||
secondary: '#f59e0b',
|
||||
accent: '#fffbeb',
|
||||
text: '#1f2937',
|
||||
textSecondary: '#6b7280',
|
||||
border: '#fde68a',
|
||||
background: '#ffffff'
|
||||
},
|
||||
'green': {
|
||||
primary: '#059669',
|
||||
secondary: '#10b981',
|
||||
accent: '#f0fdf4',
|
||||
text: '#1f2937',
|
||||
textSecondary: '#6b7280',
|
||||
border: '#bbf7d0',
|
||||
background: '#ffffff'
|
||||
},
|
||||
'blue': {
|
||||
primary: '#2563eb',
|
||||
secondary: '#3b82f6',
|
||||
accent: '#eff6ff',
|
||||
text: '#1f2937',
|
||||
textSecondary: '#6b7280',
|
||||
border: '#bfdbfe',
|
||||
background: '#ffffff'
|
||||
},
|
||||
'indigo': {
|
||||
primary: '#4f46e5',
|
||||
secondary: '#6366f1',
|
||||
accent: '#eef2ff',
|
||||
text: '#1f2937',
|
||||
textSecondary: '#6b7280',
|
||||
border: '#c7d2fe',
|
||||
background: '#ffffff'
|
||||
},
|
||||
'black': {
|
||||
primary: '#000000',
|
||||
secondary: '#1f2937',
|
||||
accent: '#f9fafb',
|
||||
text: '#000000',
|
||||
textSecondary: '#374151',
|
||||
border: '#e5e7eb',
|
||||
background: '#ffffff'
|
||||
}
|
||||
};
|
||||
|
||||
export const colorThemeLabels: Record<Exclude<ColorTheme, null>, string> = {
|
||||
'gray': 'Gray',
|
||||
'dark-gray': 'Dark Gray',
|
||||
'red': 'Red',
|
||||
'orange': 'Orange',
|
||||
'yellow': 'Yellow',
|
||||
'green': 'Green',
|
||||
'blue': 'Blue',
|
||||
'indigo': 'Indigo',
|
||||
'black': 'Black'
|
||||
};
|
||||
|
||||
export const getColorThemeLabel = (theme: ColorTheme): string => {
|
||||
if (theme === null) return 'Default';
|
||||
return colorThemeLabels[theme];
|
||||
};
|
||||
|
||||
export const colorThemeOrder: ColorTheme[] = [
|
||||
null, // Default option first
|
||||
'blue',
|
||||
'green',
|
||||
'indigo',
|
||||
'orange',
|
||||
'red',
|
||||
'yellow',
|
||||
'gray',
|
||||
'dark-gray',
|
||||
'black'
|
||||
];
|
||||
|
||||
// Convert color theme to CSS custom properties
|
||||
export const getThemeCSS = (theme: ColorTheme): React.CSSProperties => {
|
||||
if (theme === null) {
|
||||
// Return neutral/original colors for default theme
|
||||
return {
|
||||
'--theme-primary': '#1f2937', // gray-800 - neutral dark color
|
||||
'--theme-secondary': '#374151', // gray-700 - slightly lighter
|
||||
'--theme-accent': '#f9fafb', // gray-50 - very light background
|
||||
'--theme-text': '#1f2937', // gray-800 - dark text
|
||||
'--theme-background': '#ffffff', // white background
|
||||
} as React.CSSProperties;
|
||||
}
|
||||
const colors = colorThemes[theme];
|
||||
return {
|
||||
'--theme-primary': colors.primary,
|
||||
'--theme-secondary': colors.secondary,
|
||||
'--theme-accent': colors.accent,
|
||||
'--theme-text': colors.text,
|
||||
'--theme-background': colors.background,
|
||||
} as React.CSSProperties;
|
||||
};
|
||||
144
cv-engine/src/utils/thumbnailGenerator.ts
Normal file
144
cv-engine/src/utils/thumbnailGenerator.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import type { ColorTheme } from '../types/colors';
|
||||
import { colorThemes } from '../types/colors';
|
||||
|
||||
// Base SVG templates for each template type
|
||||
const baseSVGs = {
|
||||
ats: `<svg width="160" height="96" viewBox="0 0 160 96" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Background and frame -->
|
||||
<rect width="160" height="96" fill="{{background}}" rx="4"/>
|
||||
<rect x="8" y="8" width="144" height="80" fill="none" stroke="{{border}}" stroke-width="1" rx="3"/>
|
||||
|
||||
<!-- Header: name and contact -->
|
||||
<text x="80" y="18" text-anchor="middle" font-family="Times New Roman, serif" font-size="8" font-weight="bold" fill="{{text}}">FIRST LAST</text>
|
||||
<rect x="20" y="22" width="120" height="3" fill="{{textSecondary}}" rx="1"/>
|
||||
|
||||
<!-- Section: PROFESSIONAL EXPERIENCE -->
|
||||
<text x="16" y="32" font-family="Times New Roman, serif" font-size="4" font-weight="bold" fill="{{text}}">PROFESSIONAL EXPERIENCE</text>
|
||||
<line x1="16" y1="33" x2="144" y2="33" stroke="{{text}}" stroke-width="0.6"/>
|
||||
|
||||
<!-- Job 1 header -->
|
||||
<rect x="16" y="36" width="86" height="3" fill="{{text}}" rx="1"/>
|
||||
<rect x="104" y="36" width="40" height="3" fill="{{textSecondary}}" rx="1"/>
|
||||
<!-- Job 1 title -->
|
||||
<rect x="16" y="40" width="72" height="3" fill="{{textSecondary}}" rx="1"/>
|
||||
<!-- Job 1 bullets -->
|
||||
<circle cx="18" cy="46" r="0.6" fill="{{text}}"/>
|
||||
<rect x="20" y="45" width="120" height="2" fill="{{textSecondary}}" rx="1"/>
|
||||
<circle cx="18" cy="49" r="0.6" fill="{{text}}"/>
|
||||
<rect x="20" y="48" width="116" height="2" fill="{{textSecondary}}" rx="1"/>
|
||||
<circle cx="18" cy="52" r="0.6" fill="{{text}}"/>
|
||||
<rect x="20" y="51" width="118" height="2" fill="{{textSecondary}}" rx="1"/>
|
||||
|
||||
<!-- Job 2 header -->
|
||||
<rect x="16" y="56" width="80" height="3" fill="{{text}}" rx="1"/>
|
||||
<rect x="100" y="56" width="44" height="3" fill="{{textSecondary}}" rx="1"/>
|
||||
<!-- Job 2 title -->
|
||||
<rect x="16" y="60" width="68" height="3" fill="{{textSecondary}}" rx="1"/>
|
||||
<!-- Job 2 bullets -->
|
||||
<circle cx="18" cy="66" r="0.6" fill="{{text}}"/>
|
||||
<rect x="20" y="65" width="120" height="2" fill="{{textSecondary}}" rx="1"/>
|
||||
|
||||
<!-- Section: EDUCATION -->
|
||||
<text x="16" y="74" font-family="Times New Roman, serif" font-size="4" font-weight="bold" fill="{{text}}">EDUCATION</text>
|
||||
<line x1="16" y1="75" x2="144" y2="75" stroke="{{text}}" stroke-width="0.6"/>
|
||||
<rect x="16" y="78" width="88" height="3" fill="{{text}}" rx="1"/>
|
||||
<rect x="110" y="78" width="34" height="3" fill="{{textSecondary}}" rx="1"/>
|
||||
<rect x="16" y="82" width="120" height="2" fill="{{textSecondary}}" rx="1"/>
|
||||
|
||||
<!-- Section: SKILLS -->
|
||||
<text x="16" y="88" font-family="Times New Roman, serif" font-size="4" font-weight="bold" fill="{{text}}">SKILLS</text>
|
||||
<line x1="16" y1="89" x2="144" y2="89" stroke="{{text}}" stroke-width="0.6"/>
|
||||
<!-- Skills bullets row -->
|
||||
<circle cx="18" cy="93" r="0.6" fill="{{text}}"/>
|
||||
<rect x="20" y="92" width="36" height="2" fill="{{textSecondary}}" rx="1"/>
|
||||
<circle cx="60" cy="93" r="0.6" fill="{{text}}"/>
|
||||
<rect x="62" y="92" width="36" height="2" fill="{{textSecondary}}" rx="1"/>
|
||||
<circle cx="102" cy="93" r="0.6" fill="{{text}}"/>
|
||||
<rect x="104" y="92" width="36" height="2" fill="{{textSecondary}}" rx="1"/>
|
||||
</svg>`,
|
||||
|
||||
classic: `<svg width="160" height="96" viewBox="0 0 160 96" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="160" height="96" fill="white" rx="4"/>
|
||||
<rect x="8" y="8" width="144" height="80" fill="none" stroke="{{border}}" stroke-width="1" rx="2"/>
|
||||
<rect x="16" y="16" width="128" height="10" fill="{{primary}}" rx="1"/>
|
||||
<rect x="16" y="30" width="96" height="4" fill="{{text}}" rx="1"/>
|
||||
<rect x="16" y="38" width="80" height="4" fill="{{textSecondary}}" rx="1"/>
|
||||
<rect x="16" y="50" width="60" height="6" fill="{{secondary}}" rx="1"/>
|
||||
<rect x="16" y="60" width="120" height="3" fill="{{text}}" rx="1"/>
|
||||
<rect x="16" y="66" width="112" height="3" fill="{{text}}" rx="1"/>
|
||||
<rect x="16" y="72" width="104" height="3" fill="{{text}}" rx="1"/>
|
||||
<text x="80" y="88" text-anchor="middle" font-family="Arial, sans-serif" font-size="8" fill="{{textSecondary}}">Classic</text>
|
||||
</svg>`,
|
||||
|
||||
modern: `<svg width="160" height="96" viewBox="0 0 160 96" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="160" height="96" fill="white" rx="4"/>
|
||||
<rect x="8" y="8" width="144" height="80" fill="none" stroke="{{border}}" stroke-width="1" rx="2"/>
|
||||
<rect x="16" y="16" width="40" height="64" fill="{{accent}}" rx="2"/>
|
||||
<rect x="20" y="20" width="32" height="8" fill="{{primary}}" rx="1"/>
|
||||
<rect x="20" y="32" width="24" height="4" fill="{{text}}" rx="1"/>
|
||||
<rect x="20" y="40" width="28" height="4" fill="{{textSecondary}}" rx="1"/>
|
||||
<rect x="64" y="16" width="80" height="6" fill="{{text}}" rx="1"/>
|
||||
<rect x="64" y="26" width="72" height="4" fill="{{textSecondary}}" rx="1"/>
|
||||
<rect x="64" y="34" width="68" height="4" fill="{{textSecondary}}" rx="1"/>
|
||||
<rect x="64" y="46" width="80" height="6" fill="{{secondary}}" rx="1"/>
|
||||
<rect x="64" y="56" width="76" height="3" fill="{{text}}" rx="1"/>
|
||||
<rect x="64" y="62" width="72" height="3" fill="{{text}}" rx="1"/>
|
||||
<text x="80" y="88" text-anchor="middle" font-family="Arial, sans-serif" font-size="8" fill="{{textSecondary}}">Modern</text>
|
||||
</svg>`,
|
||||
|
||||
minimal: `<svg width="160" height="96" viewBox="0 0 160 96" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="160" height="96" fill="white" rx="4"/>
|
||||
<rect x="8" y="8" width="144" height="80" fill="none" stroke="{{border}}" stroke-width="1" rx="2"/>
|
||||
<rect x="16" y="16" width="128" height="8" fill="{{text}}" rx="1"/>
|
||||
<line x1="16" y1="30" x2="144" y2="30" stroke="{{primary}}" stroke-width="1"/>
|
||||
<rect x="16" y="36" width="96" height="4" fill="{{textSecondary}}" rx="1"/>
|
||||
<rect x="16" y="44" width="80" height="4" fill="{{textSecondary}}" rx="1"/>
|
||||
<line x1="16" y1="54" x2="144" y2="54" stroke="{{border}}" stroke-width="1"/>
|
||||
<rect x="16" y="60" width="120" height="3" fill="{{text}}" rx="1"/>
|
||||
<rect x="16" y="66" width="112" height="3" fill="{{text}}" rx="1"/>
|
||||
<rect x="16" y="72" width="104" height="3" fill="{{text}}" rx="1"/>
|
||||
<text x="80" y="88" text-anchor="middle" font-family="Arial, sans-serif" font-size="8" fill="{{textSecondary}}">Minimal</text>
|
||||
</svg>`,
|
||||
|
||||
timeline: `<svg width="160" height="96" viewBox="0 0 160 96" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="160" height="96" fill="white" rx="4"/>
|
||||
<rect x="8" y="8" width="144" height="80" fill="none" stroke="{{border}}" stroke-width="1" rx="2"/>
|
||||
<rect x="16" y="16" width="128" height="8" fill="{{primary}}" rx="1"/>
|
||||
<line x1="24" y1="32" x2="24" y2="76" stroke="{{secondary}}" stroke-width="2"/>
|
||||
<circle cx="24" cy="36" r="3" fill="{{primary}}"/>
|
||||
<rect x="32" y="34" width="80" height="4" fill="{{text}}" rx="1"/>
|
||||
<rect x="32" y="42" width="64" height="3" fill="{{textSecondary}}" rx="1"/>
|
||||
<circle cx="24" cy="54" r="3" fill="{{primary}}"/>
|
||||
<rect x="32" y="52" width="76" height="4" fill="{{text}}" rx="1"/>
|
||||
<rect x="32" y="60" width="60" height="3" fill="{{textSecondary}}" rx="1"/>
|
||||
<text x="80" y="88" text-anchor="middle" font-family="Arial, sans-serif" font-size="8" fill="{{textSecondary}}">Timeline</text>
|
||||
</svg>`
|
||||
};
|
||||
|
||||
export type TemplateId = keyof typeof baseSVGs;
|
||||
|
||||
export const generateThumbnail = (templateId: TemplateId, colorTheme: ColorTheme): string => {
|
||||
const baseSvg = baseSVGs[templateId];
|
||||
|
||||
if (!baseSvg) {
|
||||
throw new Error(`No base SVG found for template: ${templateId}`);
|
||||
}
|
||||
|
||||
// Use default blue theme for null colorTheme
|
||||
const palette = colorTheme ? colorThemes[colorTheme] : colorThemes.blue;
|
||||
|
||||
// Replace color placeholders with actual colors
|
||||
return baseSvg
|
||||
.replace(/\{\{primary\}\}/g, palette.primary)
|
||||
.replace(/\{\{secondary\}\}/g, palette.secondary)
|
||||
.replace(/\{\{accent\}\}/g, palette.accent)
|
||||
.replace(/\{\{text\}\}/g, palette.text)
|
||||
.replace(/\{\{textSecondary\}\}/g, palette.textSecondary)
|
||||
.replace(/\{\{border\}\}/g, palette.border)
|
||||
.replace(/\{\{background\}\}/g, palette.background);
|
||||
};
|
||||
|
||||
export const generateThumbnailDataUrl = (templateId: TemplateId, colorTheme: ColorTheme): string => {
|
||||
const svg = generateThumbnail(templateId, colorTheme);
|
||||
return `data:image/svg+xml;base64,${btoa(svg)}`;
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user