orchestrator initial commit...
This commit is contained in:
@@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 || {}),
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user