feat: Complete Smart Resume Formatter with R2 and Gemini AI integration
Some checks failed
Profile Linker Docker Build / Build and push Docker image (push) Failing after 3s
Some checks failed
Profile Linker Docker Build / Build and push Docker image (push) Failing after 3s
- Integrated Cloudflare R2 for template storage and converted file management - Added Google Gemini AI for resume parsing and HTML generation - Created backend API endpoints for templates, conversion, and history - Refactored frontend to use real API instead of mock data - Fixed Docker networking issues (IPv6/IPv4) for R2 connectivity - Added resumeService.ts for frontend API integration - Updated Vite configuration for proper asset serving in Docker - Successfully tested with 13 templates from R2 bucket
This commit is contained in:
389
frontend/App.tsx
389
frontend/App.tsx
@@ -1,103 +1,326 @@
|
||||
import React, { useState, useEffect, useCallback, ChangeEvent, useRef } from 'react';
|
||||
import { Building, UploadCloud, FileText, Download, Eye, LoaderCircle, CheckCircle, XCircle, Wand2, History, FileCode } from 'lucide-react';
|
||||
import {
|
||||
fetchTemplates,
|
||||
fetchTemplateContent,
|
||||
convertResume,
|
||||
fetchConversionHistory,
|
||||
type ConvertedFile
|
||||
} from './services/resumeService';
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import type { Person, NewPerson } from './types';
|
||||
import { getPeople, addPerson } from './services/apiService';
|
||||
import PersonList from './components/PersonList';
|
||||
import AddPersonForm from './components/AddPersonForm';
|
||||
import Modal from './components/Modal';
|
||||
import { PlusIcon, UsersIcon } from './components/Icons';
|
||||
// --- Type Definitions ---
|
||||
interface ConvertedFileWithContent extends ConvertedFile {
|
||||
template?: string;
|
||||
htmlContent?: string;
|
||||
pdfBlob?: Blob;
|
||||
}
|
||||
|
||||
const formatDate = (date: Date): string => new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeStyle: 'short' }).format(date);
|
||||
|
||||
// --- Main App Component ---
|
||||
const App: React.FC = () => {
|
||||
const [people, setPeople] = useState<Person[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||
|
||||
const fetchPeople = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await getPeople();
|
||||
setPeople(data);
|
||||
} catch (err) {
|
||||
setError('Failed to fetch people. Please try again later.');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// State
|
||||
const [templates, setTemplates] = useState<string[]>([]);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string>('');
|
||||
const [resumeFile, setResumeFile] = useState<File | null>(null);
|
||||
const [generatedHtml, setGeneratedHtml] = useState<string | null>(null);
|
||||
const [processingState, setProcessingState] = useState<'idle' | 'parsing' | 'generating' | 'done' | 'error'>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const [isLoadingTemplates, setIsLoadingTemplates] = useState<boolean>(true);
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState<boolean>(true);
|
||||
const [convertedFiles, setConvertedFiles] = useState<ConvertedFileWithContent[]>([]);
|
||||
const previewRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
fetchPeople();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// Fetch templates from R2
|
||||
setIsLoadingTemplates(true);
|
||||
fetchTemplates().then(data => {
|
||||
setTemplates(data);
|
||||
if (data.length > 0) setSelectedTemplate(data[0]);
|
||||
}).catch(error => {
|
||||
console.error('Error loading templates:', error);
|
||||
setErrorMessage('Failed to load templates from R2');
|
||||
}).finally(() => setIsLoadingTemplates(false));
|
||||
|
||||
// Fetch conversion history from R2
|
||||
setIsLoadingHistory(true);
|
||||
fetchConversionHistory().then(data => {
|
||||
setConvertedFiles(data as ConvertedFileWithContent[]);
|
||||
}).catch(error => {
|
||||
console.error('Error loading history:', error);
|
||||
}).finally(() => setIsLoadingHistory(false));
|
||||
}, []);
|
||||
|
||||
const handleAddPerson = async (newPerson: NewPerson) => {
|
||||
try {
|
||||
const addedPerson = await addPerson(newPerson);
|
||||
setPeople((prevPeople) => [...prevPeople, addedPerson]);
|
||||
setIsModalOpen(false);
|
||||
} catch (err) {
|
||||
setError('Failed to add person. Please try again.');
|
||||
console.error(err);
|
||||
// Handlers
|
||||
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) handleFileSelect(e.target.files[0]);
|
||||
};
|
||||
|
||||
const handleFileSelect = (file: File) => {
|
||||
const allowedTypes = ['application/pdf', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'];
|
||||
if (allowedTypes.includes(file.type)) {
|
||||
setResumeFile(file);
|
||||
setGeneratedHtml(null);
|
||||
setProcessingState('idle');
|
||||
} else {
|
||||
alert('Invalid file type. Please upload a PDF or DOCX file.');
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (isLoading) {
|
||||
return <p className="text-center text-gray-500 dark:text-gray-400 mt-8">Loading profiles...</p>;
|
||||
const handleGenerate = async () => {
|
||||
if (!resumeFile || !selectedTemplate) {
|
||||
setErrorMessage('Please select a template and upload a resume file.');
|
||||
setProcessingState('error');
|
||||
return;
|
||||
}
|
||||
if (error) {
|
||||
return <p className="text-center text-red-500 dark:text-red-400 mt-8">{error}</p>;
|
||||
}
|
||||
if (people.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-16 px-6 bg-white dark:bg-gray-800 rounded-lg shadow-md">
|
||||
<UsersIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-lg font-medium text-gray-900 dark:text-white">No profiles yet</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">Get started by adding a new profile.</p>
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
type="button"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
<PlusIcon className="-ml-1 mr-2 h-5 w-5" />
|
||||
Add Profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
setProcessingState('parsing');
|
||||
setErrorMessage('');
|
||||
setGeneratedHtml(null);
|
||||
|
||||
try {
|
||||
// Call backend API to convert resume
|
||||
const result = await convertResume(
|
||||
resumeFile,
|
||||
selectedTemplate,
|
||||
(message) => {
|
||||
// Update UI with progress messages
|
||||
if (message.includes('Uploading')) setProcessingState('parsing');
|
||||
else if (message.includes('Processing')) setProcessingState('generating');
|
||||
}
|
||||
);
|
||||
|
||||
setGeneratedHtml(result.html_content);
|
||||
|
||||
// Add to converted files list
|
||||
const newFile: ConvertedFileWithContent = {
|
||||
id: `${new Date().getTime()}-${resumeFile.name}`,
|
||||
name: resumeFile.name,
|
||||
url: result.html_url,
|
||||
size: new Blob([result.html_content]).size,
|
||||
lastModified: new Date().toISOString(),
|
||||
timestamp: new Date(),
|
||||
template: selectedTemplate,
|
||||
htmlContent: result.html_content,
|
||||
};
|
||||
setConvertedFiles(prev => [newFile, ...prev]);
|
||||
setProcessingState('done');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setErrorMessage(e instanceof Error ? e.message : 'An unknown error occurred during resume conversion.');
|
||||
setProcessingState('error');
|
||||
}
|
||||
return <PersonList people={people} />;
|
||||
};
|
||||
|
||||
const handleDownload = async (file: ConvertedFileWithContent, format: 'html' | 'pdf') => {
|
||||
const baseName = file.name.replace(/\.[^/.]+$/, "");
|
||||
const templateName = file.template || 'resume';
|
||||
const fileName = `${baseName}_${templateName}.${format}`;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.download = fileName;
|
||||
|
||||
// For HTML files
|
||||
if (format === 'html') {
|
||||
if (file.htmlContent) {
|
||||
// Download from in-memory content
|
||||
const blob = new Blob([file.htmlContent], { type: 'text/html' });
|
||||
link.href = URL.createObjectURL(blob);
|
||||
} else if (file.url) {
|
||||
// Download from R2 URL
|
||||
link.href = file.url;
|
||||
} else {
|
||||
console.error("No HTML content or URL to download.");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// PDF format - not supported yet in backend
|
||||
alert('PDF download will be available soon. For now, you can print the HTML preview to PDF.');
|
||||
return;
|
||||
}
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
if (file.htmlContent) {
|
||||
URL.revokeObjectURL(link.href);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewHistoryItem = async (file: ConvertedFileWithContent) => {
|
||||
if (file.htmlContent) {
|
||||
// Preview from in-memory content
|
||||
setGeneratedHtml(file.htmlContent);
|
||||
} else if (file.url) {
|
||||
// Fetch HTML content from R2 URL
|
||||
try {
|
||||
const response = await fetch(file.url);
|
||||
if (!response.ok) throw new Error('Failed to fetch file');
|
||||
const htmlContent = await response.text();
|
||||
setGeneratedHtml(htmlContent);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : 'Unknown error';
|
||||
setGeneratedHtml(`<p>Error loading preview: ${message}</p>`);
|
||||
}
|
||||
} else {
|
||||
setGeneratedHtml(`<p>Preview not available for this file.</p>`);
|
||||
}
|
||||
};
|
||||
|
||||
// UI Components
|
||||
const Card: React.FC<{ title: string; icon: React.ReactNode; children: React.ReactNode; className?: string }> = ({ title, icon, children, className }) => (
|
||||
<div className={`bg-white dark:bg-slate-800 rounded-lg shadow-md border border-slate-200 dark:border-slate-700 flex flex-col ${className}`}>
|
||||
<div className="flex items-center gap-3 p-4 border-b border-slate-200 dark:border-slate-700">
|
||||
{icon}
|
||||
<h2 className="text-lg font-bold text-slate-800 dark:text-slate-200">{title}</h2>
|
||||
</div>
|
||||
<div className="p-6 flex-grow">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const dropzoneProps = {
|
||||
onDragOver: (e: React.DragEvent) => e.preventDefault(),
|
||||
onDrop: (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
handleFileSelect(e.dataTransfer.files[0]);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const getStatusIndicator = () => {
|
||||
switch (processingState) {
|
||||
case 'parsing': return <><LoaderCircle className="w-4 h-4 animate-spin" /> Extracting resume text with AI...</>;
|
||||
case 'generating': return <><LoaderCircle className="w-4 h-4 animate-spin" /> Generating HTML & PDF with AI...</>;
|
||||
case 'done': return <><CheckCircle className="w-4 h-4 text-green-500" /> Generation complete!</>;
|
||||
case 'error': return <><XCircle className="w-4 h-4 text-red-500" /> {errorMessage}</>;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
const currentFile = convertedFiles.find(f => f.htmlContent === generatedHtml);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-800 dark:text-gray-200 font-sans">
|
||||
<div className="container mx-auto p-4 sm:p-6 lg:p-8">
|
||||
<header className="flex items-center justify-between mb-8 pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight text-gray-900 dark:text-white">
|
||||
Profile Linker
|
||||
</h1>
|
||||
{people.length > 0 && (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
|
||||
>
|
||||
<PlusIcon className="w-5 h-5 mr-2 -ml-1" />
|
||||
<span>Add Profile</span>
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 text-slate-800 dark:text-slate-200 p-4 sm:p-6 lg:p-8 font-sans">
|
||||
<header className="max-w-7xl mx-auto mb-10 text-center">
|
||||
<h1 className="text-4xl sm:text-5xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-primary to-sky-400 dark:to-sky-300 pb-2">
|
||||
Smart Resume Formatter
|
||||
</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-1 text-lg">Generate professional resumes with AI-powered template merging.</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{renderContent()}
|
||||
</main>
|
||||
</div>
|
||||
<main className="max-w-7xl mx-auto">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div className="space-y-8">
|
||||
<Card title="1. Select Template" icon={<Building className="w-6 h-6 text-primary" />}>
|
||||
{isLoadingTemplates ? <LoaderCircle className="w-6 h-6 animate-spin text-primary mx-auto" /> : (
|
||||
<select
|
||||
value={selectedTemplate}
|
||||
onChange={(e) => setSelectedTemplate(e.target.value)}
|
||||
className="w-full pl-3 pr-10 py-2 border border-slate-300 dark:border-slate-600 rounded-md bg-slate-50 dark:bg-slate-700 focus:ring-2 focus:ring-primary focus:border-primary outline-none"
|
||||
>
|
||||
{templates.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} title="Add New Profile">
|
||||
<AddPersonForm onSubmit={handleAddPerson} onCancel={() => setIsModalOpen(false)} />
|
||||
</Modal>
|
||||
<Card title="2. Upload Resume" icon={<UploadCloud className="w-6 h-6 text-primary" />}>
|
||||
<div className="flex flex-col items-center justify-center w-full gap-4" {...dropzoneProps}>
|
||||
<label htmlFor="file-upload" className="flex flex-col items-center justify-center w-full h-32 border-2 border-slate-300 dark:border-slate-600 border-dashed rounded-lg cursor-pointer bg-slate-50 dark:bg-slate-700/50 hover:bg-slate-100 dark:hover:bg-slate-700 transition">
|
||||
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
||||
{resumeFile ? (
|
||||
<>
|
||||
<FileText className="w-8 h-8 mb-2 text-green-500"/>
|
||||
<p className="font-semibold text-green-600 dark:text-green-400 truncate max-w-xs px-2" title={resumeFile.name}>{resumeFile.name}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UploadCloud className="w-8 h-8 mb-2 text-slate-500 dark:text-slate-400" />
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400"><span className="font-semibold">Click to upload</span> or drag & drop</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">PDF or DOCX</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<input id="file-upload" type="file" className="hidden" accept=".pdf,.docx" onChange={handleFileChange} />
|
||||
</label>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={!resumeFile || !selectedTemplate || ['parsing', 'generating'].includes(processingState)}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-primary text-white font-semibold rounded-md hover:bg-blue-600 transition-colors disabled:bg-slate-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Wand2 className="w-5 h-5" />
|
||||
Convert & Generate
|
||||
</button>
|
||||
<div className={`h-6 text-sm flex items-center gap-2 ${processingState === 'error' ? 'text-red-500' : 'text-slate-500 dark:text-slate-400'}`}>
|
||||
{getStatusIndicator()}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card title="3. Preview & Download" icon={<Eye className="w-6 h-6 text-primary" />} className="lg:row-span-2">
|
||||
<div className="w-full h-[500px] bg-slate-100 dark:bg-slate-900 rounded-md border border-slate-300 dark:border-slate-700 overflow-hidden">
|
||||
{generatedHtml ? (
|
||||
<iframe
|
||||
ref={previewRef}
|
||||
srcDoc={generatedHtml}
|
||||
title="Resume Preview"
|
||||
className="w-full h-full border-0"
|
||||
sandbox="allow-scripts"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center text-slate-500 dark:text-slate-400 p-4 text-center">
|
||||
<FileText className="w-12 h-12 mb-4 text-slate-400" />
|
||||
<p className="font-medium">Preview will appear here</p>
|
||||
<p className="text-sm">Generate a resume to see the result.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{generatedHtml && currentFile && (
|
||||
<div className="grid grid-cols-2 gap-4 mt-6">
|
||||
<button onClick={() => handleDownload(currentFile, 'html')} className="flex items-center justify-center gap-2 px-4 py-2 bg-slate-600 text-white font-semibold rounded-md hover:bg-slate-700 transition-colors">
|
||||
<Download className="w-5 h-5" /> Download HTML
|
||||
</button>
|
||||
<button onClick={() => handleDownload(currentFile, 'pdf')} className="flex items-center justify-center gap-2 px-4 py-2 bg-red-600 text-white font-semibold rounded-md hover:bg-red-700 transition-colors">
|
||||
<Download className="w-5 h-5" /> Download PDF
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mt-12">
|
||||
<Card title="Conversion History" icon={<History className="w-6 h-6 text-primary" />}>
|
||||
{isLoadingHistory ? (
|
||||
<div className="flex justify-center items-center py-4">
|
||||
<LoaderCircle className="w-6 h-6 animate-spin text-primary" />
|
||||
<span className="ml-3 text-slate-500 dark:text-slate-400">Loading history from R2...</span>
|
||||
</div>
|
||||
) : convertedFiles.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{convertedFiles.map((file) => (
|
||||
<div key={file.id} className="grid grid-cols-[1fr,auto] gap-4 items-center p-3 rounded-md hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors">
|
||||
<div className="truncate">
|
||||
<p className="font-semibold text-slate-700 dark:text-slate-300 truncate" title={file.name}>{file.name}</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Template: <span className="font-medium">{file.template}</span> • {formatDate(file.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button onClick={() => handlePreviewHistoryItem(file)} title="Preview" className="p-2 text-slate-500 hover:bg-slate-200 dark:hover:bg-slate-600 rounded-md transition-colors"><Eye className="w-5 h-5" /></button>
|
||||
<button onClick={() => handleDownload(file, 'html')} title="Download HTML" className="p-2 text-slate-500 hover:bg-slate-200 dark:hover:bg-slate-600 rounded-md transition-colors"><FileCode className="w-5 h-5" /></button>
|
||||
<button onClick={() => handleDownload(file, 'pdf')} title="Download PDF" className="p-2 text-slate-500 hover:bg-slate-200 dark:hover:bg-slate-600 rounded-md transition-colors"><FileText className="w-5 h-5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-slate-500 dark:text-slate-400 py-4">No converted resumes found in your bucket.</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user