tailwind transition

This commit is contained in:
DaKheera47 2025-12-14 16:48:07 +00:00
parent 38ff39b7f3
commit 29a8b0543a
29 changed files with 2736 additions and 1486 deletions

View File

@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" class="dark">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />

File diff suppressed because it is too large Load Diff

View File

@ -18,14 +18,28 @@
"pipeline:run": "tsx src/server/pipeline/run.ts" "pipeline:run": "tsx src/server/pipeline/run.ts"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"better-sqlite3": "^11.6.0", "better-sqlite3": "^11.6.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"drizzle-orm": "^0.38.2", "drizzle-orm": "^0.38.2",
"express": "^4.18.2", "express": "^4.18.2",
"lucide-react": "^0.561.0",
"next-themes": "^0.4.6",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.4.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@types/better-sqlite3": "^7.6.8", "@types/better-sqlite3": "^7.6.8",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
@ -33,11 +47,14 @@
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.22",
"concurrently": "^9.1.0", "concurrently": "^9.1.0",
"drizzle-kit": "^0.30.1", "drizzle-kit": "^0.30.1",
"postcss": "^8.5.6",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^7.0.2", "react-router-dom": "^7.0.2",
"tailwindcss": "^4.1.18",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.0.3" "vite": "^6.0.3"

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
"@tailwindcss/postcss": {},
autoprefixer: {},
},
};

View File

