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:
377
frontend/src/App.tsx
Normal file
377
frontend/src/App.tsx
Normal 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;
|
||||
90
frontend/src/components/HeaderBar.tsx
Normal file
90
frontend/src/components/HeaderBar.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user