skillsync-cli / frontend /src /components /pages /JobListPage.tsx
Mr-Haseeb786
Clean deployment build
88da18c
Raw
History Blame Contribute Delete
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;