@ -2,13 +2,15 @@
* Main App component. * Main App component.
*/ */
import React, { useState, useEffect, useCallback } from 'react'; import React, { useCallback, useEffect, useState } from "react";
import type { Job, JobStatus } from '../shared/types'; import { toast } from "sonner";
import { Header, Stats, JobList, ToastContainer, Toast, PipelineProgress } from './components';
import * as api from './api'; import { Toaster } from "@/components/ui/sonner";
import type { Job, JobStatus } from "../shared/types";
import { Header, JobList, PipelineProgress, Stats } from "./components";
import * as api from "./api";
export const App: React.FC = () => { export const App: React.FC = () => {
// State
const [jobs, setJobs] = useState<Job[]>([]); const [jobs, setJobs] = useState<Job[]>([]);
const [stats, setStats] = useState<Record<JobStatus, number>>({ const [stats, setStats] = useState<Record<JobStatus, number>>({
discovered: 0, discovered: 0,
@ -22,19 +24,7 @@ export const App: React.FC = () => {
const [isPipelineRunning, setIsPipelineRunning] = useState(false); const [isPipelineRunning, setIsPipelineRunning] = useState(false);
const [processingJobId, setProcessingJobId] = useState<string | null>(null); const [processingJobId, setProcessingJobId] = useState<string | null>(null);
const [isProcessingAll, setIsProcessingAll] = useState(false); const [isProcessingAll, setIsProcessingAll] = useState(false);
const [toasts, setToasts] = useState<Toast[]>([]);
// Toast helpers
const addToast = useCallback((message: string, type: Toast['type']) => {
const id = Math.random().toString(36).slice(2);
setToasts(prev => [...prev, { id, message, type }]);
}, []);
const dismissToast = useCallback((id: string) => {
setToasts(prev => prev.filter(t => t.id !== id));
}, []);
// Load jobs
const loadJobs = useCallback(async () => { const loadJobs = useCallback(async () => {
try { try {
setIsLoading(true); setIsLoading(true);
@ -42,14 +32,13 @@ export const App: React.FC = () => {
setJobs(data.jobs); setJobs(data.jobs);
setStats(data.byStatus); setStats(data.byStatus);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Failed to load jobs'; const message = error instanceof Error ? error.message : "Failed to load jobs";
addToast(message, 'error'); toast.error(message);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [addToast]); }, []);
// Check pipeline status
const checkPipelineStatus = useCallback(async () => { const checkPipelineStatus = useCallback(async () => {
try { try {
const status = await api.getPipelineStatus(); const status = await api.getPipelineStatus();
@ -59,12 +48,10 @@ export const App: React.FC = () => {
} }
}, []); }, []);
// Initial load
useEffect(() => { useEffect(() => {
loadJobs(); loadJobs();
checkPipelineStatus(); checkPipelineStatus();
// Poll for updates
const interval = setInterval(() => { const interval = setInterval(() => {
loadJobs(); loadJobs();
checkPipelineStatus(); checkPipelineStatus();
@ -73,103 +60,105 @@ export const App: React.FC = () => {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [loadJobs, checkPipelineStatus]); }, [loadJobs, checkPipelineStatus]);
// Run pipeline
const handleRunPipeline = async () => { const handleRunPipeline = async () => {
try { try {
setIsPipelineRunning(true); setIsPipelineRunning(true);
await api.runPipeline(); await api.runPipeline();
addToast('Pipeline started! This may take a few minutes.', 'info'); toast.message("Pipeline started", { description: "This may take a few minutes." });
// Poll more frequently while running
const pollInterval = setInterval(async () => { const pollInterval = setInterval(async () => {
const status = await api.getPipelineStatus(); try {
if (!status.isRunning) { const status = await api.getPipelineStatus();
clearInterval(pollInterval); if (!status.isRunning) {
setIsPipelineRunning(false); clearInterval(pollInterval);
loadJobs(); setIsPipelineRunning(false);
addToast('Pipeline completed!', 'success'); await loadJobs();
toast.success("Pipeline completed");
}
} catch {
// Ignore errors
} }
}, 5000); }, 5000);
} catch (error) { } catch (error) {
setIsPipelineRunning(false); setIsPipelineRunning(false);
const message = error instanceof Error ? error.message : 'Failed to start pipeline'; const message = error instanceof Error ? error.message : "Failed to start pipeline";
addToast(message, 'error'); toast.error(message);
} }
}; };
// Process single job
const handleProcess = async (jobId: string) => { const handleProcess = async (jobId: string) => {
try { try {
setProcessingJobId(jobId); setProcessingJobId(jobId);
await api.processJob(jobId); await api.processJob(jobId);
addToast('Resume generated successfully!', 'success'); toast.success("Resume generated successfully");
loadJobs(); await loadJobs();
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Failed to process job'; const message = error instanceof Error ? error.message : "Failed to process job";
addToast(message, 'error'); toast.error(message);
} finally { } finally {
setProcessingJobId(null); setProcessingJobId(null);
} }
}; };
// Mark as applied
const handleApply = async (jobId: string) => { const handleApply = async (jobId: string) => {
try { try {
await api.markAsApplied(jobId); await api.markAsApplied(jobId);
addToast('Marked as applied! ✅', 'success'); toast.success("Marked as applied");
loadJobs(); await loadJobs();
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Failed to mark as applied'; const message = error instanceof Error ? error.message : "Failed to mark as applied";
addToast(message, 'error'); toast.error(message);
} }
}; };
// Reject job
const handleReject = async (jobId: string) => { const handleReject = async (jobId: string) => {
try { try {
await api.rejectJob(jobId); await api.rejectJob(jobId);
addToast('Job skipped', 'info'); toast.message("Job skipped");
loadJobs(); await loadJobs();
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Failed to reject job'; const message = error instanceof Error ? error.message : "Failed to reject job";
addToast(message, 'error'); toast.error(message);
} }
}; };
// Clear database
const handleClearDatabase = async () => { const handleClearDatabase = async () => {
try { try {
const result = await api.clearDatabase(); const result = await api.clearDatabase();
addToast(`Database cleared! Deleted ${result.jobsDeleted} jobs.`, 'success'); toast.success("Database cleared", { description: `Deleted ${result.jobsDeleted} jobs.` });
loadJobs(); await loadJobs();
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Failed to clear database'; const message = error instanceof Error ? error.message : "Failed to clear database";
addToast(message, 'error'); toast.error(message);
} }
}; };
// Process all discovered jobs
const handleProcessAll = async () => { const handleProcessAll = async () => {
try { try {
setIsProcessingAll(true); setIsProcessingAll(true);
const result = await api.processAllDiscovered(); const result = await api.processAllDiscovered();
addToast(`Processing ${result.count} jobs in background...`, 'info'); toast.message("Processing jobs", { description: `Processing ${result.count} jobs in background...` });
// Poll for completion
const pollInterval = setInterval(async () => { const pollInterval = setInterval(async () => {
await loadJobs(); try {
const currentStats = await api.getJobs(); const data = await api.getJobs();
const stillDiscovered = currentStats.byStatus.discovered + currentStats.byStatus.processing; setJobs(data.jobs);
if (stillDiscovered === 0) { setStats(data.byStatus);
clearInterval(pollInterval);
setIsProcessingAll(false); const stillDiscovered = data.byStatus.discovered + data.byStatus.processing;
addToast('All jobs processed!', 'success'); if (stillDiscovered === 0) {
clearInterval(pollInterval);
setIsProcessingAll(false);
toast.success("All jobs processed");
}
} catch {
// Ignore errors
} }
}, 3000); }, 3000);
} catch (error) { } catch (error) {
setIsProcessingAll(false); setIsProcessingAll(false);
const message = error instanceof Error ? error.message : 'Failed to process jobs'; const message = error instanceof Error ? error.message : "Failed to process jobs";
addToast(message, 'error'); toast.error(message);
} }
}; };
@ -183,11 +172,9 @@ export const App: React.FC = () => {
isLoading={isLoading} isLoading={isLoading}
/> />
<main className="container" style={{ paddingBottom: 'var(--space-12)' }}> <main className="container mx-auto max-w-7xl space-y-6 px-4 py-6 pb-12">
<PipelineProgress isRunning={isPipelineRunning} /> <PipelineProgress isRunning={isPipelineRunning} />
<Stats stats={stats} /> <Stats stats={stats} />
<JobList <JobList
jobs={jobs} jobs={jobs}
onApply={handleApply} onApply={handleApply}
@ -199,7 +186,8 @@ export const App: React.FC = () => {
/> />
</main> </main>
<ToastContainer toasts={toasts} onDismiss={dismissToast} /> <Toaster position="bottom-right" richColors closeButton />
</> </>
); );
}; };

View File

@ -2,8 +2,21 @@
* Header component with logo and pipeline trigger. * Header component with logo and pipeline trigger.
*/ */
import React from 'react'; import React from "react";
import { RocketIcon, PlayIcon, RefreshIcon, TrashIcon } from './Icons'; import { Loader2, Play, RefreshCcw, Rocket, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
interface HeaderProps { interface HeaderProps {
onRunPipeline: () => void; onRunPipeline: () => void;
@ -20,62 +33,68 @@ export const Header: React.FC<HeaderProps> = ({
isPipelineRunning, isPipelineRunning,
isLoading, isLoading,
}) => { }) => {
const handleClearDatabase = () => {
if (window.confirm('Are you sure you want to clear all jobs from the database? This cannot be undone.')) {
onClearDatabase();
}
};
return ( return (
<header className="header"> <header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container"> <div className="container mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4">
<div className="header-content"> <div className="flex items-center gap-3">
<div className="logo"> <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm">
<div className="logo-icon"> <Rocket className="h-5 w-5" />
<RocketIcon size={20} />
</div>
<span className="logo-text">Job Ops</span>
</div> </div>
<div className="leading-tight">
<div className="flex gap-3"> <div className="text-sm font-semibold tracking-tight">Job Ops</div>
<button <div className="text-xs text-muted-foreground">Orchestrator</div>
className="btn btn-ghost"
onClick={handleClearDatabase}
disabled={isLoading}
title="Clear all jobs from database"
>
<TrashIcon size={16} />
Clear DB
</button>
<button
className="btn btn-ghost"
onClick={onRefresh}
disabled={isLoading}
>
<RefreshIcon size={16} />
Refresh
</button>
<button
className="btn btn-primary"
onClick={onRunPipeline}
disabled={isPipelineRunning}
>
{isPipelineRunning ? (
<>
<div className="spinner" />
Running...
</>
) : (
<>
<PlayIcon size={16} />
Run Pipeline
</>
)}
</button>
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-2">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={isLoading}
title="Clear all jobs from database"
>
<Trash2 className="h-4 w-4" />
<span className="hidden sm:inline">Clear DB</span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear all jobs?</AlertDialogTitle>
<AlertDialogDescription>
This deletes all jobs from the database. This action cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onClearDatabase}>
Clear database
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
<RefreshCcw className="h-4 w-4" />
<span className="hidden sm:inline">Refresh</span>
</Button>
<Button size="sm" onClick={onRunPipeline} disabled={isPipelineRunning}>
{isPipelineRunning ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Running...
</>
) : (
<>
<Play className="h-4 w-4" />
Run Pipeline
</>
)}
</Button>
</div>
</div> </div>
</header> </header>
); );

View File

@ -1,127 +0,0 @@
/**
* SVG Icons as React components.
*/
import React from 'react';
interface IconProps {
className?: string;
size?: number;
}
export const BriefcaseIcon: React.FC<IconProps> = ({ className, size = 16 }) => (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"/>
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>
</svg>
);
export const MapPinIcon: React.FC<IconProps> = ({ className, size = 16 }) => (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
);
export const CalendarIcon: React.FC<IconProps> = ({ className, size = 16 }) => (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
);
export const DollarIcon: React.FC<IconProps> = ({ className, size = 16 }) => (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="1" x2="12" y2="23"/>
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
</svg>
);
export const GraduationCapIcon: React.FC<IconProps> = ({ className, size = 16 }) => (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 10v6M2 10l10-5 10 5-10 5z"/>
<path d="M6 12v5c3 3 9 3 12 0v-5"/>
</svg>
);
export const ExternalLinkIcon: React.FC<IconProps> = ({ className, size = 16 }) => (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
);
export const FileTextIcon: React.FC<IconProps> = ({ className, size = 16 }) => (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
);
export const CheckCircleIcon: React.FC<IconProps> = ({ className, size = 16 }) => (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
);
export const XCircleIcon: React.FC<IconProps> = ({ className, size = 16 }) => (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
);
export const RefreshIcon: React.FC<IconProps> = ({ className, size = 16 }) => (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="23 4 23 10 17 10"/>
<polyline points="1 20 1 14 7 14"/>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
</svg>
);
export const PlayIcon: React.FC<IconProps> = ({ className, size = 16 }) => (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
);
export const DownloadIcon: React.FC<IconProps> = ({ className, size = 16 }) => (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
);
export const XIcon: React.FC<IconProps> = ({ className, size = 16 }) => (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
);
export const RocketIcon: React.FC<IconProps> = ({ className, size = 16 }) => (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/>
<path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/>
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/>
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>
</svg>
);
export const TrashIcon: React.FC<IconProps> = ({ className, size = 16 }) => (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
<line x1="10" y1="11" x2="10" y2="17"/>
<line x1="14" y1="11" x2="14" y2="17"/>
</svg>
);

View File

@ -2,21 +2,25 @@
* Individual job card component. * Individual job card component.
*/ */
import React from 'react'; import React from "react";
import type { Job } from '../../shared/types';
import { StatusBadge } from './StatusBadge';
import { ScoreIndicator } from './ScoreIndicator';
import { import {
MapPinIcon, Calendar,
CalendarIcon, CheckCircle2,
DollarIcon, DollarSign,
GraduationCapIcon, Download,
ExternalLinkIcon, ExternalLink,
DownloadIcon, GraduationCap,
CheckCircleIcon, Loader2,
XCircleIcon, MapPin,
RefreshIcon, RefreshCcw,
} from './Icons'; XCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import type { Job } from "../../shared/types";
import { ScoreIndicator } from "./ScoreIndicator";
import { StatusBadge } from "./StatusBadge";
interface JobCardProps { interface JobCardProps {
job: Job; job: Job;
@ -26,6 +30,21 @@ interface JobCardProps {
isProcessing: boolean; isProcessing: boolean;
} }
const formatDate = (dateStr: string | null) => {
if (!dateStr) return null;
try {
return new Date(dateStr).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
});
} catch {
return dateStr;
}
};
const safeFilenamePart = (value: string) => value.replace(/[^a-z0-9]/gi, "_");
export const JobCard: React.FC<JobCardProps> = ({ export const JobCard: React.FC<JobCardProps> = ({
job, job,
onApply, onApply,
@ -33,155 +52,133 @@ export const JobCard: React.FC<JobCardProps> = ({
onProcess, onProcess,
isProcessing, isProcessing,
}) => { }) => {
const formatDate = (dateStr: string | null) => {
if (!dateStr) return null;
try {
return new Date(dateStr).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
} catch {
return dateStr;
}
};
const hasPdf = !!job.pdfPath; const hasPdf = !!job.pdfPath;
const canApply = job.status === 'ready'; const canApply = job.status === "ready";
const canProcess = job.status === 'discovered'; const canProcess = job.status === "discovered";
const canReject = ['discovered', 'ready'].includes(job.status); const canReject = ["discovered", "ready"].includes(job.status);
const jobLink = job.applicationLink || job.jobUrl;
const pdfHref = `/pdfs/resume_${job.id}.pdf`;
const deadline = formatDate(job.deadline);
return ( return (
<article className="job-card"> <Card>
<div className="job-card-header"> <CardHeader className="space-y-3">
<div> <div className="flex flex-col justify-between gap-3 sm:flex-row sm:items-start">
<h3 className="job-title">{job.title}</h3> <div className="min-w-0 space-y-1">
<p className="job-employer">{job.employer}</p> <CardTitle className="text-base leading-tight">{job.title}</CardTitle>
</div> <div className="text-sm text-muted-foreground">{job.employer}</div>
<div className="flex items-center gap-3"> </div>
<ScoreIndicator score={job.suitabilityScore} />
<StatusBadge status={job.status} />
</div>
</div>
<div className="job-meta"> <div className="flex flex-wrap items-center gap-3">
{job.location && ( <ScoreIndicator score={job.suitabilityScore} />
<span className="job-meta-item"> <StatusBadge status={job.status} />
<MapPinIcon /> </div>
{job.location} </div>
</span>
)}
{job.deadline && (
<span className="job-meta-item">
<CalendarIcon />
{job.deadline}
</span>
)}
{job.salary && (
<span className="job-meta-item">
<DollarIcon />
{job.salary}
</span>
)}
{job.degreeRequired && (
<span className="job-meta-item">
<GraduationCapIcon />
{job.degreeRequired}
</span>
)}
</div>
{job.suitabilityReason && ( <div className="flex flex-wrap gap-x-4 gap-y-2 text-sm text-muted-foreground">
<p style={{ {job.location && (
marginTop: 'var(--space-3)', <span className="flex items-center gap-1">
fontSize: '0.8125rem', <MapPin className="h-4 w-4" />
color: 'var(--color-text-secondary)', {job.location}
fontStyle: 'italic', </span>
}}> )}
"{job.suitabilityReason}" {deadline && (
</p> <span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{deadline}
</span>
)}
{job.salary && (
<span className="flex items-center gap-1">
<DollarSign className="h-4 w-4" />
{job.salary}
</span>
)}
{job.degreeRequired && (
<span className="flex items-center gap-1">
<GraduationCap className="h-4 w-4" />
{job.degreeRequired}
</span>
)}
</div>
</CardHeader>
{(job.suitabilityReason || canApply || canReject || canProcess || hasPdf) && (
<CardContent className="space-y-3">
{job.suitabilityReason && (
<p className="text-sm italic text-muted-foreground">
&quot;{job.suitabilityReason}&quot;
</p>
)}
</CardContent>
)} )}
<div className="job-actions"> <CardFooter className="flex flex-wrap gap-2">
{/* View job posting */} <Button asChild variant="outline" size="sm">
<a <a href={jobLink} target="_blank" rel="noopener noreferrer">
href={job.applicationLink || job.jobUrl} <ExternalLink className="mr-2 h-4 w-4" />
target="_blank" View Job
rel="noopener noreferrer"
className="btn btn-ghost"
>
<ExternalLinkIcon size={16} />
View Job
</a>
{/* View PDF in browser */}
{hasPdf && (
<a
href={`/pdfs/resume_${job.id}.pdf`}
target="_blank"
rel="noopener noreferrer"
className="btn btn-ghost"
>
<ExternalLinkIcon size={16} />
View PDF
</a> </a>
</Button>
{hasPdf && (
<Button asChild variant="outline" size="sm">
<a href={pdfHref} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
View PDF
</a>
</Button>
)} )}
{/* Download PDF */}
{hasPdf && ( {hasPdf && (
<a <Button asChild variant="outline" size="sm">
href={`/pdfs/resume_${job.id}.pdf`} <a
download={`resume_${job.employer.replace(/[^a-z0-9]/gi, '_')}_${job.title.replace(/[^a-z0-9]/gi, '_')}.pdf`} href={pdfHref}
className="btn btn-ghost" download={`resume_${safeFilenamePart(job.employer)}_${safeFilenamePart(job.title)}.pdf`}
> >
<DownloadIcon size={16} /> <Download className="mr-2 h-4 w-4" />
Download Download
</a> </a>
</Button>
)} )}
{/* Process job */}
{canProcess && ( {canProcess && (
<button <Button
className="btn btn-ghost" variant="secondary"
size="sm"
onClick={() => onProcess(job.id)} onClick={() => onProcess(job.id)}
disabled={isProcessing} disabled={isProcessing}
> >
{isProcessing ? ( {isProcessing ? (
<> <>
<div className="spinner" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing... Processing...
</> </>
) : ( ) : (
<> <>
<RefreshIcon size={16} /> <RefreshCcw className="mr-2 h-4 w-4" />
Generate Resume Generate Resume
</> </>
)} )}
</button> </Button>
)} )}
{/* Reject */}
{canReject && ( {canReject && (
<button <Button variant="destructive" size="sm" onClick={() => onReject(job.id)}>
className="btn btn-danger" <XCircle className="mr-2 h-4 w-4" />
onClick={() => onReject(job.id)}
>
<XCircleIcon size={16} />
Skip Skip
</button> </Button>
)} )}
{/* Mark as applied */}
{canApply && ( {canApply && (
<button <Button size="sm" onClick={() => onApply(job.id)}>
className="btn btn-success" <CheckCircle2 className="mr-2 h-4 w-4" />
onClick={() => onApply(job.id)}
>
<CheckCircleIcon size={16} />
Mark Applied Mark Applied
</button> </Button>
)} )}
</div> </CardFooter>
</article> </Card>
); );
}; };

View File

@ -2,10 +2,14 @@
* Job list with filtering tabs. * Job list with filtering tabs.
*/ */
import React, { useState } from 'react'; import React, { useMemo, useState } from "react";
import type { Job, JobStatus } from '../../shared/types'; import { Loader2, RefreshCcw } from "lucide-react";
import { JobCard } from './JobCard';
import { RefreshIcon } from './Icons'; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { Job, JobStatus } from "../../shared/types";
import { JobCard } from "./JobCard";
interface JobListProps { interface JobListProps {
jobs: Job[]; jobs: Job[];
@ -17,15 +21,22 @@ interface JobListProps {
isProcessingAll: boolean; isProcessingAll: boolean;
} }
type FilterTab = 'ready' | 'discovered' | 'applied' | 'all'; type FilterTab = "ready" | "discovered" | "applied" | "all";
const tabs: Array<{ id: FilterTab; label: string; statuses: JobStatus[] }> = [ const tabs: Array<{ id: FilterTab; label: string; statuses: JobStatus[] }> = [
{ id: 'ready', label: '✨ Ready to Apply', statuses: ['ready'] }, { id: "ready", label: "Ready", statuses: ["ready"] },
{ id: 'discovered', label: '🔍 Discovered', statuses: ['discovered', 'processing'] }, { id: "discovered", label: "Discovered", statuses: ["discovered", "processing"] },
{ id: 'applied', label: '✅ Applied', statuses: ['applied'] }, { id: "applied", label: "Applied", statuses: ["applied"] },
{ id: 'all', label: '📋 All Jobs', statuses: [] }, { id: "all", label: "All Jobs", statuses: [] },
]; ];
const emptyStateCopy: Record<FilterTab, string> = {
ready: "Run the pipeline to discover and process new jobs.",
discovered: "All discovered jobs have been processed.",
applied: "You haven't applied to any jobs yet.",
all: "No jobs in the system yet. Run the pipeline to get started!",
};
export const JobList: React.FC<JobListProps> = ({ export const JobList: React.FC<JobListProps> = ({
jobs, jobs,
onApply, onApply,
@ -35,86 +46,104 @@ export const JobList: React.FC<JobListProps> = ({
processingJobId, processingJobId,
isProcessingAll, isProcessingAll,
}) => { }) => {
const [activeTab, setActiveTab] = useState<FilterTab>('ready'); const [activeTab, setActiveTab] = useState<FilterTab>("ready");
const filteredJobs = React.useMemo(() => { const counts = useMemo(() => {
const tab = tabs.find(t => t.id === activeTab); const byTab: Record<FilterTab, number> = {
if (!tab || tab.statuses.length === 0) { ready: 0,
return jobs; discovered: 0,
applied: 0,
all: jobs.length,
};
for (const job of jobs) {
if (job.status === "ready") byTab.ready += 1;
if (job.status === "applied") byTab.applied += 1;
if (job.status === "discovered" || job.status === "processing") byTab.discovered += 1;
} }
return jobs.filter(job => tab.statuses.includes(job.status));
}, [jobs, activeTab]);
const discoveredCount = jobs.filter(j => j.status === 'discovered').length; return byTab;
}, [jobs]);
const jobsForTab = useMemo(() => {
const map = new Map<FilterTab, Job[]>();
for (const tab of tabs) {
if (tab.statuses.length === 0) {
map.set(tab.id, jobs);
} else {
map.set(tab.id, jobs.filter((job) => tab.statuses.includes(job.status)));
}
}
return map;
}, [jobs]);
return ( return (
<div> <Tabs
<div className="tabs" style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-4)' }}> value={activeTab}
<div style={{ display: 'flex', gap: 'var(--space-2)', flex: 1 }}> onValueChange={(value) => setActiveTab(value as FilterTab)}
{tabs.map(tab => { className="space-y-4"
const count = tab.statuses.length === 0 >
? jobs.length <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
: jobs.filter(j => tab.statuses.includes(j.status)).length; <TabsList className="w-full sm:w-auto">
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id} className="flex-1 sm:flex-none">
{tab.label}
<span className="ml-2 text-xs tabular-nums text-muted-foreground">
({counts[tab.id]})
</span>
</TabsTrigger>
))}
</TabsList>
return ( {activeTab === "discovered" && counts.discovered > 0 && (
<button <Button onClick={onProcessAll} disabled={isProcessingAll} size="sm">
key={tab.id}
className={`tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label} ({count})
</button>
);
})}
</div>
{activeTab === 'discovered' && discoveredCount > 0 && (
<button
className="btn btn-primary"
onClick={onProcessAll}
disabled={isProcessingAll}
style={{ marginLeft: 'auto' }}
>
{isProcessingAll ? ( {isProcessingAll ? (
<> <>
<div className="spinner" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing... Processing...
</> </>
) : ( ) : (
<> <>
<RefreshIcon size={16} /> <RefreshCcw className="mr-2 h-4 w-4" />
Process All ({discoveredCount}) Process All ({counts.discovered})
</> </>
)} )}
</button> </Button>
)} )}
</div> </div>
{filteredJobs.length === 0 ? ( {tabs.map((tab) => {
<div className="empty-state"> const filteredJobs = jobsForTab.get(tab.id) ?? [];
<div className="empty-state-icon">📭</div>
<h3 className="empty-state-title">No jobs found</h3> return (
<p> <TabsContent key={tab.id} value={tab.id} className="space-y-4">
{activeTab === 'ready' && 'Run the pipeline to discover and process new jobs.'} {filteredJobs.length === 0 ? (
{activeTab === 'discovered' && 'All discovered jobs have been processed.'} <Card className="border-dashed bg-muted/20">
{activeTab === 'applied' && "You haven't applied to any jobs yet."} <CardContent className="flex flex-col items-center justify-center gap-2 py-12 text-center">
{activeTab === 'all' && 'No jobs in the system yet. Run the pipeline to get started!'} <div className="text-base font-semibold">No jobs found</div>
</p> <p className="max-w-xl text-sm text-muted-foreground">{emptyStateCopy[tab.id]}</p>
</div> </CardContent>
) : ( </Card>
<div className="job-list"> ) : (
{filteredJobs.map(job => ( <div className="grid gap-4">
<JobCard {filteredJobs.map((job) => (
key={job.id} <JobCard
job={job} key={job.id}
onApply={onApply} job={job}
onReject={onReject} onApply={onApply}
onProcess={onProcess} onReject={onReject}
isProcessing={processingJobId === job.id} onProcess={onProcess}
/> isProcessing={processingJobId === job.id}
))} />
</div> ))}
)} </div>
</div> )}
</TabsContent>
);
})}
</Tabs>
); );
}; };

View File

@ -2,10 +2,17 @@
* Live pipeline progress display component. * Live pipeline progress display component.
*/ */
import React, { useEffect, useState } from 'react'; import React, { useEffect, useMemo, useState } from "react";
import { Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
interface PipelineProgress { interface PipelineProgress {
step: 'idle' | 'crawling' | 'importing' | 'scoring' | 'processing' | 'completed' | 'failed'; step: "idle" | "crawling" | "importing" | "scoring" | "processing" | "completed" | "failed";
message: string; message: string;
detail?: string; detail?: string;
crawlingListPagesProcessed: number; crawlingListPagesProcessed: number;
@ -14,7 +21,7 @@ interface PipelineProgress {
crawlingJobPagesEnqueued: number; crawlingJobPagesEnqueued: number;
crawlingJobPagesSkipped: number; crawlingJobPagesSkipped: number;
crawlingJobPagesProcessed: number; crawlingJobPagesProcessed: number;
crawlingPhase?: 'list' | 'job'; crawlingPhase?: "list" | "job";
crawlingCurrentUrl?: string; crawlingCurrentUrl?: string;
jobsDiscovered: number; jobsDiscovered: number;
jobsScored: number; jobsScored: number;
@ -34,26 +41,28 @@ interface PipelineProgressProps {
isRunning: boolean; isRunning: boolean;
} }
const stepLabels: Record<PipelineProgress['step'], string> = { const stepLabels: Record<PipelineProgress["step"], string> = {
idle: 'Ready', idle: "Ready",
crawling: 'Crawling Jobs', crawling: "Crawling",
importing: 'Importing', importing: "Importing",
scoring: 'Scoring Jobs', scoring: "Scoring",
processing: 'Generating Resumes', processing: "Processing",
completed: 'Complete', completed: "Complete",
failed: 'Failed', failed: "Failed",
}; };
const stepColors: Record<PipelineProgress['step'], string> = { const stepBadgeClasses: Record<PipelineProgress["step"], string> = {
idle: 'var(--color-muted)', idle: "bg-muted text-muted-foreground border-border",
crawling: 'var(--color-info)', crawling: "bg-sky-500/10 text-sky-400 border-sky-500/20",
importing: 'var(--color-info)', importing: "bg-sky-500/10 text-sky-400 border-sky-500/20",
scoring: 'var(--color-warning)', scoring: "bg-amber-500/10 text-amber-400 border-amber-500/20",
processing: 'var(--color-primary-500)', processing: "bg-primary/10 text-primary border-primary/20",
completed: 'var(--color-success)', completed: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
failed: 'var(--color-error)', failed: "bg-destructive/10 text-destructive border-destructive/20",
}; };
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
export const PipelineProgress: React.FC<PipelineProgressProps> = ({ isRunning }) => { export const PipelineProgress: React.FC<PipelineProgressProps> = ({ isRunning }) => {
const [progress, setProgress] = useState<PipelineProgress | null>(null); const [progress, setProgress] = useState<PipelineProgress | null>(null);
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
@ -61,11 +70,11 @@ export const PipelineProgress: React.FC<PipelineProgressProps> = ({ isRunning })
useEffect(() => { useEffect(() => {
if (!isRunning) { if (!isRunning) {
setProgress(null); setProgress(null);
setIsConnected(false);
return; return;
} }
// Connect to SSE endpoint const eventSource = new EventSource("/api/pipeline/progress");
const eventSource = new EventSource('/api/pipeline/progress');
eventSource.onopen = () => { eventSource.onopen = () => {
setIsConnected(true); setIsConnected(true);
@ -73,8 +82,7 @@ export const PipelineProgress: React.FC<PipelineProgressProps> = ({ isRunning })
eventSource.onmessage = (event) => { eventSource.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data); setProgress(JSON.parse(event.data));
setProgress(data);
} catch { } catch {
// Ignore parse errors // Ignore parse errors
} }
@ -94,203 +102,137 @@ export const PipelineProgress: React.FC<PipelineProgressProps> = ({ isRunning })
return null; return null;
} }
const step = progress?.step || 'idle'; const step = progress?.step ?? "idle";
const isActive = progress && step !== 'idle' && step !== 'completed' && step !== 'failed'; const isActive = step !== "idle" && step !== "completed" && step !== "failed";
// Calculate overall progress percentage const percentage = useMemo(() => {
let percentage = 0; if (!progress) return 0;
if (progress) {
switch (step) { switch (progress.step) {
case 'crawling': case "crawling": {
if (progress.crawlingListPagesTotal > 0) { if (progress.crawlingListPagesTotal > 0) {
percentage = (progress.crawlingListPagesProcessed / progress.crawlingListPagesTotal) * 15; return clamp((progress.crawlingListPagesProcessed / progress.crawlingListPagesTotal) * 15, 0, 15);
} else if (progress.crawlingListPagesProcessed > 0) {
percentage = 8;
} else {
percentage = 5;
} }
break; if (progress.crawlingListPagesProcessed > 0) return 8;
case 'importing': return 5;
percentage = 20; }
break; case "importing":
case 'scoring': return 20;
case "scoring": {
if (progress.jobsScored > 0) { if (progress.jobsScored > 0) {
percentage = 20 + (progress.jobsScored / Math.max(progress.jobsDiscovered, 1)) * 30; return clamp(20 + (progress.jobsScored / Math.max(progress.jobsDiscovered, 1)) * 30, 20, 50);
} else {
percentage = 25;
} }
break; return 25;
case 'processing': }
case "processing": {
if (progress.totalToProcess > 0) { if (progress.totalToProcess > 0) {
percentage = 50 + (progress.jobsProcessed / progress.totalToProcess) * 50; return clamp(50 + (progress.jobsProcessed / progress.totalToProcess) * 50, 50, 100);
} else {
percentage = 55;
} }
break; return 55;
case 'completed': }
percentage = 100; case "completed":
break; case "failed":
case 'failed': return 100;
percentage = 100; case "idle":
break; default:
return 0;
} }
} }, [progress]);
const showStats = !!progress && ["crawling", "scoring", "processing", "completed"].includes(step);
return ( return (
<div className="pipeline-progress" style={{ <Card>
background: 'var(--glass-background)', <CardHeader className="space-y-2">
backdropFilter: 'blur(12px)', <div className="flex items-start justify-between gap-3">
border: '1px solid var(--glass-border)', <div className="flex min-w-0 items-center gap-2">
borderRadius: 'var(--radius-lg)', <CardTitle className="text-base">Pipeline</CardTitle>
padding: 'var(--space-6)', <Badge variant="outline" className={cn("uppercase tracking-wide", stepBadgeClasses[step])}>
marginBottom: 'var(--space-6)', {stepLabels[step]}
}}> </Badge>
{/* Header */} <span className="truncate text-xs text-muted-foreground">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-4)' }}> {isConnected ? "Live" : "Connecting…"}
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}> </span>
{isActive && ( </div>
<div className="spinner" style={{ width: '16px', height: '16px' }} />
)} <div className="flex items-center gap-2 text-sm text-muted-foreground">
<span style={{ {isActive && <Loader2 className="h-4 w-4 animate-spin" />}
color: stepColors[step], <span className="tabular-nums">{Math.round(percentage)}%</span>
fontWeight: '600', </div>
fontSize: 'var(--font-sm)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}>
{stepLabels[step]}
</span>
</div> </div>
<span style={{ color: 'var(--color-muted)', fontSize: 'var(--font-xs)' }}>
{Math.round(percentage)}%
</span>
</div>
{/* Progress bar */} <Progress value={percentage} className="h-2" />
<div style={{ </CardHeader>
height: '6px',
background: 'var(--color-surface-elevated)',
borderRadius: '3px',
overflow: 'hidden',
marginBottom: 'var(--space-4)',
}}>
<div style={{
height: '100%',
width: `${percentage}%`,
background: step === 'failed'
? 'var(--color-error)'
: 'linear-gradient(90deg, var(--color-primary-500), var(--color-primary-400))',
borderRadius: '3px',
transition: 'width 0.3s ease',
}} />
</div>
{/* Message */}
{progress && ( {progress && (
<div style={{ marginBottom: 'var(--space-3)' }}> <CardContent className="space-y-4">
<p style={{ color: 'var(--color-text)', margin: 0 }}> <div className="space-y-1">
{progress.message} <p className="text-sm">{progress.message}</p>
</p> {progress.detail && <p className="text-sm text-muted-foreground">{progress.detail}</p>}
{progress.detail && ( </div>
<p style={{
color: 'var(--color-muted)',
fontSize: 'var(--font-sm)',
margin: 'var(--space-1) 0 0 0',
}}>
{progress.detail}
</p>
)}
</div>
)}
{/* Stats */} {showStats && (
{progress && (step === 'crawling' || step === 'scoring' || step === 'processing' || step === 'completed') && (
<div style={{
display: 'flex',
gap: 'var(--space-6)',
paddingTop: 'var(--space-3)',
borderTop: '1px solid var(--glass-border)',
fontSize: 'var(--font-sm)',
}}>
{step === 'crawling' && (
<> <>
<div> <Separator />
<span style={{ color: 'var(--color-muted)' }}>Sources: </span> <div className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
<span style={{ color: 'var(--color-text)', fontWeight: '500' }}> {step === "crawling" ? (
{progress.crawlingListPagesProcessed} <>
{progress.crawlingListPagesTotal > 0 ? `/${progress.crawlingListPagesTotal}` : ''} <div>
</span> <div className="text-xs text-muted-foreground">Sources</div>
<div className="tabular-nums">
{progress.crawlingListPagesProcessed}
{progress.crawlingListPagesTotal > 0 ? `/${progress.crawlingListPagesTotal}` : ""}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Pages</div>
<div className="tabular-nums">
{progress.crawlingJobPagesProcessed}/{Math.max(progress.crawlingJobPagesEnqueued, 0)}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Enqueued</div>
<div className="tabular-nums">{progress.crawlingJobPagesEnqueued}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Skipped</div>
<div className="tabular-nums">{progress.crawlingJobPagesSkipped}</div>
</div>
</>
) : (
<>
<div>
<div className="text-xs text-muted-foreground">Discovered</div>
<div className="tabular-nums">{progress.jobsDiscovered}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Scored</div>
<div className="tabular-nums">{progress.jobsScored}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Processed</div>
<div className="tabular-nums">
{progress.totalToProcess > 0 ? `${progress.jobsProcessed}/${progress.totalToProcess}` : progress.jobsProcessed}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">To process</div>
<div className="tabular-nums">{progress.totalToProcess}</div>
</div>
</>
)}
</div> </div>
<div>
<span style={{ color: 'var(--color-muted)' }}>Pages: </span>
<span style={{ color: 'var(--color-text)', fontWeight: '500' }}>
{progress.crawlingJobPagesProcessed}/{Math.max(progress.crawlingJobPagesEnqueued, 0)}
</span>
</div>
<div>
<span style={{ color: 'var(--color-muted)' }}>Enqueued: </span>
<span style={{ color: 'var(--color-text)', fontWeight: '500' }}>
{progress.crawlingJobPagesEnqueued}
</span>
</div>
{progress.crawlingJobPagesSkipped > 0 && (
<div>
<span style={{ color: 'var(--color-muted)' }}>Skipped: </span>
<span style={{ color: 'var(--color-text)', fontWeight: '500' }}>
{progress.crawlingJobPagesSkipped}
</span>
</div>
)}
{progress.crawlingJobCardsFound > 0 && (
<div>
<span style={{ color: 'var(--color-muted)' }}>Cards: </span>
<span style={{ color: 'var(--color-text)', fontWeight: '500' }}>
{progress.crawlingJobCardsFound}
</span>
</div>
)}
</> </>
)} )}
{step !== 'crawling' && (
<div>
<span style={{ color: 'var(--color-muted)' }}>Discovered: </span>
<span style={{ color: 'var(--color-text)', fontWeight: '500' }}>
{progress.jobsDiscovered}
</span>
</div>
)}
{progress.jobsScored > 0 && (
<div>
<span style={{ color: 'var(--color-muted)' }}>Scored: </span>
<span style={{ color: 'var(--color-text)', fontWeight: '500' }}>
{progress.jobsScored}
</span>
</div>
)}
{progress.totalToProcess > 0 && (
<div>
<span style={{ color: 'var(--color-muted)' }}>Processed: </span>
<span style={{ color: 'var(--color-text)', fontWeight: '500' }}>
{progress.jobsProcessed}/{progress.totalToProcess}
</span>
</div>
)}
</div>
)}
{/* Error state */} {step === "failed" && progress.error && (
{step === 'failed' && progress?.error && ( <div className="rounded-md border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
<div style={{ {progress.error}
marginTop: 'var(--space-3)', </div>
padding: 'var(--space-3)', )}
background: 'rgba(var(--color-error-rgb), 0.1)', </CardContent>
borderRadius: 'var(--radius-md)',
color: 'var(--color-error)',
fontSize: 'var(--font-sm)',
}}>
{progress.error}
</div>
)} )}
</div> </Card>
); );
}; };

View File

@ -2,7 +2,9 @@
* Suitability score display component. * Suitability score display component.
*/ */
import React from 'react'; import React from "react";
import { Progress } from "@/components/ui/progress";
interface ScoreIndicatorProps { interface ScoreIndicatorProps {
score: number | null; score: number | null;
@ -10,28 +12,14 @@ interface ScoreIndicatorProps {
export const ScoreIndicator: React.FC<ScoreIndicatorProps> = ({ score }) => { export const ScoreIndicator: React.FC<ScoreIndicatorProps> = ({ score }) => {
if (score === null) { if (score === null) {
return ( return <span className="text-sm text-muted-foreground">Not scored</span>;
<span style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}>
Not scored
</span>
);
} }
const getScoreClass = () => {
if (score >= 70) return 'score-high';
if (score >= 40) return 'score-medium';
return 'score-low';
};
return ( return (
<div className={`score ${getScoreClass()}`}> <div className="flex items-center gap-2">
<div className="score-bar"> <Progress value={score} className="h-2 w-20" />
<div <span className="text-sm tabular-nums text-muted-foreground">{score}</span>
className="score-bar-fill"
style={{ width: `${score}%` }}
/>
</div>
<span>{score}</span>
</div> </div>
); );
}; };

View File

@ -2,8 +2,18 @@
* Stats dashboard showing job counts by status. * Stats dashboard showing job counts by status.
*/ */
import React from 'react'; import React from "react";
import type { JobStatus } from '../../shared/types'; import {
CheckCircle2,
Clock,
Loader2,
Search,
Sparkles,
XCircle,
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { JobStatus } from "../../shared/types";
interface StatsProps { interface StatsProps {
stats: Record<JobStatus, number>; stats: Record<JobStatus, number>;
@ -12,39 +22,56 @@ interface StatsProps {
const statConfig: Array<{ const statConfig: Array<{
key: JobStatus; key: JobStatus;
label: string; label: string;
emoji: string; Icon: React.ComponentType<{ className?: string }>;
}> = [ }> = [
{ key: 'discovered', label: 'Discovered', emoji: '🔍' }, { key: "discovered", label: "Discovered", Icon: Search },
{ key: 'processing', label: 'Processing', emoji: '⚙️' }, { key: "processing", label: "Processing", Icon: Loader2 },
{ key: 'ready', label: 'Ready', emoji: '✨' }, { key: "ready", label: "Ready", Icon: Sparkles },
{ key: 'applied', label: 'Applied', emoji: '✅' }, { key: "applied", label: "Applied", Icon: CheckCircle2 },
{ key: 'rejected', label: 'Rejected', emoji: '❌' }, { key: "rejected", label: "Rejected", Icon: XCircle },
{ key: 'expired', label: 'Expired', emoji: '⏰' }, { key: "expired", label: "Expired", Icon: Clock },
]; ];
export const Stats: React.FC<StatsProps> = ({ stats }) => { export const Stats: React.FC<StatsProps> = ({ stats }) => {
const total = Object.values(stats).reduce((a, b) => a + b, 0); const total = Object.values(stats).reduce((a, b) => a + b, 0);
return ( return (
<div className="card" style={{ marginBottom: 'var(--space-6)' }}> <Card>
<div className="card-header"> <CardHeader className="flex flex-row items-center justify-between space-y-0">
<h2>Overview</h2> <CardTitle>Overview</CardTitle>
<span style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem' }}> <div className="text-sm text-muted-foreground">{total} total jobs</div>
{total} total jobs </CardHeader>
</span>
</div>
<div className="stats-grid"> <CardContent>
{statConfig.map(({ key, label, emoji }) => ( <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<div key={key} className="stat-card"> {statConfig.map(({ key, label, Icon }) => (
<div className="stat-value">{stats[key] || 0}</div> <Card key={key} className="bg-muted/20">
<div className="stat-label"> <CardContent className="p-4">
<span style={{ marginRight: '4px' }}>{emoji}</span> <div className="flex items-center gap-3">
{label} <div className="flex h-9 w-9 items-center justify-center rounded-md bg-background/40 text-muted-foreground">
</div> <Icon
</div> className={
))} key === "processing"
</div> ? "h-4 w-4 animate-spin"
</div> : "h-4 w-4"
}
/>
</div>
<div className="min-w-0">
<div className="text-2xl font-semibold tabular-nums leading-none">
{stats[key] || 0}
</div>
<div className="mt-1 truncate text-xs text-muted-foreground">
{label}
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</CardContent>
</Card>
); );
}; };

View File

@ -2,27 +2,46 @@
* Status badge component. * Status badge component.
*/ */
import React from 'react'; import React from "react";
import type { JobStatus } from '../../shared/types'; import { Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import type { JobStatus } from "../../shared/types";
interface StatusBadgeProps { interface StatusBadgeProps {
status: JobStatus; status: JobStatus;
} }
const statusLabels: Record<JobStatus, string> = { const statusLabels: Record<JobStatus, string> = {
discovered: 'Discovered', discovered: "Discovered",
processing: 'Processing', processing: "Processing",
ready: 'Ready', ready: "Ready",
applied: 'Applied', applied: "Applied",
rejected: 'Rejected', rejected: "Rejected",
expired: 'Expired', expired: "Expired",
};
const statusStyles: Record<
JobStatus,
{ variant: "default" | "secondary" | "destructive" | "outline"; className?: string }
> = {
discovered: { variant: "secondary" },
processing: { variant: "secondary" },
ready: { variant: "default" },
applied: { variant: "outline", className: "text-emerald-400 border-emerald-500/30" },
rejected: { variant: "destructive" },
expired: { variant: "outline", className: "text-muted-foreground" },
}; };
export const StatusBadge: React.FC<StatusBadgeProps> = ({ status }) => { export const StatusBadge: React.FC<StatusBadgeProps> = ({ status }) => {
const { variant, className } = statusStyles[status];
return ( return (
<span className={`badge badge-${status}`}> <Badge variant={variant} className={cn("gap-1", className)}>
{status === 'processing' && <span className="pulse"></span>} {status === "processing" && <Loader2 className="h-3 w-3 animate-spin" />}
{statusLabels[status]} {statusLabels[status]}
</span> </Badge>
); );
}; };

View File

@ -1,44 +0,0 @@
/**
* Toast notification component.
*/
import React, { useEffect } from 'react';
export interface Toast {
id: string;
message: string;
type: 'success' | 'error' | 'info';
}
interface ToastContainerProps {
toasts: Toast[];
onDismiss: (id: string) => void;
}
export const ToastContainer: React.FC<ToastContainerProps> = ({ toasts, onDismiss }) => {
return (
<div className="toast-container">
{toasts.map(toast => (
<ToastItem key={toast.id} toast={toast} onDismiss={onDismiss} />
))}
</div>
);
};
const ToastItem: React.FC<{ toast: Toast; onDismiss: (id: string) => void }> = ({
toast,
onDismiss,
}) => {
useEffect(() => {
const timer = setTimeout(() => {
onDismiss(toast.id);
}, 5000);
return () => clearTimeout(timer);
}, [toast.id, onDismiss]);
return (
<div className={`toast toast-${toast.type}`}>
<span>{toast.message}</span>
</div>
);
};

View File

@ -4,6 +4,4 @@ export { StatusBadge } from './StatusBadge';
export { ScoreIndicator } from './ScoreIndicator'; export { ScoreIndicator } from './ScoreIndicator';
export { JobCard } from './JobCard'; export { JobCard } from './JobCard';
export { JobList } from './JobList'; export { JobList } from './JobList';
export { ToastContainer, type Toast } from './Toast';
export { PipelineProgress } from './PipelineProgress'; export { PipelineProgress } from './PipelineProgress';
export * from './Icons';

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { App } from './App'; import { App } from './App';
import './styles/index.css'; import '../index.css';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>

View File

@ -1,680 +0,0 @@
/* ===================================================================
Job Ops Orchestrator - Design System
A modern, dark-mode first design with glassmorphism and gradients
=================================================================== */
/* CSS Custom Properties (Design Tokens) */
:root {
/* Colors */
--color-background: #0a0a0f;
--color-surface: #12121a;
--color-surface-elevated: #1a1a25;
--color-surface-glass: rgba(26, 26, 37, 0.7);
--color-border: rgba(255, 255, 255, 0.08);
--color-border-light: rgba(255, 255, 255, 0.12);
--color-text-primary: #f5f5f7;
--color-text-secondary: #a1a1aa;
--color-text-muted: #71717a;
/* Accent colors */
--color-primary: #6366f1;
--color-primary-light: #818cf8;
--color-primary-dark: #4f46e5;
--color-primary-glow: rgba(99, 102, 241, 0.3);
--color-success: #10b981;
--color-success-light: #34d399;
--color-success-glow: rgba(16, 185, 129, 0.2);
--color-warning: #f59e0b;
--color-warning-light: #fbbf24;
--color-danger: #ef4444;
--color-danger-light: #f87171;
--color-danger-glow: rgba(239, 68, 68, 0.2);
--color-info: #3b82f6;
--color-info-light: #60a5fa;
/* Gradients */
--gradient-primary: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
--gradient-success: linear-gradient(135deg, #10b981 0%, #34d399 100%);
--gradient-mesh: radial-gradient(at 40% 20%, hsla(250, 80%, 60%, 0.1) 0px, transparent 50%),
radial-gradient(at 80% 0%, hsla(280, 80%, 50%, 0.1) 0px, transparent 50%),
radial-gradient(at 0% 50%, hsla(220, 100%, 60%, 0.05) 0px, transparent 50%);
/* Typography */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'SF Mono', Monaco, 'Cascadia Code', monospace;
/* Spacing */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
/* Border Radius */
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-2xl: 1.5rem;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
--shadow-glow: 0 0 20px var(--color-primary-glow);
/* Transitions */
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* Reset & Base */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: var(--font-sans);
background-color: var(--color-background);
color: var(--color-text-primary);
line-height: 1.6;
min-height: 100vh;
}
#root {
min-height: 100vh;
background-image: var(--gradient-mesh);
background-attachment: fixed;
}
/* Typography */
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
line-height: 1.3;
letter-spacing: -0.02em;
}
h1 { font-size: 2rem; }
h2 { font-size: 1.5rem; }
h3 { font-size: 1.25rem; }
h4 { font-size: 1.125rem; }
a {
color: var(--color-primary-light);
text-decoration: none;
transition: color var(--transition-fast);
}
a:hover {
color: var(--color-primary);
}
/* Utility Classes */
.container {
max-width: 1400px;
margin: 0 auto;
padding: 0 var(--space-6);
}
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-2 { gap: var(--space-2); }
.gap-3 { gap: var(--space-3); }
.gap-4 { gap: var(--space-4); }
.gap-6 { gap: var(--space-6); }
/* Glass Card */
.card {
background: var(--color-surface-glass);
backdrop-filter: blur(12px);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-6);
transition: all var(--transition-normal);
}
.card:hover {
border-color: var(--color-border-light);
box-shadow: var(--shadow-lg);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-4);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-5);
font-family: var(--font-sans);
font-size: 0.875rem;
font-weight: 500;
border: none;
border-radius: var(--radius-lg);
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--gradient-primary);
color: white;
box-shadow: var(--shadow-sm), 0 0 20px var(--color-primary-glow);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: var(--shadow-md), 0 0 30px var(--color-primary-glow);
}
.btn-success {
background: var(--gradient-success);
color: white;
}
.btn-success:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: var(--shadow-md), 0 0 20px var(--color-success-glow);
}
.btn-danger {
background: var(--color-danger);
color: white;
}
.btn-danger:hover:not(:disabled) {
background: var(--color-danger-light);
}
.btn-ghost {
background: transparent;
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.btn-ghost:hover:not(:disabled) {
background: var(--color-surface);
color: var(--color-text-primary);
border-color: var(--color-border-light);
}
.btn-icon {
padding: var(--space-2);
border-radius: var(--radius-md);
}
/* Status Badges */
.badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
font-size: 0.75rem;
font-weight: 500;
border-radius: var(--radius-full);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge-discovered {
background: rgba(59, 130, 246, 0.15);
color: var(--color-info-light);
border: 1px solid rgba(59, 130, 246, 0.3);
}
.badge-processing {
background: rgba(245, 158, 11, 0.15);
color: var(--color-warning-light);
border: 1px solid rgba(245, 158, 11, 0.3);
}
.badge-ready {
background: rgba(16, 185, 129, 0.15);
color: var(--color-success-light);
border: 1px solid rgba(16, 185, 129, 0.3);
}
.badge-applied {
background: rgba(99, 102, 241, 0.15);
color: var(--color-primary-light);
border: 1px solid rgba(99, 102, 241, 0.3);
}
.badge-rejected {
background: rgba(239, 68, 68, 0.15);
color: var(--color-danger-light);
border: 1px solid rgba(239, 68, 68, 0.3);
}
.badge-expired {
background: rgba(113, 113, 122, 0.15);
color: var(--color-text-muted);
border: 1px solid rgba(113, 113, 122, 0.3);
}
/* Score indicator */
.score {
display: flex;
align-items: center;
gap: var(--space-2);
font-weight: 600;
font-size: 0.875rem;
}
.score-bar {
width: 60px;
height: 6px;
background: var(--color-surface);
border-radius: var(--radius-full);
overflow: hidden;
}
.score-bar-fill {
height: 100%;
border-radius: var(--radius-full);
transition: width var(--transition-slow);
}
.score-high .score-bar-fill { background: var(--color-success); }
.score-medium .score-bar-fill { background: var(--color-warning); }
.score-low .score-bar-fill { background: var(--color-danger); }
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: var(--space-4);
}
.stat-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-4);
text-align: center;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
background: var(--gradient-primary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.stat-label {
font-size: 0.75rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: var(--space-1);
}
/* Job List */
.job-list {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.job-card {
background: var(--color-surface-glass);
backdrop-filter: blur(12px);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-5);
transition: all var(--transition-normal);
}
.job-card:hover {
border-color: var(--color-primary);
box-shadow: var(--shadow-glow);
transform: translateY(-2px);
}
.job-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: var(--space-3);
}
.job-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: var(--space-1);
}
.job-employer {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.job-meta {
display: flex;
flex-wrap: wrap;
gap: var(--space-4);
margin-top: var(--space-3);
padding-top: var(--space-3);
border-top: 1px solid var(--color-border);
}
.job-meta-item {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.job-meta-item svg {
width: 14px;
height: 14px;
opacity: 0.7;
}
.job-actions {
display: flex;
gap: var(--space-2);
margin-top: var(--space-4);
}
/* Tabs */
.tabs {
display: flex;
gap: var(--space-1);
background: var(--color-surface);
padding: var(--space-1);
border-radius: var(--radius-lg);
margin-bottom: var(--space-6);
}
.tab {
flex: 1;
padding: var(--space-3) var(--space-4);
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-muted);
background: transparent;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
}
.tab:hover {
color: var(--color-text-secondary);
}
.tab.active {
background: var(--gradient-primary);
color: white;
}
/* Empty State */
.empty-state {
text-align: center;
padding: var(--space-12);
color: var(--color-text-muted);
}
.empty-state-icon {
font-size: 3rem;
margin-bottom: var(--space-4);
opacity: 0.5;
}
.empty-state-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text-secondary);
margin-bottom: var(--space-2);
}
/* Loading Spinner */
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Pulse animation for processing jobs */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.pulse {
animation: pulse 2s ease-in-out infinite;
}
/* Header */
.header {
padding: var(--space-6) 0;
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-8);
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
}
.logo {
display: flex;
align-items: center;
gap: var(--space-3);
}
.logo-icon {
width: 40px;
height: 40px;
border-radius: var(--radius-lg);
background: var(--gradient-primary);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
}
.logo-text {
font-size: 1.25rem;
font-weight: 700;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
opacity: 0;
animation: fadeIn var(--transition-fast) forwards;
}
@keyframes fadeIn {
to { opacity: 1; }
}
.modal {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-2xl);
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
animation: slideUp var(--transition-normal) forwards;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-5) var(--space-6);
border-bottom: 1px solid var(--color-border);
}
.modal-title {
font-size: 1.125rem;
font-weight: 600;
}
.modal-body {
padding: var(--space-6);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
padding: var(--space-4) var(--space-6);
border-top: 1px solid var(--color-border);
background: var(--color-surface-elevated);
border-radius: 0 0 var(--radius-2xl) var(--radius-2xl);
}
/* Toast */
.toast-container {
position: fixed;
bottom: var(--space-6);
right: var(--space-6);
z-index: 200;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.toast {
background: var(--color-surface-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-4) var(--space-5);
box-shadow: var(--shadow-lg);
min-width: 280px;
animation: slideIn var(--transition-normal) forwards;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.toast-success { border-left: 3px solid var(--color-success); }
.toast-error { border-left: 3px solid var(--color-danger); }
.toast-info { border-left: 3px solid var(--color-info); }
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-surface);
}
::-webkit-scrollbar-thumb {
background: var(--color-border-light);
border-radius: var(--radius-full);
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
}
/* Responsive */
@media (max-width: 768px) {
.container {
padding: 0 var(--space-4);
}
h1 { font-size: 1.5rem; }
.header-content {
flex-direction: column;
gap: var(--space-4);
align-items: flex-start;
}
.stats-grid {
grid-template-columns: repeat(3, 1fr);
}
.job-card-header {
flex-direction: column;
}
.job-actions {
flex-direction: column;
}
.job-actions .btn {
width: 100%;
}
}

View File

@ -0,0 +1,139 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@ -0,0 +1,29 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@ -0,0 +1,26 @@
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
theme="dark"
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

130
orchestrator/src/index.css Normal file
View File

@ -0,0 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *));
@tailwind utilities;
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI",
Roboto, Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji";
@apply bg-background text-foreground antialiased;
}
}

View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@ -0,0 +1,11 @@
import type { Config } from "tailwindcss";
export default {
darkMode: "class",
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {},
},
plugins: [],
} satisfies Config;