Download frontend/src/components/pages/JobListPage.tsx from fyp-2-skillsync/skillsync-cli: direct link, hf CLI and curl.
- Browser
- Download file 20 kB
-
https://huggingface.co/spaces/fyp-2-skillsync/skillsync-cli/resolve/main/frontend/src/components/pages/JobListPage.tsx
- Command line
-
hf download hf://spaces/fyp-2-skillsync/skillsync-cli/frontend/src/components/pages/JobListPage.tsx
-
curl -L -o JobListPage.tsx https://huggingface.co/spaces/fyp-2-skillsync/skillsync-cli/resolve/main/frontend/src/components/pages/JobListPage.tsx
20 kB
| import React from "react"; | |
| import { motion, AnimatePresence } from "framer-motion"; | |
| import { | |
| Briefcase, | |
| Building2, | |
| MapPin, | |
| Clock, | |
| ChevronDown, | |
| Zap, | |
| Cpu, | |
| ExternalLink, | |
| } from "lucide-react"; | |
| import { Card, Button, Badge, Spinner } from "@/components/common"; | |
| import { useJobStore } from "@/store/appStore"; | |
| import { jobsAPI } from "@/utils/api"; | |
| import { downloadFile } from "@/utils/helpers"; | |
| import type { Job } from "@/types"; | |
| import { JobFitAnalyzer } from "../sections/JobFitAnalyzer"; | |
| interface JobListPageProps { | |
| onBack?: () => void; | |
| onHomeClick?: () => void; | |
| onSettingsClick?: () => void; | |
| } | |
| const JobListPage: React.FC<JobListPageProps> = ({ | |
| onBack, | |
| onHomeClick, | |
| onSettingsClick, | |
| }) => { | |
| const { | |
| jobs, | |
| setJobs, // Temporary | |
| selectedJobIds, | |
| toggleJobSelection, | |
| selectAllJobs, | |
| deselectAllJobs, | |
| isGenerating, | |
| setIsGenerating, | |
| batchProgress, | |
| setBatchProgress, | |
| addGeneratedCV, | |
| setNotification, | |
| tailoringMode, | |
| llmProvider, | |
| enableProfessionalSummary, | |
| includeCoverLetters, | |
| } = useJobStore(); | |
| const [expandedJobId, setExpandedJobId] = React.useState<string | null>(null); | |
| const [generatingJobId, setGeneratingJobId] = React.useState<string | null>( | |
| null | |
| ); | |
| const selectedCount = selectedJobIds.size; | |
| const allSelected = selectedCount === jobs.length && jobs.length > 0; | |
| const getLLMProviderLabel = (provider: string) => { | |
| const labels: Record<string, string> = { | |
| ollama: "Ollama", | |
| groq: "Groq", | |
| grok: "Grok", | |
| openai: "OpenAI", | |
| }; | |
| return labels[provider] || provider; | |
| }; | |
| const handleGenerateSingleCV = async (job: Job) => { | |
| setGeneratingJobId(job.id); | |
| try { | |
| const response = await jobsAPI.generateCV(job.id); | |
| if (response.success) { | |
| // Temporary if statement below | |
| if (response.job) { | |
| const updatedJobs = jobs.map((j) => | |
| j.id === job.id ? { ...j, ...response.job } : j | |
| ); | |
| setJobs(updatedJobs as Job[]); | |
| } | |
| addGeneratedCV({ | |
| jobId: job.id, | |
| jobTitle: job.title, | |
| company: job.company, | |
| filename: response.filename, | |
| url: response.url, | |
| coverLetterFilename: response.cover_letter_filename, | |
| coverLetterUrl: response.cover_letter_url, | |
| generatedAt: new Date().toISOString(), | |
| status: "success", | |
| }); | |
| const hasCoverLetter = !!response.cover_letter_filename; | |
| setNotification({ | |
| type: "success", | |
| message: hasCoverLetter | |
| ? `CV + Cover Letter generated for ${job.company} - ${job.title}` | |
| : `CV generated for ${job.company} - ${job.title}`, | |
| }); | |
| // Download the file | |
| const blob = await jobsAPI.downloadFile(response.filename); | |
| downloadFile(blob, response.filename); | |
| if (response.cover_letter_filename) { | |
| const coverLetterBlob = await jobsAPI.downloadFile( | |
| response.cover_letter_filename | |
| ); | |
| downloadFile(coverLetterBlob, response.cover_letter_filename); | |
| } | |
| } | |
| } catch (error) { | |
| setNotification({ | |
| type: "error", | |
| message: "Failed to generate CV", | |
| }); | |
| } finally { | |
| setGeneratingJobId(null); | |
| } | |
| }; | |
| const handleGenerateAllCVs = async () => { | |
| if (selectedCount === 0) { | |
| setNotification({ | |
| type: "error", | |
| message: "Please select at least one job", | |
| }); | |
| return; | |
| } | |
| setIsGenerating(true); | |
| setBatchProgress({ | |
| total: selectedCount, | |
| completed: 0, | |
| failed: 0, | |
| isProcessing: true, | |
| }); | |
| try { | |
| const jobIds = Array.from(selectedJobIds); | |
| const response = await jobsAPI.generateAllCVs(jobIds); | |
| setBatchProgress({ | |
| total: selectedCount, | |
| completed: response.successful.length, | |
| failed: response.failed.length, | |
| isProcessing: false, | |
| }); | |
| response.successful.forEach((cv) => { | |
| addGeneratedCV(cv); | |
| }); | |
| setNotification({ | |
| type: "success", | |
| message: `Generated ${response.successful.length} CVs successfully`, | |
| }); | |
| // Download the ZIP file | |
| const blob = await jobsAPI.downloadFile(response.zip_filename); | |
| downloadFile(blob, response.zip_filename); | |
| } catch (error) { | |
| setNotification({ | |
| type: "error", | |
| message: "Batch generation failed", | |
| }); | |
| } finally { | |
| setIsGenerating(false); | |
| } | |
| }; | |
| if (jobs.length === 0) { | |
| return ( | |
| <div className="min-h-screen bg-gradient-saas flex items-center justify-center px-4"> | |
| <Card className="text-center max-w-md border-emerald-500/20 shadow-[0_0_60px_rgba(34,197,94,0.08)]"> | |
| <div className="w-16 h-16 bg-emerald-500/20 rounded-lg flex items-center justify-center mx-auto mb-4"> | |
| <Briefcase className="w-8 h-8 text-emerald-400" /> | |
| </div> | |
| <h2 className="text-2xl font-bold text-white mb-2">No Jobs Found</h2> | |
| <p className="text-slate-400 mb-6"> | |
| Search for jobs to get started with CV generation | |
| </p> | |
| <div className="flex gap-3"> | |
| <Button variant="secondary" onClick={onHomeClick} fullWidth> | |
| Home | |
| </Button> | |
| <Button onClick={onBack} fullWidth> | |
| Back to Search | |
| </Button> | |
| </div> | |
| </Card> | |
| </div> | |
| ); | |
| } | |
| // TODO: Add website link that opens up the company's own website to apply to instead of applying on linkedIn | |
| // BUG: The Selenium Scrapper data does not show up correctly sometims on the frontend | |
| return ( | |
| <div className="min-h-screen bg-gradient-saas px-4 sm:px-6 lg:px-8 py-8 max-w-7xl mx-auto"> | |
| {/* Header */} | |
| <motion.div | |
| initial={{ opacity: 0, y: -20 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| className="mb-8" | |
| > | |
| <div className="flex items-center justify-between mb-4"> | |
| <Button variant="ghost" size="sm" onClick={onHomeClick}> | |
| Home | |
| </Button> | |
| <div className="flex items-center gap-3"> | |
| <div className="text-xs uppercase tracking-[0.3em] text-emerald-200/60"> | |
| Tailored CVs | |
| </div> | |
| {tailoringMode === "api" && ( | |
| <div className="flex items-center gap-1 px-2 py-1 bg-emerald-500/15 rounded-full border border-emerald-500/30"> | |
| <Cpu className="w-3 h-3 text-emerald-400" /> | |
| <span className="text-xs text-emerald-300 font-medium"> | |
| {getLLMProviderLabel(llmProvider || "groq")} | |
| </span> | |
| </div> | |
| )} | |
| <div className="px-2 py-1 rounded-full border text-xs font-medium border-emerald-500/20 text-emerald-200/75"> | |
| Summary: {enableProfessionalSummary ? "On" : "Off"} | |
| </div> | |
| <div className="px-2 py-1 rounded-full border text-xs font-medium border-emerald-500/20 text-emerald-200/75"> | |
| Cover Letters: {includeCoverLetters ? "On" : "Off"} | |
| </div> | |
| {onSettingsClick && ( | |
| <Button variant="ghost" size="sm" onClick={onSettingsClick}> | |
| Settings | |
| </Button> | |
| )} | |
| </div> | |
| </div> | |
| <div className="flex items-center justify-between mb-6"> | |
| <div> | |
| <h1 className="text-3xl font-bold text-white mb-2"> | |
| Found {jobs.length} Jobs | |
| </h1> | |
| <p className="text-slate-400"> | |
| Select jobs to generate tailored CVs | |
| </p> | |
| </div> | |
| <Button variant="secondary" onClick={onBack}> | |
| Back to Search | |
| </Button> | |
| </div> | |
| {/* Selection Controls */} | |
| <Card className="flex items-center justify-between border-emerald-500/10 shadow-[0_0_40px_rgba(34,197,94,0.08)]"> | |
| <div className="flex items-center gap-4"> | |
| <input | |
| type="checkbox" | |
| checked={allSelected} | |
| onChange={() => { | |
| if (allSelected) { | |
| deselectAllJobs(); | |
| } else { | |
| selectAllJobs(); | |
| } | |
| }} | |
| className="w-5 h-5 rounded border-slate-600 cursor-pointer" | |
| /> | |
| <span className="text-slate-300"> | |
| {selectedCount > 0 ? ( | |
| <> | |
| <strong>{selectedCount}</strong> job | |
| {selectedCount !== 1 ? "s" : ""} selected | |
| </> | |
| ) : ( | |
| "Select jobs to generate CVs" | |
| )} | |
| </span> | |
| </div> | |
| {selectedCount > 0 && ( | |
| <Button | |
| size="sm" | |
| loading={isGenerating} | |
| onClick={handleGenerateAllCVs} | |
| icon={<Zap className="w-4 h-4" />} | |
| > | |
| Generate All ({selectedCount}) | |
| </Button> | |
| )} | |
| </Card> | |
| </motion.div> | |
| {/* Jobs List */} | |
| <motion.div | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| transition={{ staggerChildren: 0.1 }} | |
| className="space-y-4" | |
| > | |
| {jobs.map((job, index) => { | |
| // --- SAFETIES ADDED HERE --- | |
| const matchedSkills = job.matched_skills || []; | |
| const matchedCategories = job.matched_categories || {}; | |
| const isRawData = matchedSkills.length === 0; | |
| console.log(job); | |
| return ( | |
| <motion.div | |
| key={job.id || index} // Fallback to index if id is missing in raw data | |
| initial={{ opacity: 0, y: 20 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ delay: index * 0.05 }} | |
| > | |
| <Card hover noPadding className="overflow-hidden"> | |
| <div className="p-6"> | |
| {/* ========================================== */} | |
| {/* TOP SECTION: Header, Meta, and Action Footer */} | |
| {/* ========================================== */} | |
| <div className="flex items-start gap-4"> | |
| <input | |
| type="checkbox" | |
| checked={selectedJobIds.has(job.id)} | |
| onChange={() => toggleJobSelection(job.id)} | |
| className="w-5 h-5 rounded border-slate-600 cursor-pointer mt-1 shrink-0" | |
| /> | |
| <div className="flex-1 min-w-0 w-full"> | |
| {/* Top Row: Title, Company & Expand Chevron */} | |
| <div className="flex items-start justify-between mb-2"> | |
| <div className="pr-4"> | |
| <h3 className="text-lg font-semibold text-white truncate"> | |
| {job.title} | |
| </h3> | |
| <p className="text-emerald-400 font-medium"> | |
| {job.company} | |
| </p> | |
| </div> | |
| <motion.button | |
| onClick={() => | |
| setExpandedJobId( | |
| expandedJobId === job.id ? null : job.id | |
| ) | |
| } | |
| animate={{ | |
| rotate: expandedJobId === job.id ? 180 : 0, | |
| }} | |
| className="text-slate-400 hover:text-white transition-colors shrink-0 p-1" | |
| > | |
| <ChevronDown className="w-5 h-5" /> | |
| </motion.button> | |
| </div> | |
| {/* Job Meta with Safeties for missing data */} | |
| <div className="flex flex-wrap gap-4 text-sm text-slate-400 mb-3"> | |
| {job.location && ( | |
| <div className="flex items-center gap-1"> | |
| <MapPin className="w-4 h-4" /> | |
| {job.location} | |
| </div> | |
| )} | |
| <div className="flex items-center gap-1"> | |
| <Clock className="w-4 h-4" /> | |
| {job.posted_days_ago} days ago | |
| </div> | |
| <div className="flex items-center gap-1"> | |
| <Building2 className="w-4 h-4" /> | |
| {job.source} | |
| </div> | |
| {job.link && ( | |
| <motion.a | |
| href={job.link} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| whileHover={{ x: 2 }} | |
| className="flex items-center gap-1 text-emerald-400 hover:text-emerald-300 transition-colors font-medium" | |
| > | |
| View on LinkedIn | |
| <ExternalLink className="w-4 h-4" /> | |
| </motion.a> | |
| )} | |
| </div> | |
| {/* Skills Preview with Safeties */} | |
| <div className="flex flex-wrap gap-2 mb-4"> | |
| {matchedSkills.slice(0, 3).map((skill) => ( | |
| <Badge key={skill} variant="primary" size="sm"> | |
| {skill} | |
| </Badge> | |
| ))} | |
| {matchedSkills.length > 3 && ( | |
| <Badge variant="secondary" size="sm"> | |
| +{matchedSkills.length - 3} more | |
| </Badge> | |
| )} | |
| {/* Show a badge indicating this is fast/raw data */} | |
| {isRawData && ( | |
| <Badge | |
| variant="secondary" | |
| size="sm" | |
| className="opacity-70 border-slate-600" | |
| > | |
| Ready for AI CV Tailoring | |
| </Badge> | |
| )} | |
| </div> | |
| {/* --- FULL-WIDTH ACTION FOOTER --- */} | |
| <div className="w-full pt-4 mt-2 border-t border-slate-700/50"> | |
| <div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4 w-full"> | |
| {/* Left Side: Analyzer Pill & Skills Drawer */} | |
| <div className="flex-1 w-full min-w-0"> | |
| <JobFitAnalyzer jobId={job.id} /> | |
| </div> | |
| {/* Right Side: Generate CV Button */} | |
| <div className="shrink-0 mt-1"> | |
| <Button | |
| size="sm" | |
| loading={generatingJobId === job.id} | |
| onClick={() => handleGenerateSingleCV(job)} | |
| disabled={generatingJobId !== null} | |
| className="bg-emerald-500 hover:bg-emerald-600 text-white shadow-lg shadow-emerald-500/20" | |
| > | |
| Generate CV | |
| </Button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {/* ========================================== */} | |
| {/* BOTTOM SECTION: Collapsible Job Description */} | |
| {/* ========================================== */} | |
| <AnimatePresence> | |
| {expandedJobId === job.id && ( | |
| <motion.div | |
| initial={{ opacity: 0, height: 0 }} | |
| animate={{ opacity: 1, height: "auto" }} | |
| exit={{ opacity: 0, height: 0 }} | |
| className="border-t border-slate-700 pt-4 mt-6" | |
| > | |
| {/* LinkedIn Link Button */} | |
| {(job.apply_url || job.link) && ( | |
| <motion.a | |
| href={job.apply_url} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| whileHover={{ scale: 1.02 }} | |
| className="inline-flex items-center gap-2 px-4 py-2 bg-emerald-500/15 hover:bg-emerald-500/25 border border-emerald-500/30 hover:border-emerald-500/50 text-emerald-300 hover:text-emerald-200 rounded-lg transition-all duration-200 font-medium text-sm mb-4" | |
| > | |
| <ExternalLink className="w-4 h-4" /> | |
| Visit Job Post Page | |
| </motion.a> | |
| )} | |
| {/* Skill Categories with Safeties */} | |
| {Object.keys(matchedCategories).length > 0 && ( | |
| <div className="mb-4"> | |
| <h4 className="font-semibold text-white mb-3"> | |
| Matched Skills | |
| </h4> | |
| <div className="space-y-3"> | |
| {Object.entries(matchedCategories).map( | |
| ([category, skills]) => ( | |
| <div key={category}> | |
| <p className="text-sm font-medium text-slate-300 mb-2"> | |
| {category} | |
| </p> | |
| <div className="flex flex-wrap gap-2"> | |
| {(skills as string[]).map((skill) => ( | |
| <Badge | |
| key={skill} | |
| variant="success" | |
| size="sm" | |
| > | |
| {skill} | |
| </Badge> | |
| ))} | |
| </div> | |
| </div> | |
| ) | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| {/* Job Description Preview */} | |
| <div> | |
| <h4 className="font-semibold text-white mb-2"> | |
| Job Description | |
| </h4> | |
| <p className="text-sm text-slate-300 whitespace-pre-wrap"> | |
| {job.description || "No description provided."} | |
| </p> | |
| </div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| </div> | |
| </Card> | |
| </motion.div> | |
| ); | |
| })} | |
| </motion.div> | |
| {/* Batch Progress */} | |
| {batchProgress.isProcessing && ( | |
| <div className="fixed bottom-4 right-4"> | |
| <Card glow> | |
| <div className="flex items-center gap-3"> | |
| <Spinner size="sm" /> | |
| <div> | |
| <p className="text-sm font-medium text-white"> | |
| {batchProgress.completed} / {batchProgress.total} CVs | |
| Generated | |
| </p> | |
| {batchProgress.current && ( | |
| <p className="text-xs text-slate-400"> | |
| {batchProgress.current} | |
| </p> | |
| )} | |
| </div> | |
| </div> | |
| </Card> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| export default JobListPage; | |