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

- 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:
Laxmi Khilnani
2025-10-14 21:43:41 +05:30
parent ee030b70bc
commit cda50356b4
34 changed files with 2604 additions and 360 deletions

377
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,377 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
// FIX: Add imports for Gemini API
import { GoogleGenAI } from '@google/genai';
import { FolderData, ApiResponse } from './types';
import HeaderBar from './components/HeaderBar';
import FolderSection from './components/FolderSection';
import Spinner from './components/Spinner';
import FilePreviewModal from './components/FilePreviewModal';
import CreateFolderModal from './components/CreateFolderModal';
// FIX: Add more icons for new UI elements
import { ServerCrash, Files, Search, Sparkles, Image as ImageIcon, X, LoaderCircle } from 'lucide-react';
// Mock API data with varied dates to test sorting
const mockApiData: ApiResponse = {
"folders": [
{
"name": "reports",
"files": [
{ "name": "summary.pdf", "type": "pdf", "url": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf", "size": 13264, "lastModified": "2025-10-12T10:00:00Z" },
{ "name": "quarterly-review.html", "type": "html", "url": "https://raw.githack.com/bvaughn/infinite-scroll-example/master/index.html", "size": 65536, "lastModified": "2025-11-15T11:00:00Z" }
]
},
{
"name": "invoices",
"files": [
{ "name": "invoice-q3.pdf", "type": "pdf", "url": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf", "size": 204800, "lastModified": "2025-10-13T08:30:00Z" },
{ "name": "invoice-q4.pdf", "type": "pdf", "url": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf", "size": 215040, "lastModified": "2025-11-01T14:00:00Z" }
]
},
{
"name": "presentations",
"files": []
}
]
};
// Mock fetch function to simulate a real API call
const fetchFiles = (): Promise<ApiResponse> => {
return new Promise((resolve, reject) => {
setTimeout(() => {
// Uncomment to simulate a random API error
// if (Math.random() > 0.8) {
// reject(new Error("Failed to fetch files from the server."));
// return;
// }
resolve(mockApiData);
}, 1200);
});
};
// Mock API function for creating a folder
const createFolderAPI = (folderName: string): Promise<{ success: boolean; folder: FolderData }> => {
return new Promise((resolve) => {
setTimeout(() => {
const newFolder: FolderData = {
name: folderName,
files: [],
};
resolve({ success: true, folder: newFolder });
}, 800);
});
};
// FIX: Initialize Gemini AI client
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
const App: React.FC = () => {
const [folders, setFolders] = useState<FolderData[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState<string>('');
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [isCreateFolderModalOpen, setIsCreateFolderModalOpen] = useState(false);
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
// FIX: Add state for AI Vision feature
const [isVisionModalOpen, setIsVisionModalOpen] = useState(false);
const [visionImage, setVisionImage] = useState<File | null>(null);
const [generatedHtml, setGeneratedHtml] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState<boolean>(false);
const [visionError, setVisionError] = useState<string | null>(null);
const loadFiles = useCallback(() => {
setIsLoading(true);
setError(null);
fetchFiles()
.then(data => {
// Display all folders
setFolders(data.folders);
})
.catch(err => {
setError(err.message || 'An unknown error occurred.');
})
.finally(() => {
setIsLoading(false);
});
}, []);
useEffect(() => {
loadFiles();
}, [loadFiles]);
const handleCreateFolder = async (folderName: string) => {
if (folders.some(f => f.name.toLowerCase() === folderName.toLowerCase())) {
alert(`A folder named "${folderName}" already exists.`);
return;
}
setIsCreatingFolder(true);
try {
const response = await createFolderAPI(folderName);
if (response.success) {
setFolders(prevFolders => [response.folder, ...prevFolders]);
setIsCreateFolderModalOpen(false);
} else {
alert('Failed to create the folder. Please try again.');
}
} catch (err) {
alert('An error occurred while creating the folder.');
} finally {
setIsCreatingFolder(false);
}
};
// FIX: Add handler for image analysis using Gemini API
const handleAnalyzeImage = async () => {
if (!visionImage) {
setVisionError('Please select an image file.');
return;
}
setIsGenerating(true);
setGeneratedHtml(null);
setVisionError(null);
try {
const fileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve((reader.result as string).split(',')[1]);
reader.onerror = error => reject(error);
});
};
const base64Data = await fileToBase64(visionImage);
const imagePart = {
inlineData: {
data: base64Data,
mimeType: visionImage.type,
},
};
const textPart = {
text: "Analyze the following image, which could be a woodworking project. Describe the project in detail, then generate a complete, self-contained HTML document to showcase it. The HTML should be visually appealing with modern CSS styling (e.g., using flexbox or grid for layout). Include a title, a detailed description, and a list of potential materials or key features observed in the image. The entire response should be only the HTML code, starting with <!DOCTYPE html>.",
};
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: { parts: [imagePart, textPart] },
});
let htmlContent = response.text;
const match = htmlContent.match(/```html([\s\S]*)```/);
if (match) {
htmlContent = match[1].trim();
}
setGeneratedHtml(htmlContent);
} catch (e) {
console.error(e);
let message = 'Failed to generate HTML. Please try again.';
if (e instanceof Error) {
message = `${message} Error: ${e.message}`;
}
setVisionError(message);
} finally {
setIsGenerating(false);
}
};
// FIX: Add handler to close and reset vision modal state
const handleCloseVisionModal = () => {
setIsVisionModalOpen(false);
setVisionImage(null);
setGeneratedHtml(null);
setVisionError(null);
setIsGenerating(false);
};
const handleRefresh = () => {
loadFiles();
};
const handlePreview = (url: string) => {
setPreviewUrl(url);
};
const handleCloseModal = () => {
setPreviewUrl(null);
};
const totalFiles = useMemo(() => folders.reduce((sum, folder) => sum + folder.files.length, 0), [folders]);
const totalMatches = useMemo(() => {
if (!searchTerm) return totalFiles;
return folders.reduce((acc, folder) => {
const matches = folder.files.filter(file => file.name.toLowerCase().includes(searchTerm.toLowerCase()));
return acc + matches.length;
}, 0);
}, [folders, searchTerm, totalFiles]);
const renderContent = () => {
if (isLoading) {
return <Spinner />;
}
if (error) {
return (
<div className="text-center p-8 bg-red-100 dark:bg-red-900/30 border border-red-400 rounded-lg">
<ServerCrash className="w-12 h-12 text-red-500 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-red-700 dark:text-red-300">Oops! Something went wrong.</h3>
<p className="text-red-600 dark:text-red-400 mt-2">{error}</p>
</div>
);
}
if (totalFiles === 0) {
return (
<div className="text-center p-8 bg-slate-100 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 rounded-lg">
<Files className="w-12 h-12 text-slate-400 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-slate-700 dark:text-slate-300">No Files Found</h3>
<p className="text-slate-500 dark:text-slate-400 mt-2">There are currently no files available in your storage.</p>
</div>
);
}
if (totalMatches === 0 && searchTerm) {
return (
<div className="text-center p-8 bg-slate-100 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 rounded-lg">
<Search className="w-12 h-12 text-slate-400 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-slate-700 dark:text-slate-300">No Results Found</h3>
<p className="text-slate-500 dark:text-slate-400 mt-2">Your search for "{searchTerm}" did not match any files.</p>
</div>
);
}
return folders.map(folder => (
<FolderSection
key={folder.name}
folder={folder}
searchTerm={searchTerm}
onPreview={handlePreview}
/>
));
};
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 text-slate-800 dark:text-slate-200 p-4 sm:p-6 lg:p-8">
<header className="max-w-7xl mx-auto mb-8 text-center">
<h1 className="text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-primary to-sky-400 dark:to-sky-300 pb-2">
R2 File Dashboard
</h1>
<p className="text-slate-500 dark:text-slate-400 mt-1 text-lg">Browse, search, and manage your cloud files with ease.</p>
</header>
<main className="max-w-7xl mx-auto">
<HeaderBar
onSearch={setSearchTerm}
onRefresh={handleRefresh}
onCreateFolder={() => setIsCreateFolderModalOpen(true)}
onAnalyzeImage={() => setIsVisionModalOpen(true)}
isLoading={isLoading}
/>
<div className="transition-opacity duration-300">
{renderContent()}
</div>
{/* FIX: Corrected closing tag from </ai> to </main> */}
</main>
<FilePreviewModal url={previewUrl} onClose={handleCloseModal} />
<CreateFolderModal
isOpen={isCreateFolderModalOpen}
onClose={() => setIsCreateFolderModalOpen(false)}
onSubmit={handleCreateFolder}
isCreating={isCreatingFolder}
/>
{/* FIX: Add AI Vision Modal JSX directly here to avoid creating new files */}
{isVisionModalOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-60 flex justify-center items-center z-50 p-4"
onClick={handleCloseVisionModal}
>
<div
className="bg-white dark:bg-slate-800 rounded-lg shadow-2xl w-full max-w-4xl h-full max-h-[90vh] flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-center p-4 border-b border-slate-200 dark:border-slate-700">
<h2 className="text-lg font-semibold text-slate-800 dark:text-slate-200 flex items-center gap-2">
<Sparkles className="w-6 h-6 text-primary" />
Analyze Image with AI
</h2>
<button
onClick={handleCloseVisionModal}
className="p-1 rounded-full text-slate-500 hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
aria-label="Close modal"
>
<X className="w-6 h-6" />
</button>
</div>
<div className="flex-grow flex flex-col md:flex-row gap-4 p-4 overflow-auto">
<div className="w-full md:w-1/3 flex flex-col gap-4">
<h3 className="text-md font-semibold text-slate-700 dark:text-slate-300">1. Upload an Image</h3>
<div className="aspect-video bg-slate-100 dark:bg-slate-700/50 rounded-lg flex items-center justify-center border-2 border-dashed border-slate-300 dark:border-slate-600">
{visionImage ? (
<img src={URL.createObjectURL(visionImage)} alt="Preview" className="max-h-full max-w-full object-contain rounded-md" />
) : (
<div className="text-center text-slate-500 dark:text-slate-400 p-4">
<ImageIcon className="w-10 h-10 mx-auto mb-2" />
<p>Image preview will appear here.</p>
</div>
)}
</div>
<input
id="imageUpload"
type="file"
accept="image/*"
onChange={(e) => {
if (e.target.files && e.target.files[0]) {
setVisionImage(e.target.files[0]);
setGeneratedHtml(null);
setVisionError(null);
}
}}
className="block w-full text-sm text-slate-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-primary/10 file:text-primary hover:file:bg-primary/20"
/>
<button
type="button"
onClick={handleAnalyzeImage}
disabled={isGenerating || !visionImage}
className="flex items-center justify-center gap-2 w-full px-4 py-2 text-sm font-semibold bg-primary text-white rounded-md hover:bg-blue-600 transition-colors disabled:bg-slate-400 disabled:cursor-not-allowed"
>
{isGenerating ? (
<>
<LoaderCircle className="w-4 h-4 animate-spin" />
Generating...
</>
) : (
'Generate HTML from Image'
)}
</button>
{visionError && <p className="text-sm text-red-500 mt-2">{visionError}</p>}
</div>
<div className="w-full md:w-2/3 flex flex-col">
<h3 className="text-md font-semibold text-slate-700 dark:text-slate-300 mb-2">2. Generated HTML Preview</h3>
<div className="flex-grow bg-white border border-slate-300 dark:border-slate-600 rounded-lg overflow-hidden">
{generatedHtml ? (
<iframe
srcDoc={generatedHtml}
title="Generated HTML Preview"
className="w-full h-full border-0"
sandbox="allow-scripts"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-slate-500 dark:text-slate-400 p-4 text-center">
{isGenerating ? 'AI is working its magic...' : 'HTML preview will be displayed here after generation.'}
</div>
)}
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default App;

View File

@@ -0,0 +1,90 @@
import React, { useState, FormEvent } from 'react';
// FIX: Import Sparkles icon for AI feature button
import { Search, RefreshCw, X, FolderPlus, Sparkles } from 'lucide-react';
interface HeaderBarProps {
onSearch: (term: string) => void;
onRefresh: () => void;
onCreateFolder: () => void;
// FIX: Add prop for new AI feature
onAnalyzeImage: () => void;
isLoading: boolean;
}
const HeaderBar: React.FC<HeaderBarProps> = ({ onSearch, onRefresh, onCreateFolder, onAnalyzeImage, isLoading }) => {
const [inputValue, setInputValue] = useState('');
const handleSearch = (e: FormEvent) => {
e.preventDefault();
onSearch(inputValue);
};
const handleClear = () => {
setInputValue('');
onSearch('');
};
return (
<div className="bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm p-4 rounded-lg shadow-lg mb-8 sticky top-4 z-10 border border-slate-200 dark:border-slate-700">
<form onSubmit={handleSearch} className="flex flex-col md:flex-row items-center gap-4">
<div className="relative w-full">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400 pointer-events-none" />
<input
type="text"
placeholder="Search files by name..."
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
className="w-full pl-10 pr-10 py-2 border border-slate-300 dark:border-slate-600 rounded-md bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-200 focus:ring-2 focus:ring-primary focus:border-primary outline-none transition"
/>
{inputValue && (
<button
type="button"
onClick={handleClear}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-slate-500 hover:text-slate-800 dark:hover:text-slate-200"
aria-label="Clear search"
>
<X className="w-4 h-4" />
</button>
)}
</div>
<div className="flex items-center gap-2 w-full md:w-auto flex-shrink-0">
<button
type="button"
onClick={onCreateFolder}
className="flex items-center justify-center gap-2 w-full md:w-auto px-4 py-2 bg-green-500 text-white font-semibold rounded-md hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-500 transition-colors"
>
<FolderPlus className="w-5 h-5" />
<span>New Folder</span>
</button>
{/* FIX: Add new button for AI Vision feature */}
<button
type="button"
onClick={onAnalyzeImage}
className="flex items-center justify-center gap-2 w-full md:w-auto px-4 py-2 bg-purple-500 text-white font-semibold rounded-md hover:bg-purple-600 dark:bg-purple-600 dark:hover:bg-purple-500 transition-colors"
>
<Sparkles className="w-5 h-5" />
<span>Analyze Image</span>
</button>
<button
type="submit"
className="flex items-center justify-center gap-2 w-full md:w-auto px-4 py-2 bg-slate-700 text-white font-semibold rounded-md hover:bg-slate-800 dark:bg-slate-600 dark:hover:bg-slate-500 transition-colors"
>
<Search className="w-5 h-5" />
<span>Search</span>
</button>
<button
type="button"
onClick={onRefresh}
disabled={isLoading}
className="flex items-center justify-center gap-2 w-full md:w-auto px-4 py-2 bg-primary text-white font-semibold rounded-md hover:bg-blue-600 transition-colors disabled:bg-slate-400 disabled:cursor-not-allowed"
>
<RefreshCw className={`w-5 h-5 ${isLoading ? 'animate-spin' : ''}`} />
<span>Refresh</span>
</button>
</div>
</form>
</div>
);
};
export default HeaderBar;