orchestrator initial commit...

This commit is contained in:
DaKheera47
2025-12-11 22:31:59 +00:00
parent 7ab024efda
commit 1b082a3eb6
46 changed files with 9792 additions and 5 deletions
+162
View File
@@ -0,0 +1,162 @@
/**
* Main App component.
*/
import React, { useState, useEffect, useCallback } from 'react';
import type { Job, JobStatus } from '../shared/types';
import { Header, Stats, JobList, ToastContainer, Toast } from './components';
import * as api from './api';
export const App: React.FC = () => {
// State
const [jobs, setJobs] = useState<Job[]>([]);
const [stats, setStats] = useState<Record<JobStatus, number>>({
discovered: 0,
processing: 0,
ready: 0,
applied: 0,
rejected: 0,
expired: 0,
});
const [isLoading, setIsLoading] = useState(true);
const [isPipelineRunning, setIsPipelineRunning] = useState(false);
const [processingJobId, setProcessingJobId] = useState<string | null>(null);
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 () => {
try {
setIsLoading(true);
const data = await api.getJobs();
setJobs(data.jobs);
setStats(data.byStatus);
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to load jobs';
addToast(message, 'error');
} finally {
setIsLoading(false);
}
}, [addToast]);
// Check pipeline status
const checkPipelineStatus = useCallback(async () => {
try {
const status = await api.getPipelineStatus();
setIsPipelineRunning(status.isRunning);
} catch {
// Ignore errors
}
}, []);
// Initial load
useEffect(() => {
loadJobs();
checkPipelineStatus();
// Poll for updates
const interval = setInterval(() => {
loadJobs();
checkPipelineStatus();
}, 10000);
return () => clearInterval(interval);
}, [loadJobs, checkPipelineStatus]);
// Run pipeline
const handleRunPipeline = async () => {
try {
setIsPipelineRunning(true);
await api.runPipeline();
addToast('Pipeline started! This may take a few minutes.', 'info');
// Poll more frequently while running
const pollInterval = setInterval(async () => {
const status = await api.getPipelineStatus();
if (!status.isRunning) {
clearInterval(pollInterval);
setIsPipelineRunning(false);
loadJobs();
addToast('Pipeline completed!', 'success');
}
}, 5000);
} catch (error) {
setIsPipelineRunning(false);
const message = error instanceof Error ? error.message : 'Failed to start pipeline';
addToast(message, 'error');
}
};
// Process single job
const handleProcess = async (jobId: string) => {
try {
setProcessingJobId(jobId);
await api.processJob(jobId);
addToast('Resume generated successfully!', 'success');
loadJobs();
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to process job';
addToast(message, 'error');
} finally {
setProcessingJobId(null);
}
};
// Mark as applied
const handleApply = async (jobId: string) => {
try {
await api.markAsApplied(jobId);
addToast('Marked as applied! ✅', 'success');
loadJobs();
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to mark as applied';
addToast(message, 'error');
}
};
// Reject job
const handleReject = async (jobId: string) => {
try {
await api.rejectJob(jobId);
addToast('Job skipped', 'info');
loadJobs();
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to reject job';
addToast(message, 'error');
}
};
return (
<>
<Header
onRunPipeline={handleRunPipeline}
onRefresh={loadJobs}
isPipelineRunning={isPipelineRunning}
isLoading={isLoading}
/>
<main className="container" style={{ paddingBottom: 'var(--space-12)' }}>
<Stats stats={stats} />
<JobList
jobs={jobs}
onApply={handleApply}
onReject={handleReject}
onProcess={handleProcess}
processingJobId={processingJobId}
/>
</main>
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
</>
);
};
+91
View File
@@ -0,0 +1,91 @@
/**
* API client for the orchestrator backend.
*/
import type {
Job,
ApiResponse,
JobsListResponse,
PipelineStatusResponse,
PipelineRun
} from '../../shared/types';
const API_BASE = '/api';
async function fetchApi<T>(
endpoint: string,
options?: RequestInit
): Promise<T> {
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
const data: ApiResponse<T> = await response.json();
if (!data.success) {
throw new Error(data.error || 'API request failed');
}
return data.data as T;
}
// Jobs API
export async function getJobs(statuses?: string[]): Promise<JobsListResponse> {
const query = statuses?.length ? `?status=${statuses.join(',')}` : '';
return fetchApi<JobsListResponse>(`/jobs${query}`);
}
export async function getJob(id: string): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}`);
}
export async function updateJob(
id: string,
update: Partial<Job>
): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}`, {
method: 'PATCH',
body: JSON.stringify(update),
});
}
export async function processJob(id: string): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}/process`, {
method: 'POST',
});
}
export async function markAsApplied(id: string): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}/apply`, {
method: 'POST',
});
}
export async function rejectJob(id: string): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}/reject`, {
method: 'POST',
});
}
// Pipeline API
export async function getPipelineStatus(): Promise<PipelineStatusResponse> {
return fetchApi<PipelineStatusResponse>('/pipeline/status');
}
export async function getPipelineRuns(): Promise<PipelineRun[]> {
return fetchApi<PipelineRun[]>('/pipeline/runs');
}
export async function runPipeline(config?: {
topN?: number;
minSuitabilityScore?: number;
}): Promise<{ message: string }> {
return fetchApi<{ message: string }>('/pipeline/run', {
method: 'POST',
body: JSON.stringify(config || {}),
});
}
+1
View File
@@ -0,0 +1 @@
export * from './client';
@@ -0,0 +1,64 @@
/**
* Header component with logo and pipeline trigger.
*/
import React from 'react';
import { RocketIcon, PlayIcon, RefreshIcon } from './Icons';
interface HeaderProps {
onRunPipeline: () => void;
onRefresh: () => void;
isPipelineRunning: boolean;
isLoading: boolean;
}
export const Header: React.FC<HeaderProps> = ({
onRunPipeline,
onRefresh,
isPipelineRunning,
isLoading,
}) => {
return (
<header className="header">
<div className="container">
<div className="header-content">
<div className="logo">
<div className="logo-icon">
<RocketIcon size={20} />
</div>
<span className="logo-text">Job Ops</span>
</div>
<div className="flex gap-3">
<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>
</header>
);
};
@@ -0,0 +1,118 @@
/**
* 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>
);
@@ -0,0 +1,174 @@
/**
* Individual job card component.
*/
import React from 'react';
import type { Job } from '../../shared/types';
import { StatusBadge } from './StatusBadge';
import { ScoreIndicator } from './ScoreIndicator';
import {
MapPinIcon,
CalendarIcon,
DollarIcon,
GraduationCapIcon,
ExternalLinkIcon,
DownloadIcon,
CheckCircleIcon,
XCircleIcon,
RefreshIcon,
} from './Icons';
interface JobCardProps {
job: Job;
onApply: (id: string) => void;
onReject: (id: string) => void;
onProcess: (id: string) => void;
isProcessing: boolean;
}
export const JobCard: React.FC<JobCardProps> = ({
job,
onApply,
onReject,
onProcess,
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 canApply = job.status === 'ready';
const canProcess = job.status === 'discovered';
const canReject = ['discovered', 'ready'].includes(job.status);
return (
<article className="job-card">
<div className="job-card-header">
<div>
<h3 className="job-title">{job.title}</h3>
<p className="job-employer">{job.employer}</p>
</div>
<div className="flex items-center gap-3">
<ScoreIndicator score={job.suitabilityScore} />
<StatusBadge status={job.status} />
</div>
</div>
<div className="job-meta">
{job.location && (
<span className="job-meta-item">
<MapPinIcon />
{job.location}
</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 && (
<p style={{
marginTop: 'var(--space-3)',
fontSize: '0.8125rem',
color: 'var(--color-text-secondary)',
fontStyle: 'italic',
}}>
"{job.suitabilityReason}"
</p>
)}
<div className="job-actions">
{/* View job posting */}
<a
href={job.applicationLink || job.jobUrl}
target="_blank"
rel="noopener noreferrer"
className="btn btn-ghost"
>
<ExternalLinkIcon size={16} />
View Job
</a>
{/* Download PDF */}
{hasPdf && (
<a
href={`/pdfs/resume_${job.id}.pdf`}
download
className="btn btn-ghost"
>
<DownloadIcon size={16} />
Download PDF
</a>
)}
{/* Process job */}
{canProcess && (
<button
className="btn btn-ghost"
onClick={() => onProcess(job.id)}
disabled={isProcessing}
>
{isProcessing ? (
<>
<div className="spinner" />
Processing...
</>
) : (
<>
<RefreshIcon size={16} />
Generate Resume
</>
)}
</button>
)}
{/* Reject */}
{canReject && (
<button
className="btn btn-danger"
onClick={() => onReject(job.id)}
>
<XCircleIcon size={16} />
Skip
</button>
)}
{/* Mark as applied */}
{canApply && (
<button
className="btn btn-success"
onClick={() => onApply(job.id)}
>
<CheckCircleIcon size={16} />
Mark Applied
</button>
)}
</div>
</article>
);
};
@@ -0,0 +1,90 @@
/**
* Job list with filtering tabs.
*/
import React, { useState } from 'react';
import type { Job, JobStatus } from '../../shared/types';
import { JobCard } from './JobCard';
interface JobListProps {
jobs: Job[];
onApply: (id: string) => void;
onReject: (id: string) => void;
onProcess: (id: string) => void;
processingJobId: string | null;
}
type FilterTab = 'ready' | 'discovered' | 'applied' | 'all';
const tabs: Array<{ id: FilterTab; label: string; statuses: JobStatus[] }> = [
{ id: 'ready', label: '✨ Ready to Apply', statuses: ['ready'] },
{ id: 'discovered', label: '🔍 Discovered', statuses: ['discovered', 'processing'] },
{ id: 'applied', label: '✅ Applied', statuses: ['applied'] },
{ id: 'all', label: '📋 All Jobs', statuses: [] },
];
export const JobList: React.FC<JobListProps> = ({
jobs,
onApply,
onReject,
onProcess,
processingJobId,
}) => {
const [activeTab, setActiveTab] = useState<FilterTab>('ready');
const filteredJobs = React.useMemo(() => {
const tab = tabs.find(t => t.id === activeTab);
if (!tab || tab.statuses.length === 0) {
return jobs;
}
return jobs.filter(job => tab.statuses.includes(job.status));
}, [jobs, activeTab]);
return (
<div>
<div className="tabs">
{tabs.map(tab => {
const count = tab.statuses.length === 0
? jobs.length
: jobs.filter(j => tab.statuses.includes(j.status)).length;
return (
<button
key={tab.id}
className={`tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label} ({count})
</button>
);
})}
</div>
{filteredJobs.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">📭</div>
<h3 className="empty-state-title">No jobs found</h3>
<p>
{activeTab === 'ready' && 'Run the pipeline to discover and process new jobs.'}
{activeTab === 'discovered' && 'All discovered jobs have been processed.'}
{activeTab === 'applied' && "You haven't applied to any jobs yet."}
{activeTab === 'all' && 'No jobs in the system yet. Run the pipeline to get started!'}
</p>
</div>
) : (
<div className="job-list">
{filteredJobs.map(job => (
<JobCard
key={job.id}
job={job}
onApply={onApply}
onReject={onReject}
onProcess={onProcess}
isProcessing={processingJobId === job.id}
/>
))}
</div>
)}
</div>
);
};
@@ -0,0 +1,37 @@
/**
* Suitability score display component.
*/
import React from 'react';
interface ScoreIndicatorProps {
score: number | null;
}
export const ScoreIndicator: React.FC<ScoreIndicatorProps> = ({ score }) => {
if (score === null) {
return (
<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 (
<div className={`score ${getScoreClass()}`}>
<div className="score-bar">
<div
className="score-bar-fill"
style={{ width: `${score}%` }}
/>
</div>
<span>{score}</span>
</div>
);
};
@@ -0,0 +1,50 @@
/**
* Stats dashboard showing job counts by status.
*/
import React from 'react';
import type { JobStatus } from '../../shared/types';
interface StatsProps {
stats: Record<JobStatus, number>;
}
const statConfig: Array<{
key: JobStatus;
label: string;
emoji: string;
}> = [
{ key: 'discovered', label: 'Discovered', emoji: '🔍' },
{ key: 'processing', label: 'Processing', emoji: '⚙️' },
{ key: 'ready', label: 'Ready', emoji: '✨' },
{ key: 'applied', label: 'Applied', emoji: '✅' },
{ key: 'rejected', label: 'Rejected', emoji: '❌' },
{ key: 'expired', label: 'Expired', emoji: '⏰' },
];
export const Stats: React.FC<StatsProps> = ({ stats }) => {
const total = Object.values(stats).reduce((a, b) => a + b, 0);
return (
<div className="card" style={{ marginBottom: 'var(--space-6)' }}>
<div className="card-header">
<h2>Overview</h2>
<span style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem' }}>
{total} total jobs
</span>
</div>
<div className="stats-grid">
{statConfig.map(({ key, label, emoji }) => (
<div key={key} className="stat-card">
<div className="stat-value">{stats[key] || 0}</div>
<div className="stat-label">
<span style={{ marginRight: '4px' }}>{emoji}</span>
{label}
</div>
</div>
))}
</div>
</div>
);
};
@@ -0,0 +1,28 @@
/**
* Status badge component.
*/
import React from 'react';
import type { JobStatus } from '../../shared/types';
interface StatusBadgeProps {
status: JobStatus;
}
const statusLabels: Record<JobStatus, string> = {
discovered: 'Discovered',
processing: 'Processing',
ready: 'Ready',
applied: 'Applied',
rejected: 'Rejected',
expired: 'Expired',
};
export const StatusBadge: React.FC<StatusBadgeProps> = ({ status }) => {
return (
<span className={`badge badge-${status}`}>
{status === 'processing' && <span className="pulse">●</span>}
{statusLabels[status]}
</span>
);
};
@@ -0,0 +1,44 @@
/**
* 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>
);
};
@@ -0,0 +1,8 @@
export { Header } from './Header';
export { Stats } from './Stats';
export { StatusBadge } from './StatusBadge';
export { ScoreIndicator } from './ScoreIndicator';
export { JobCard } from './JobCard';
export { JobList } from './JobList';
export { ToastContainer, type Toast } from './Toast';
export * from './Icons';
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './App';
import './styles/index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+680
View File
@@ -0,0 +1,680 @@
/* ===================================================================
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%;
}
}