tailwind transition
This commit is contained in:
parent
38ff39b7f3
commit
29a8b0543a
22
orchestrator/components.json
Normal file
22
orchestrator/components.json
Normal 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": {}
|
||||||
|
}
|
||||||
@ -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" />
|
||||||
|
|||||||
1474
orchestrator/package-lock.json
generated
1474
orchestrator/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||||
|
|||||||
6
orchestrator/postcss.config.cjs
Normal file
6
orchestrator/postcss.config.cjs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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 () => {
|
||||||
|
try {
|
||||||
const status = await api.getPipelineStatus();
|
const status = await api.getPipelineStatus();
|
||||||
if (!status.isRunning) {
|
if (!status.isRunning) {
|
||||||
clearInterval(pollInterval);
|
clearInterval(pollInterval);
|
||||||
setIsPipelineRunning(false);
|
setIsPipelineRunning(false);
|
||||||
loadJobs();
|
await loadJobs();
|
||||||
addToast('Pipeline completed!', 'success');
|
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);
|
||||||
|
setStats(data.byStatus);
|
||||||
|
|
||||||
|
const stillDiscovered = data.byStatus.discovered + data.byStatus.processing;
|
||||||
if (stillDiscovered === 0) {
|
if (stillDiscovered === 0) {
|
||||||
clearInterval(pollInterval);
|
clearInterval(pollInterval);
|
||||||
setIsProcessingAll(false);
|
setIsProcessingAll(false);
|
||||||
addToast('All jobs processed!', 'success');
|
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 />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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,61 +33,67 @@ 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>
|
||||||
|
<div className="leading-tight">
|
||||||
|
<div className="text-sm font-semibold tracking-tight">Job Ops</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Orchestrator</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="logo-text">Job Ops</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<button
|
<AlertDialog>
|
||||||
className="btn btn-ghost"
|
<AlertDialogTrigger asChild>
|
||||||
onClick={handleClearDatabase}
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
title="Clear all jobs from database"
|
title="Clear all jobs from database"
|
||||||
>
|
>
|
||||||
<TrashIcon size={16} />
|
<Trash2 className="h-4 w-4" />
|
||||||
Clear DB
|
<span className="hidden sm:inline">Clear DB</span>
|
||||||
</button>
|
</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
|
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
|
||||||
className="btn btn-ghost"
|
<RefreshCcw className="h-4 w-4" />
|
||||||
onClick={onRefresh}
|
<span className="hidden sm:inline">Refresh</span>
|
||||||
disabled={isLoading}
|
</Button>
|
||||||
>
|
|
||||||
<RefreshIcon size={16} />
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<Button size="sm" onClick={onRunPipeline} disabled={isPipelineRunning}>
|
||||||
className="btn btn-primary"
|
|
||||||
onClick={onRunPipeline}
|
|
||||||
disabled={isPipelineRunning}
|
|
||||||
>
|
|
||||||
{isPipelineRunning ? (
|
{isPipelineRunning ? (
|
||||||
<>
|
<>
|
||||||
<div className="spinner" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
Running...
|
Running...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<PlayIcon size={16} />
|
<Play className="h-4 w-4" />
|
||||||
Run Pipeline
|
Run Pipeline
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
@ -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 className="text-sm text-muted-foreground">{job.employer}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<ScoreIndicator score={job.suitabilityScore} />
|
<ScoreIndicator score={job.suitabilityScore} />
|
||||||
<StatusBadge status={job.status} />
|
<StatusBadge status={job.status} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="job-meta">
|
<div className="flex flex-wrap gap-x-4 gap-y-2 text-sm text-muted-foreground">
|
||||||
{job.location && (
|
{job.location && (
|
||||||
<span className="job-meta-item">
|
<span className="flex items-center gap-1">
|
||||||
<MapPinIcon />
|
<MapPin className="h-4 w-4" />
|
||||||
{job.location}
|
{job.location}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{job.deadline && (
|
{deadline && (
|
||||||
<span className="job-meta-item">
|
<span className="flex items-center gap-1">
|
||||||
<CalendarIcon />
|
<Calendar className="h-4 w-4" />
|
||||||
{job.deadline}
|
{deadline}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{job.salary && (
|
{job.salary && (
|
||||||
<span className="job-meta-item">
|
<span className="flex items-center gap-1">
|
||||||
<DollarIcon />
|
<DollarSign className="h-4 w-4" />
|
||||||
{job.salary}
|
{job.salary}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{job.degreeRequired && (
|
{job.degreeRequired && (
|
||||||
<span className="job-meta-item">
|
<span className="flex items-center gap-1">
|
||||||
<GraduationCapIcon />
|
<GraduationCap className="h-4 w-4" />
|
||||||
{job.degreeRequired}
|
{job.degreeRequired}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{(job.suitabilityReason || canApply || canReject || canProcess || hasPdf) && (
|
||||||
|
<CardContent className="space-y-3">
|
||||||
{job.suitabilityReason && (
|
{job.suitabilityReason && (
|
||||||
<p style={{
|
<p className="text-sm italic text-muted-foreground">
|
||||||
marginTop: 'var(--space-3)',
|
"{job.suitabilityReason}"
|
||||||
fontSize: '0.8125rem',
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
fontStyle: 'italic',
|
|
||||||
}}>
|
|
||||||
"{job.suitabilityReason}"
|
|
||||||
</p>
|
</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"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="btn btn-ghost"
|
|
||||||
>
|
|
||||||
<ExternalLinkIcon size={16} />
|
|
||||||
View Job
|
View Job
|
||||||
</a>
|
</a>
|
||||||
|
</Button>
|
||||||
|
|
||||||
{/* View PDF in browser */}
|
|
||||||
{hasPdf && (
|
{hasPdf && (
|
||||||
<a
|
<Button asChild variant="outline" size="sm">
|
||||||
href={`/pdfs/resume_${job.id}.pdf`}
|
<a href={pdfHref} target="_blank" rel="noopener noreferrer">
|
||||||
target="_blank"
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="btn btn-ghost"
|
|
||||||
>
|
|
||||||
<ExternalLinkIcon size={16} />
|
|
||||||
View PDF
|
View PDF
|
||||||
</a>
|
</a>
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Download PDF */}
|
|
||||||
{hasPdf && (
|
{hasPdf && (
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
<a
|
<a
|
||||||
href={`/pdfs/resume_${job.id}.pdf`}
|
href={pdfHref}
|
||||||
download={`resume_${job.employer.replace(/[^a-z0-9]/gi, '_')}_${job.title.replace(/[^a-z0-9]/gi, '_')}.pdf`}
|
download={`resume_${safeFilenamePart(job.employer)}_${safeFilenamePart(job.title)}.pdf`}
|
||||||
className="btn btn-ghost"
|
|
||||||
>
|
>
|
||||||
<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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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,75 +46,89 @@ 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
|
|
||||||
: 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})
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
</button>
|
<TabsList className="w-full sm:w-auto">
|
||||||
);
|
{tabs.map((tab) => (
|
||||||
})}
|
<TabsTrigger key={tab.id} value={tab.id} className="flex-1 sm:flex-none">
|
||||||
</div>
|
{tab.label}
|
||||||
|
<span className="ml-2 text-xs tabular-nums text-muted-foreground">
|
||||||
|
({counts[tab.id]})
|
||||||
|
</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
{activeTab === 'discovered' && discoveredCount > 0 && (
|
{activeTab === "discovered" && counts.discovered > 0 && (
|
||||||
<button
|
<Button onClick={onProcessAll} disabled={isProcessingAll} size="sm">
|
||||||
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>
|
||||||
|
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const filteredJobs = jobsForTab.get(tab.id) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsContent key={tab.id} value={tab.id} className="space-y-4">
|
||||||
{filteredJobs.length === 0 ? (
|
{filteredJobs.length === 0 ? (
|
||||||
<div className="empty-state">
|
<Card className="border-dashed bg-muted/20">
|
||||||
<div className="empty-state-icon">📭</div>
|
<CardContent className="flex flex-col items-center justify-center gap-2 py-12 text-center">
|
||||||
<h3 className="empty-state-title">No jobs found</h3>
|
<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>
|
||||||
{activeTab === 'ready' && 'Run the pipeline to discover and process new jobs.'}
|
</CardContent>
|
||||||
{activeTab === 'discovered' && 'All discovered jobs have been processed.'}
|
</Card>
|
||||||
{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">
|
<div className="grid gap-4">
|
||||||
{filteredJobs.map(job => (
|
{filteredJobs.map((job) => (
|
||||||
<JobCard
|
<JobCard
|
||||||
key={job.id}
|
key={job.id}
|
||||||
job={job}
|
job={job}
|
||||||
@ -115,6 +140,10 @@ export const JobList: React.FC<JobListProps> = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</TabsContent>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tabs>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
|
||||||
break;
|
|
||||||
case 'failed':
|
|
||||||
percentage = 100;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
case "completed":
|
||||||
|
case "failed":
|
||||||
|
return 100;
|
||||||
|
case "idle":
|
||||||
|
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)',
|
|
||||||
}}>
|
|
||||||
{/* Header */}
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-4)' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
|
||||||
{isActive && (
|
|
||||||
<div className="spinner" style={{ width: '16px', height: '16px' }} />
|
|
||||||
)}
|
|
||||||
<span style={{
|
|
||||||
color: stepColors[step],
|
|
||||||
fontWeight: '600',
|
|
||||||
fontSize: 'var(--font-sm)',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: '0.05em',
|
|
||||||
}}>
|
|
||||||
{stepLabels[step]}
|
{stepLabels[step]}
|
||||||
</span>
|
</Badge>
|
||||||
</div>
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
<span style={{ color: 'var(--color-muted)', fontSize: 'var(--font-xs)' }}>
|
{isConnected ? "Live" : "Connecting…"}
|
||||||
{Math.round(percentage)}%
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress bar */}
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<div style={{
|
{isActive && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
height: '6px',
|
<span className="tabular-nums">{Math.round(percentage)}%</span>
|
||||||
background: 'var(--color-surface-elevated)',
|
</div>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
{/* Message */}
|
<Progress value={percentage} className="h-2" />
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
{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 && (
|
|
||||||
<p style={{
|
|
||||||
color: 'var(--color-muted)',
|
|
||||||
fontSize: 'var(--font-sm)',
|
|
||||||
margin: 'var(--space-1) 0 0 0',
|
|
||||||
}}>
|
|
||||||
{progress.detail}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Stats */}
|
{showStats && (
|
||||||
{progress && (step === 'crawling' || step === 'scoring' || step === 'processing' || step === 'completed') && (
|
<>
|
||||||
<div style={{
|
<Separator />
|
||||||
display: 'flex',
|
<div className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
|
||||||
gap: 'var(--space-6)',
|
{step === "crawling" ? (
|
||||||
paddingTop: 'var(--space-3)',
|
|
||||||
borderTop: '1px solid var(--glass-border)',
|
|
||||||
fontSize: 'var(--font-sm)',
|
|
||||||
}}>
|
|
||||||
{step === 'crawling' && (
|
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<span style={{ color: 'var(--color-muted)' }}>Sources: </span>
|
<div className="text-xs text-muted-foreground">Sources</div>
|
||||||
<span style={{ color: 'var(--color-text)', fontWeight: '500' }}>
|
<div className="tabular-nums">
|
||||||
{progress.crawlingListPagesProcessed}
|
{progress.crawlingListPagesProcessed}
|
||||||
{progress.crawlingListPagesTotal > 0 ? `/${progress.crawlingListPagesTotal}` : ''}
|
{progress.crawlingListPagesTotal > 0 ? `/${progress.crawlingListPagesTotal}` : ""}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span style={{ color: 'var(--color-muted)' }}>Pages: </span>
|
<div className="text-xs text-muted-foreground">Pages</div>
|
||||||
<span style={{ color: 'var(--color-text)', fontWeight: '500' }}>
|
<div className="tabular-nums">
|
||||||
{progress.crawlingJobPagesProcessed}/{Math.max(progress.crawlingJobPagesEnqueued, 0)}
|
{progress.crawlingJobPagesProcessed}/{Math.max(progress.crawlingJobPagesEnqueued, 0)}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span style={{ color: 'var(--color-muted)' }}>Enqueued: </span>
|
<div className="text-xs text-muted-foreground">Enqueued</div>
|
||||||
<span style={{ color: 'var(--color-text)', fontWeight: '500' }}>
|
<div className="tabular-nums">{progress.crawlingJobPagesEnqueued}</div>
|
||||||
{progress.crawlingJobPagesEnqueued}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{progress.crawlingJobPagesSkipped > 0 && (
|
|
||||||
<div>
|
<div>
|
||||||
<span style={{ color: 'var(--color-muted)' }}>Skipped: </span>
|
<div className="text-xs text-muted-foreground">Skipped</div>
|
||||||
<span style={{ color: 'var(--color-text)', fontWeight: '500' }}>
|
<div className="tabular-nums">{progress.crawlingJobPagesSkipped}</div>
|
||||||
{progress.crawlingJobPagesSkipped}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</>
|
||||||
{progress.crawlingJobCardsFound > 0 && (
|
) : (
|
||||||
|
<>
|
||||||
<div>
|
<div>
|
||||||
<span style={{ color: 'var(--color-muted)' }}>Cards: </span>
|
<div className="text-xs text-muted-foreground">Discovered</div>
|
||||||
<span style={{ color: 'var(--color-text)', fontWeight: '500' }}>
|
<div className="tabular-nums">{progress.jobsDiscovered}</div>
|
||||||
{progress.crawlingJobCardsFound}
|
</div>
|
||||||
</span>
|
<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>
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{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>
|
</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={{
|
|
||||||
marginTop: 'var(--space-3)',
|
|
||||||
padding: 'var(--space-3)',
|
|
||||||
background: 'rgba(var(--color-error-rgb), 0.1)',
|
|
||||||
borderRadius: 'var(--radius-md)',
|
|
||||||
color: 'var(--color-error)',
|
|
||||||
fontSize: 'var(--font-sm)',
|
|
||||||
}}>
|
|
||||||
{progress.error}
|
{progress.error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-background/40 text-muted-foreground">
|
||||||
|
<Icon
|
||||||
|
className={
|
||||||
|
key === "processing"
|
||||||
|
? "h-4 w-4 animate-spin"
|
||||||
|
: "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}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -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';
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
139
orchestrator/src/components/ui/alert-dialog.tsx
Normal file
139
orchestrator/src/components/ui/alert-dialog.tsx
Normal 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,
|
||||||
|
}
|
||||||
36
orchestrator/src/components/ui/badge.tsx
Normal file
36
orchestrator/src/components/ui/badge.tsx
Normal 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 }
|
||||||
57
orchestrator/src/components/ui/button.tsx
Normal file
57
orchestrator/src/components/ui/button.tsx
Normal 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 }
|
||||||
76
orchestrator/src/components/ui/card.tsx
Normal file
76
orchestrator/src/components/ui/card.tsx
Normal 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 }
|
||||||
28
orchestrator/src/components/ui/progress.tsx
Normal file
28
orchestrator/src/components/ui/progress.tsx
Normal 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 }
|
||||||
29
orchestrator/src/components/ui/separator.tsx
Normal file
29
orchestrator/src/components/ui/separator.tsx
Normal 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 }
|
||||||
26
orchestrator/src/components/ui/sonner.tsx
Normal file
26
orchestrator/src/components/ui/sonner.tsx
Normal 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 }
|
||||||
53
orchestrator/src/components/ui/tabs.tsx
Normal file
53
orchestrator/src/components/ui/tabs.tsx
Normal 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
130
orchestrator/src/index.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
orchestrator/src/lib/utils.ts
Normal file
6
orchestrator/src/lib/utils.ts
Normal 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))
|
||||||
|
}
|
||||||
11
orchestrator/tailwind.config.ts
Normal file
11
orchestrator/tailwind.config.ts
Normal 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;
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user