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:
geulah 2025-10-14 22:39:36 +01:00
parent 81712044c2
commit 3a9591fb48
16 changed files with 892 additions and 151 deletions

View File

@ -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">

View File

@ -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

View File

@ -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;
})()}
</>
)}

View File

@ -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

View 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;

View 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;

View File

@ -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

View File

@ -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);

View File

@ -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>

View File

@ -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">

View File

@ -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>
))}

View File

@ -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>
))}

View File

@ -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 => (

View File

@ -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;
};

View 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;
};

View 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)}`;
};