responsiveness

This commit is contained in:
DaKheera47 2026-01-16 03:29:45 +00:00
parent 63d8c40c2e
commit 072feaf373
11 changed files with 926 additions and 608 deletions

View File

@ -11,6 +11,7 @@
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-separator": "^1.1.8",
@ -31,6 +32,7 @@
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2",
"zod": "^3.23.8"
},
"devDependencies": {
@ -8722,6 +8724,18 @@
"node": ">= 0.8"
}
},
"node_modules/vaul": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
"integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
"dependencies": {
"@radix-ui/react-dialog": "^1.1.1"
},
"peerDependencies": {
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",

View File

@ -23,6 +23,7 @@
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-separator": "^1.1.8",
@ -43,6 +44,7 @@
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2",
"zod": "^3.23.8"
},
"devDependencies": {

View File

@ -150,13 +150,13 @@ const DecideMode: React.FC<DecideModeProps> = ({
</div>
{/* Primary/Secondary actions */}
<div className='flex gap-2 pt-2'>
<div className='flex flex-col gap-2 pt-2 sm:flex-row'>
<Button
variant='outline'
size='sm'
size='default'
onClick={onSkip}
disabled={isSkipping}
className='flex-1 h-10 text-muted-foreground hover:text-foreground hover:border-rose-500/30 hover:bg-rose-500/5'
className='flex-1 h-11 text-sm text-muted-foreground hover:text-foreground hover:border-rose-500/30 hover:bg-rose-500/5 sm:h-10 sm:text-xs'
>
{isSkipping ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
@ -166,9 +166,9 @@ const DecideMode: React.FC<DecideModeProps> = ({
Skip
</Button>
<Button
size='sm'
size='default'
onClick={onTailor}
className='flex-1 h-10 bg-primary/90 hover:bg-primary'
className='flex-1 h-11 text-sm bg-primary/90 hover:bg-primary sm:h-10 sm:text-xs'
>
<Sparkles className='mr-2 h-4 w-4' />
Tailor
@ -388,7 +388,7 @@ const TailorMode: React.FC<TailorModeProps> = ({
return (
<div className='flex flex-col h-full'>
{/* Header with back navigation */}
<div className='flex items-center justify-between pb-3'>
<div className='flex flex-col gap-2 pb-3 sm:flex-row sm:items-center sm:justify-between'>
<button
type='button'
onClick={onBack}
@ -434,7 +434,7 @@ const TailorMode: React.FC<TailorModeProps> = ({
{/* Scrollable content */}
<div className='flex-1 overflow-y-auto space-y-4 pr-1'>
{/* AI Generate option */}
<div className='flex items-center justify-between rounded-lg border border-border/40 bg-muted/10 p-3'>
<div className='flex flex-col gap-2 rounded-lg border border-border/40 bg-muted/10 p-3 sm:flex-row sm:items-center sm:justify-between'>
<div>
<div className='text-xs font-medium'>
Need help getting started?
@ -448,7 +448,7 @@ const TailorMode: React.FC<TailorModeProps> = ({
variant='outline'
onClick={handleGenerateWithAI}
disabled={isGenerating || isFinalizing}
className='h-8 text-xs'
className='h-8 w-full text-xs sm:w-auto'
>
{isGenerating ? (
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />
@ -506,7 +506,7 @@ const TailorMode: React.FC<TailorModeProps> = ({
{/* Selected Projects */}
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<div className='flex flex-wrap items-start gap-2 sm:items-center sm:justify-between'>
<label className='text-xs font-medium text-muted-foreground'>
Selected Projects
</label>

View File

@ -156,8 +156,8 @@ export const PipelineProgress: React.FC<PipelineProgressProps> = ({ isRunning })
return (
<Card>
<CardHeader className="space-y-2">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<CardTitle className="text-base">Pipeline</CardTitle>
<Badge variant="outline" className={cn("uppercase tracking-wide", stepBadgeClasses[step])}>
{stepLabels[step]}

View File

@ -223,9 +223,9 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
All actions in one line: View, Save, Open, and Mark Applied
*/}
<div className="pb-4 border-b border-border/40">
<div className="flex gap-1">
<div className="grid gap-2 sm:grid-cols-2">
{/* Show PDF - to verify quickly without download */}
<Button asChild variant="outline" className="flex-1 h-9 gap-1 px-2 text-xs">
<Button asChild variant="outline" className="h-9 w-full gap-1 px-2 text-xs">
<a href={pdfHref} target="_blank" rel="noopener noreferrer">
<FileText className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">View PDF</span>
@ -233,7 +233,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
</Button>
{/* Download PDF - primary artifact action */}
<Button asChild variant="outline" className="flex-1 h-9 gap-1 px-2 text-xs">
<Button asChild variant="outline" className="h-9 w-full gap-1 px-2 text-xs">
<a
href={pdfHref}
download={`Shaheer_Sarfaraz_${safeFilenamePart(job.employer)}.pdf`}
@ -244,7 +244,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
</Button>
{/* Open job - to verify before applying */}
<Button asChild variant="outline" className="flex-1 h-9 gap-1 px-2 text-xs">
<Button asChild variant="outline" className="h-9 w-full gap-1 px-2 text-xs">
<a href={jobLink} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">Open Job Listing</span>
@ -255,7 +255,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
<Button
onClick={handleMarkApplied}
variant="default"
className="flex-1 h-9 gap-1 px-2 text-xs"
className="h-9 w-full gap-1 px-2 text-xs"
disabled={isMarkingApplied}
>
{isMarkingApplied ? (
@ -382,10 +382,10 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
Lightweight undo option after marking applied
*/}
{recentlyApplied && (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50">
<div className="flex items-center gap-3 rounded-lg border bg-card px-4 py-2 shadow-lg">
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 w-[calc(100%-2rem)] max-w-xl">
<div className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-2 shadow-lg">
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
<span className="text-sm">
<span className="min-w-0 flex-1 truncate text-sm">
<span className="font-medium">{recentlyApplied.jobTitle}</span>
<span className="text-muted-foreground"> marked applied</span>
</span>

View File

@ -154,14 +154,15 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
return (
<div className="space-y-4">
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex flex-col gap-2 pb-2 sm:flex-row sm:items-center sm:justify-between">
<h3 className="text-sm font-semibold text-muted-foreground">Editor</h3>
<div className="flex gap-2">
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
<Button
size="sm"
variant="outline"
onClick={handleSummarize}
disabled={isSummarizing || isGeneratingPdf || isSaving}
className="w-full sm:w-auto"
>
{isSummarizing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="mr-2 h-4 w-4" />}
AI Summarize
@ -170,6 +171,7 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
size="sm"
onClick={handleGeneratePdf}
disabled={isSummarizing || isGeneratingPdf || isSaving || !summary}
className="w-full sm:w-auto"
>
{isGeneratingPdf ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileText className="mr-2 h-4 w-4" />}
Generate PDF
@ -203,7 +205,7 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
<Separator />
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-start gap-2 sm:items-center sm:justify-between">
<label className="text-sm font-medium">Selected Projects</label>
{tooManyProjects && (
<span className="flex items-center gap-1 text-xs text-amber-600 font-medium">

View File

@ -40,12 +40,12 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
actions,
}) => (
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4">
<div className="flex items-center gap-3">
<div className="container mx-auto flex max-w-7xl flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 flex-wrap items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/60 bg-muted/30">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="leading-tight">
<div className="min-w-0 leading-tight">
<div className="text-sm font-semibold tracking-tight">{title}</div>
<div className="text-xs text-muted-foreground">{subtitle}</div>
</div>
@ -57,7 +57,7 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
{statusIndicator}
</div>
<div className="flex items-center gap-2">
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto sm:justify-end">
{nav?.map((item) => (
<Button key={item.to} asChild variant="ghost" size="icon" aria-label={item.label}>
<Link to={item.to}>
@ -132,7 +132,7 @@ interface ListPanelProps {
}
export const ListPanel: React.FC<ListPanelProps> = ({ children, header, footer, className }) => (
<div className={cn("rounded-xl border border-border/60 bg-card/40 flex flex-col", className)}>
<div className={cn("min-w-0 rounded-xl border border-border/60 bg-card/40 flex flex-col", className)}>
{header && <div className="border-b border-border/60 px-4 py-3">{header}</div>}
<div className="flex-1 divide-y divide-border/60 overflow-y-auto">{children}</div>
{footer && <div className="border-t border-border/60 px-4 py-2">{footer}</div>}
@ -178,7 +178,7 @@ interface DetailPanelProps {
export const DetailPanel: React.FC<DetailPanelProps> = ({ children, className, sticky = true }) => (
<div
className={cn(
"rounded-xl border border-border/60 bg-card/40 p-4",
"min-w-0 rounded-xl border border-border/60 bg-card/40 p-4",
sticky && "lg:sticky lg:top-24 lg:self-start",
className
)}
@ -254,11 +254,11 @@ interface FullHeightSplitProps {
export const FullHeightSplit: React.FC<FullHeightSplitProps> = ({
sidebar,
sidebarWidth = "w-[420px]",
sidebarWidth = "lg:w-[420px]",
children,
}) => (
<div className="flex flex-1 overflow-hidden">
<div className={cn("flex flex-col border-r", sidebarWidth)}>{sidebar}</div>
<div className="flex flex-1 flex-col overflow-hidden lg:flex-row">
<div className={cn("flex w-full flex-col border-b lg:border-b-0 lg:border-r", sidebarWidth)}>{sidebar}</div>
<div className="flex-1 overflow-y-auto">{children}</div>
</div>
);

View File

@ -47,6 +47,7 @@ import {
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
import { cn } from "@/lib/utils";
import { copyTextToClipboard, formatJobForWebhook } from "@client/lib/jobCopy";
import { PipelineProgress, DiscoveredPanel } from "../components";
@ -314,6 +315,10 @@ export const OrchestratorPage: React.FC = () => {
const [editedDescription, setEditedDescription] = useState("");
const [isSavingDescription, setIsSavingDescription] = useState(false);
const [hasUnsavedTailoring, setHasUnsavedTailoring] = useState(false);
const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
const [isDesktop, setIsDesktop] = useState(
() => (typeof window !== "undefined" ? window.matchMedia("(min-width: 1024px)").matches : false),
);
const saveTailoringRef = useRef<null | (() => Promise<void>)>(null);
const [pipelineSources, setPipelineSources] = useState<JobSource[]>(() => {
try {
@ -488,6 +493,31 @@ export const OrchestratorPage: React.FC = () => {
}
}, [activeJobs, selectedJobId]);
useEffect(() => {
if (!selectedJobId) {
setIsDetailDrawerOpen(false);
}
}, [selectedJobId]);
useEffect(() => {
if (typeof window === "undefined") return;
const media = window.matchMedia("(min-width: 1024px)");
const handleChange = () => setIsDesktop(media.matches);
handleChange();
if (media.addEventListener) {
media.addEventListener("change", handleChange);
return () => media.removeEventListener("change", handleChange);
}
media.addListener(handleChange);
return () => media.removeListener(handleChange);
}, []);
useEffect(() => {
if (isDesktop && isDetailDrawerOpen) {
setIsDetailDrawerOpen(false);
}
}, [isDesktop, isDetailDrawerOpen]);
const selectedJob = useMemo(
() => (selectedJobId ? jobs.find((job) => job.id === selectedJobId) ?? null : null),
[jobs, selectedJobId],
@ -598,332 +628,15 @@ export const OrchestratorPage: React.FC = () => {
const isProcessingSelected =
selectedJob ? processingJobId === selectedJob.id || selectedJob.status === "processing" : false;
const toggleSource = (source: JobSource, checked: boolean) => {
const next = checked
? Array.from(new Set([...pipelineSources, source]))
: pipelineSources.filter((s) => s !== source);
if (next.length === 0) return;
setPipelineSources(next);
const handleSelectJob = (jobId: string) => {
setSelectedJobId(jobId);
if (!isDesktop) {
setIsDetailDrawerOpen(true);
}
};
const counts = useMemo(() => {
const byTab: Record<FilterTab, number> = {
ready: 0,
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 byTab;
}, [jobs]);
return (
<>
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/60 bg-muted/30">
<Sparkles className="h-4 w-4 text-muted-foreground" />
</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>
{isPipelineRunning && (
<span className="inline-flex items-center gap-2 rounded-full border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-amber-200">
<span className="h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
Pipeline running
</span>
)}
</div>
<div className="flex items-center gap-2">
<Button asChild variant="ghost" size="icon" aria-label="Visa Sponsors search">
<Link to="/visa-sponsors">
<Shield className="h-4 w-4" />
</Link>
</Button>
<Button asChild variant="ghost" size="icon" aria-label="UK Visa Jobs search">
<Link to="/ukvisajobs">
<Search className="h-4 w-4" />
</Link>
</Button>
<Button asChild variant="ghost" size="icon" aria-label="Settings">
<Link to="/settings">
<Settings className="h-4 w-4" />
</Link>
</Button>
<div className="flex items-center gap-1">
<Button size="sm" onClick={handleRunPipeline} disabled={isPipelineRunning} className="gap-2">
{isPipelineRunning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
{isPipelineRunning ? "Running" : "Run pipeline"}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="outline" disabled={isPipelineRunning} aria-label="Select pipeline sources">
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Sources</DropdownMenuLabel>
<DropdownMenuSeparator />
{orderedSources.map((source) => (
<DropdownMenuCheckboxItem
key={source}
checked={pipelineSources.includes(source)}
onCheckedChange={(checked) => toggleSource(source, Boolean(checked))}
onSelect={(e) => e.preventDefault()}
>
{sourceLabel[source]}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
setPipelineSources(orderedSources);
}}
>
All sources
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
setPipelineSources(["gradcracker"]);
}}
>
Gradcracker only
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
setPipelineSources(["indeed", "linkedin"]);
}}
>
Indeed + LinkedIn only
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</header>
<main className="container mx-auto max-w-7xl space-y-6 px-4 py-6 pb-12">
<section className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight">Jobs</h1>
</div>
{isPipelineRunning && (
<div className="max-w-3xl">
<PipelineProgress isRunning={isPipelineRunning} />
</div>
)}
{/* Compact metrics summary - demoted visual weight */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground/80">
<span className="font-medium text-foreground/60">{totalJobs} jobs total</span>
<span className="text-border"></span>
<span><span className="tabular-nums">{stats.ready}</span> ready</span>
<span className="text-border"></span>
<span><span className="tabular-nums">{stats.discovered + stats.processing}</span> discovered</span>
<span className="text-border"></span>
<span><span className="tabular-nums">{stats.applied}</span> applied</span>
{(stats.skipped > 0 || stats.expired > 0) && (
<>
<span className="text-border"></span>
<span className="text-muted-foreground/60"><span className="tabular-nums">{stats.skipped + stats.expired}</span> skipped</span>
</>
)}
</div>
</section>
{/* Main content: tabs/filters -> list/detail */}
<section className="space-y-4">
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as FilterTab)}>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<TabsList className="h-9 w-full lg:w-auto">
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id} className="flex-1 flex items-center lg:flex-none gap-1.5">
<span>{tab.label}</span>
{counts[tab.id] > 0 && (
<span className="text-[10px] mt-[2px] tabular-nums opacity-60">{counts[tab.id]}</span>
)}
</TabsTrigger>
))}
</TabsList>
<div className="flex flex-wrap items-center gap-2">
<div className="relative w-full min-w-[180px] flex-1 lg:max-w-[240px] lg:flex-none">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" />
<Input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search..."
className="h-8 pl-8 text-sm"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs text-muted-foreground hover:text-foreground">
<Filter className="h-3.5 w-3.5" />
{sourceFilter === "all" ? "All sources" : sourceLabel[sourceFilter]}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Filter by source</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={sourceFilter}
onValueChange={(value) => setSourceFilter(value as JobSource | "all")}
>
<DropdownMenuRadioItem value="all">All Sources</DropdownMenuRadioItem>
{(Object.keys(sourceLabel) as JobSource[]).map((key) => (
<DropdownMenuRadioItem key={key} value={key}>
{sourceLabel[key]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs text-muted-foreground hover:text-foreground">
<ArrowUpDown className="h-3.5 w-3.5" />
{sortLabels[sort.key]}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={sort.key}
onValueChange={(value) =>
setSort({
key: value as JobSort["key"],
direction: defaultSortDirection[value as JobSort["key"]],
})
}
>
{(Object.keys(sortLabels) as Array<JobSort["key"]>).map((key) => (
<DropdownMenuRadioItem key={key} value={key}>
{sortLabels[key]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() =>
setSort((current) => ({
...current,
direction: current.direction === "asc" ? "desc" : "asc",
}))
}
>
Direction: {sort.direction === "asc" ? "Ascending" : "Descending"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</Tabs>
{/* List/Detail grid - directly under tabs, no extra section */}
<div className="grid gap-4 lg:grid-cols-[minmax(0,400px)_minmax(0,1fr)]">
{/* Primary region: Job list with highest visual weight */}
<div className="rounded-xl border border-border bg-card shadow-sm">
{isLoading && jobs.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<div className="text-sm text-muted-foreground">Loading jobs...</div>
</div>
) : activeJobs.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 px-6 py-12 text-center">
<div className="text-base font-semibold">No jobs found</div>
<p className="max-w-md text-sm text-muted-foreground">
{searchQuery.trim() ? `No jobs match "${searchQuery.trim()}".` : emptyStateCopy[activeTab]}
</p>
</div>
) : (
<div className="divide-y divide-border/40">
{activeJobs.map((job) => {
const isSelected = job.id === selectedJobId;
const hasScore = job.suitabilityScore != null;
const statusToken = statusTokens[job.status] ?? defaultStatusToken;
return (
<button
key={job.id}
type="button"
onClick={() => setSelectedJobId(job.id)}
className={cn(
"flex w-full items-center gap-3 px-4 py-3 text-left transition-colors",
isSelected
? "bg-primary/5 border-l-2 border-l-primary"
: "hover:bg-muted/20 border-l-2 border-l-transparent",
)}
aria-pressed={isSelected}
>
{/* Single status indicator: subtle dot */}
<span
className={cn(
"h-2 w-2 rounded-full shrink-0",
statusToken.dot,
!isSelected && "opacity-70"
)}
title={statusToken.label}
/>
{/* Primary content: title strongest, company secondary */}
<div className="min-w-0 flex-1">
<div className={cn(
"truncate text-sm leading-tight",
isSelected ? "font-semibold" : "font-medium"
)}>
{job.title}
</div>
<div className="truncate text-xs text-muted-foreground mt-0.5">
{job.employer}
{job.location && <span className="before:content-['_·_']">{job.location}</span>}
</div>
</div>
{/* Single triage cue: score only (status shown via dot) */}
{hasScore && (
<div className="shrink-0 text-right">
<span className={cn(
"text-xs tabular-nums",
job.suitabilityScore! >= 70 ? "text-emerald-400/90" :
job.suitabilityScore! >= 50 ? "text-foreground/60" :
"text-muted-foreground/60"
)}>
{job.suitabilityScore}
</span>
</div>
)}
</button>
);
})}
</div>
)}
</div>
{/* Inspector panel: visually subordinate to list */}
<div className="rounded-lg border border-border/40 bg-muted/5 p-4 lg:sticky lg:top-24 lg:self-start lg:max-h-[calc(100vh-8rem)] lg:overflow-y-auto">
{/* Use DiscoveredPanel for Discovered tab - two-mode triage workflow */}
{activeTab === "discovered" ? (
const detailPanelContent =
activeTab === "discovered" ? (
<DiscoveredPanel
job={selectedJob}
onJobUpdated={loadJobs}
@ -935,7 +648,7 @@ export const OrchestratorPage: React.FC = () => {
}}
/>
) : activeTab === "ready" ? (
/* ReadyPanel for Ready tab - shipping lane workflow: verify → download → apply → mark applied */
/* ReadyPanel for Ready tab - shipping lane workflow: verify + download + apply + mark applied */
<ReadyPanel
job={selectedJob}
onJobUpdated={loadJobs}
@ -1125,7 +838,7 @@ export const OrchestratorPage: React.FC = () => {
</DropdownMenu>
</div>
<Tabs value={detailTab} onValueChange={(value) => setDetailTab(value as typeof detailTab)}>
<TabsList className="h-8 text-xs">
<TabsList className="h-auto flex-wrap justify-start gap-1 text-xs">
<TabsTrigger value="overview" className="text-xs">Overview</TabsTrigger>
<TabsTrigger value="tailoring" className="text-xs">Tailoring</TabsTrigger>
<TabsTrigger value="description" className="text-xs">Description</TabsTrigger>
@ -1141,19 +854,19 @@ export const OrchestratorPage: React.FC = () => {
<div className="grid gap-2 text-xs sm:grid-cols-2">
<div>
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Discipline</div>
<div className="text-foreground/80">{selectedJob.disciplines || ""}</div>
<div className="text-foreground/80">{selectedJob.disciplines || "-"}</div>
</div>
<div>
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Function</div>
<div className="text-foreground/80">{selectedJob.jobFunction || ""}</div>
<div className="text-foreground/80">{selectedJob.jobFunction || "-"}</div>
</div>
<div>
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Level</div>
<div className="text-foreground/80">{selectedJob.jobLevel || ""}</div>
<div className="text-foreground/80">{selectedJob.jobLevel || "-"}</div>
</div>
<div>
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Type</div>
<div className="text-foreground/80">{selectedJob.jobType || ""}</div>
<div className="text-foreground/80">{selectedJob.jobType || "-"}</div>
</div>
</div>
@ -1300,11 +1013,372 @@ export const OrchestratorPage: React.FC = () => {
</TabsContent>
</Tabs>
</div>
);
const toggleSource = (source: JobSource, checked: boolean) => {
const next = checked
? Array.from(new Set([...pipelineSources, source]))
: pipelineSources.filter((s) => s !== source);
if (next.length === 0) return;
setPipelineSources(next);
};
const counts = useMemo(() => {
const byTab: Record<FilterTab, number> = {
ready: 0,
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 byTab;
}, [jobs]);
return (
<>
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto flex max-w-7xl flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 flex-wrap items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/60 bg-muted/30">
<Sparkles className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0 leading-tight">
<div className="text-sm font-semibold tracking-tight">Job Ops</div>
<div className="text-xs text-muted-foreground">Orchestrator</div>
</div>
{isPipelineRunning && (
<span className="inline-flex items-center gap-2 rounded-full border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-amber-200">
<span className="h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
Pipeline running
</span>
)}
</div>
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto sm:justify-end">
<Button asChild variant="ghost" size="icon" aria-label="Visa Sponsors search">
<Link to="/visa-sponsors">
<Shield className="h-4 w-4" />
</Link>
</Button>
<Button asChild variant="ghost" size="icon" aria-label="UK Visa Jobs search">
<Link to="/ukvisajobs">
<Search className="h-4 w-4" />
</Link>
</Button>
<Button asChild variant="ghost" size="icon" aria-label="Settings">
<Link to="/settings">
<Settings className="h-4 w-4" />
</Link>
</Button>
<div className="flex w-full items-center gap-1 sm:w-auto">
<Button
size="sm"
onClick={handleRunPipeline}
disabled={isPipelineRunning}
className="w-full gap-2 sm:w-auto"
>
{isPipelineRunning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
{isPipelineRunning ? "Running" : "Run pipeline"}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="outline"
disabled={isPipelineRunning}
aria-label="Select pipeline sources"
className="shrink-0"
>
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Sources</DropdownMenuLabel>
<DropdownMenuSeparator />
{orderedSources.map((source) => (
<DropdownMenuCheckboxItem
key={source}
checked={pipelineSources.includes(source)}
onCheckedChange={(checked) => toggleSource(source, Boolean(checked))}
onSelect={(e) => e.preventDefault()}
>
{sourceLabel[source]}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
setPipelineSources(orderedSources);
}}
>
All sources
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
setPipelineSources(["gradcracker"]);
}}
>
Gradcracker only
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
setPipelineSources(["indeed", "linkedin"]);
}}
>
Indeed + LinkedIn only
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</header>
<main className="container mx-auto max-w-7xl space-y-6 px-4 py-6 pb-12">
<section className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight">Jobs</h1>
</div>
{isPipelineRunning && (
<div className="max-w-3xl">
<PipelineProgress isRunning={isPipelineRunning} />
</div>
)}
{/* Compact metrics summary - demoted visual weight */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground/80">
<span className="font-medium text-foreground/60">{totalJobs} jobs total</span>
<span className="text-border"></span>
<span><span className="tabular-nums">{stats.ready}</span> ready</span>
<span className="text-border"></span>
<span><span className="tabular-nums">{stats.discovered + stats.processing}</span> discovered</span>
<span className="text-border"></span>
<span><span className="tabular-nums">{stats.applied}</span> applied</span>
{(stats.skipped > 0 || stats.expired > 0) && (
<>
<span className="text-border"></span>
<span className="text-muted-foreground/60"><span className="tabular-nums">{stats.skipped + stats.expired}</span> skipped</span>
</>
)}
</div>
</section>
{/* Main content: tabs/filters -> list/detail */}
<section className="space-y-4">
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as FilterTab)}>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<TabsList className="h-auto w-full flex-wrap justify-start gap-1 lg:w-auto">
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id} className="flex-1 flex items-center lg:flex-none gap-1.5">
<span>{tab.label}</span>
{counts[tab.id] > 0 && (
<span className="text-[10px] mt-[2px] tabular-nums opacity-60">{counts[tab.id]}</span>
)}
</TabsTrigger>
))}
</TabsList>
<div className="flex flex-wrap items-center gap-2">
<div className="relative w-full min-w-0 flex-1 sm:min-w-[180px] lg:max-w-[240px] lg:flex-none">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" />
<Input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search..."
className="h-8 pl-8 text-sm"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-full gap-1.5 text-xs text-muted-foreground hover:text-foreground sm:w-auto"
>
<Filter className="h-3.5 w-3.5" />
{sourceFilter === "all" ? "All sources" : sourceLabel[sourceFilter]}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Filter by source</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={sourceFilter}
onValueChange={(value) => setSourceFilter(value as JobSource | "all")}
>
<DropdownMenuRadioItem value="all">All Sources</DropdownMenuRadioItem>
{(Object.keys(sourceLabel) as JobSource[]).map((key) => (
<DropdownMenuRadioItem key={key} value={key}>
{sourceLabel[key]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-full gap-1.5 text-xs text-muted-foreground hover:text-foreground sm:w-auto"
>
<ArrowUpDown className="h-3.5 w-3.5" />
{sortLabels[sort.key]}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={sort.key}
onValueChange={(value) =>
setSort({
key: value as JobSort["key"],
direction: defaultSortDirection[value as JobSort["key"]],
})
}
>
{(Object.keys(sortLabels) as Array<JobSort["key"]>).map((key) => (
<DropdownMenuRadioItem key={key} value={key}>
{sortLabels[key]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() =>
setSort((current) => ({
...current,
direction: current.direction === "asc" ? "desc" : "asc",
}))
}
>
Direction: {sort.direction === "asc" ? "Ascending" : "Descending"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</Tabs>
{/* List/Detail grid - directly under tabs, no extra section */}
<div className="grid gap-4 lg:grid-cols-[minmax(0,400px)_minmax(0,1fr)]">
{/* Primary region: Job list with highest visual weight */}
<div className="min-w-0 rounded-xl border border-border bg-card shadow-sm">
{isLoading && jobs.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<div className="text-sm text-muted-foreground">Loading jobs...</div>
</div>
) : activeJobs.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 px-6 py-12 text-center">
<div className="text-base font-semibold">No jobs found</div>
<p className="max-w-md text-sm text-muted-foreground">
{searchQuery.trim() ? `No jobs match "${searchQuery.trim()}".` : emptyStateCopy[activeTab]}
</p>
</div>
) : (
<div className="divide-y divide-border/40">
{activeJobs.map((job) => {
const isSelected = job.id === selectedJobId;
const hasScore = job.suitabilityScore != null;
const statusToken = statusTokens[job.status] ?? defaultStatusToken;
return (
<button
key={job.id}
type="button"
onClick={() => handleSelectJob(job.id)}
className={cn(
"flex w-full items-center gap-3 px-4 py-3 text-left transition-colors",
isSelected
? "bg-primary/5 border-l-2 border-l-primary"
: "hover:bg-muted/20 border-l-2 border-l-transparent",
)}
aria-pressed={isSelected}
>
{/* Single status indicator: subtle dot */}
<span
className={cn(
"h-2 w-2 rounded-full shrink-0",
statusToken.dot,
!isSelected && "opacity-70"
)}
title={statusToken.label}
/>
{/* Primary content: title strongest, company secondary */}
<div className="min-w-0 flex-1">
<div className={cn(
"truncate text-sm leading-tight",
isSelected ? "font-semibold" : "font-medium"
)}>
{job.title}
</div>
<div className="truncate text-xs text-muted-foreground mt-0.5">
{job.employer}
{job.location && <span className="before:content-['_·_']">{job.location}</span>}
</div>
</div>
{/* Single triage cue: score only (status shown via dot) */}
{hasScore && (
<div className="shrink-0 text-right">
<span className={cn(
"text-xs tabular-nums",
job.suitabilityScore! >= 70 ? "text-emerald-400/90" :
job.suitabilityScore! >= 50 ? "text-foreground/60" :
"text-muted-foreground/60"
)}>
{job.suitabilityScore}
</span>
</div>
)}
</button>
);
})}
</div>
)}
</div>
{/* Inspector panel: visually subordinate to list */}
<div className="min-w-0 rounded-lg border border-border/40 bg-muted/5 p-4 lg:sticky lg:top-24 lg:self-start lg:max-h-[calc(100vh-8rem)] lg:overflow-y-auto hidden lg:block">
{detailPanelContent}
</div>
</div>
</section>
</main>
<Drawer open={isDetailDrawerOpen} onOpenChange={setIsDetailDrawerOpen}>
<DrawerContent className="max-h-[90vh]">
<div className="flex items-center justify-between px-4 pt-2">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Job details</div>
<DrawerClose asChild>
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs">
Close
</Button>
</DrawerClose>
</div>
<div className="max-h-[calc(90vh-3.5rem)] overflow-y-auto px-4 pb-6 pt-3">
{detailPanelContent}
</div>
</DrawerContent>
</Drawer>
</>
);
};

View File

@ -27,6 +27,7 @@ import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
import { cn } from "@/lib/utils";
import * as api from "../api";
import type { CreateJobInput } from "../../shared/types";
@ -86,6 +87,10 @@ export const UkVisaJobsPage: React.FC = () => {
const [totalJobs, setTotalJobs] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
const [isDesktop, setIsDesktop] = useState(
() => (typeof window !== "undefined" ? window.matchMedia("(min-width: 1024px)").matches : false),
);
useEffect(() => {
if (results.length === 0) {
@ -98,6 +103,31 @@ export const UkVisaJobsPage: React.FC = () => {
}
}, [results, selectedJobId]);
useEffect(() => {
if (!selectedJobId) {
setIsDetailDrawerOpen(false);
}
}, [selectedJobId]);
useEffect(() => {
if (typeof window === "undefined") return;
const media = window.matchMedia("(min-width: 1024px)");
const handleChange = () => setIsDesktop(media.matches);
handleChange();
if (media.addEventListener) {
media.addEventListener("change", handleChange);
return () => media.removeEventListener("change", handleChange);
}
media.addListener(handleChange);
return () => media.removeListener(handleChange);
}, []);
useEffect(() => {
if (isDesktop && isDetailDrawerOpen) {
setIsDetailDrawerOpen(false);
}
}, [isDesktop, isDetailDrawerOpen]);
useEffect(() => {
setSelectedJobIds(new Set());
}, [results]);
@ -204,15 +234,111 @@ export const UkVisaJobsPage: React.FC = () => {
const canGoPrev = page > 1;
const canGoNext = page < totalPages;
const handleSelectJob = (jobId: string) => {
setSelectedJobId(jobId);
if (!isDesktop) {
setIsDetailDrawerOpen(true);
}
};
const detailPanelContent = !selectedJob ? (
<div className="flex h-full flex-col items-center justify-center gap-2 text-center">
<div className="text-base font-semibold">Select a job</div>
<p className="text-sm text-muted-foreground">Pick a job from the list to inspect details.</p>
</div>
) : (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-base font-semibold">{selectedJob.title}</div>
<div className="text-sm text-muted-foreground">{selectedJob.employer}</div>
</div>
<Badge variant="outline" className="uppercase tracking-wide">
UK Visa Jobs
</Badge>
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
{selectedJob.location && (
<span className="flex items-center gap-1">
<MapPin className="h-3.5 w-3.5" />
{selectedJob.location}
</span>
)}
{selectedDeadline && (
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
{selectedDeadline}
</span>
)}
{selectedPosted && (
<span className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
Posted {selectedPosted}
</span>
)}
{selectedJob.salary && (
<span className="flex items-center gap-1">
<DollarSign className="h-3.5 w-3.5" />
{selectedJob.salary}
</span>
)}
{selectedJob.degreeRequired && (
<span className="flex items-center gap-1">
<GraduationCap className="h-3.5 w-3.5" />
{selectedJob.degreeRequired}
</span>
)}
</div>
<div className="grid gap-3 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Job type</div>
<div className="font-medium">{selectedJob.jobType || "Not set"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Job level</div>
<div className="font-medium">{selectedJob.jobLevel || "Not set"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Location</div>
<div className="font-medium">{selectedJob.location || "Not set"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Deadline</div>
<div className="font-medium">{selectedDeadline || "Not set"}</div>
</div>
</div>
<Separator />
<Button asChild size="sm" variant="outline" className="w-full gap-2 sm:w-auto">
<a href={selectedJobLink} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
View job
</a>
</Button>
<div className="space-y-2">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Description
</div>
<div className="rounded-lg border border-border/60 bg-muted/10 p-3 text-sm text-muted-foreground whitespace-pre-wrap">
{selectedDescription}
</div>
</div>
</div>
);
return (
<>
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4">
<div className="flex items-center gap-3">
<div className="container mx-auto flex max-w-7xl flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 flex-wrap items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/60 bg-muted/30">
<Briefcase className="h-4 w-4 text-muted-foreground" />
</div>
<div className="leading-tight">
<div className="min-w-0 leading-tight">
<div className="text-sm font-semibold tracking-tight">UK Visa Jobs</div>
<div className="text-xs text-muted-foreground">Live search console</div>
</div>
@ -221,7 +347,7 @@ export const UkVisaJobsPage: React.FC = () => {
</Badge>
</div>
<div className="flex items-center gap-2">
<div className="flex w-full items-center gap-2 sm:w-auto sm:justify-end">
<Button asChild variant="ghost" size="icon" aria-label="Back to orchestrator">
<Link to="/">
<ArrowLeft className="h-4 w-4" />
@ -270,11 +396,11 @@ export const UkVisaJobsPage: React.FC = () => {
<Separator className="my-4" />
<div className="flex flex-wrap items-center justify-between gap-3 text-xs text-muted-foreground">
<div className="flex flex-col gap-2 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<div>
Last run: {lastRunAt ? formatDateTime(lastRunAt) : "No searches yet"}
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="text-xs">
{totalJobs} total
</Badge>
@ -290,7 +416,7 @@ export const UkVisaJobsPage: React.FC = () => {
</section>
<section className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,420px)]">
<div className="relative rounded-xl border border-border/60 bg-card/40">
<div className="relative min-w-0 rounded-xl border border-border/60 bg-card/40">
{isSearching && results.length > 0 && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 rounded-xl bg-background/70 text-sm text-muted-foreground backdrop-blur-sm">
<Loader2 className="h-5 w-5 animate-spin" />
@ -338,7 +464,7 @@ export const UkVisaJobsPage: React.FC = () => {
<Button
variant="outline"
size="sm"
className="gap-2"
className="w-full gap-2 sm:w-auto"
onClick={handleImportSelected}
disabled={selectedCount === 0 || isImporting}
>
@ -358,11 +484,11 @@ export const UkVisaJobsPage: React.FC = () => {
key={key}
role="button"
tabIndex={0}
onClick={() => setSelectedJobId(key)}
onClick={() => handleSelectJob(key)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setSelectedJobId(key);
handleSelectJob(key);
}
}}
className={cn(
@ -443,7 +569,7 @@ export const UkVisaJobsPage: React.FC = () => {
<span>
Showing {summaryCounts.startIndex}-{summaryCounts.endIndex} of {totalJobs}
</span>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
@ -473,98 +599,27 @@ export const UkVisaJobsPage: React.FC = () => {
)}
</div>
<div className="rounded-xl border border-border/60 bg-card/40 p-4 lg:sticky lg:top-24 lg:self-start">
{!selectedJob ? (
<div className="flex h-full flex-col items-center justify-center gap-2 text-center">
<div className="text-base font-semibold">Select a job</div>
<p className="text-sm text-muted-foreground">Pick a job from the list to inspect details.</p>
</div>
) : (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-base font-semibold">{selectedJob.title}</div>
<div className="text-sm text-muted-foreground">{selectedJob.employer}</div>
</div>
<Badge variant="outline" className="uppercase tracking-wide">
UK Visa Jobs
</Badge>
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
{selectedJob.location && (
<span className="flex items-center gap-1">
<MapPin className="h-3.5 w-3.5" />
{selectedJob.location}
</span>
)}
{selectedDeadline && (
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
{selectedDeadline}
</span>
)}
{selectedPosted && (
<span className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
Posted {selectedPosted}
</span>
)}
{selectedJob.salary && (
<span className="flex items-center gap-1">
<DollarSign className="h-3.5 w-3.5" />
{selectedJob.salary}
</span>
)}
{selectedJob.degreeRequired && (
<span className="flex items-center gap-1">
<GraduationCap className="h-3.5 w-3.5" />
{selectedJob.degreeRequired}
</span>
)}
</div>
<div className="grid gap-3 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Job type</div>
<div className="font-medium">{selectedJob.jobType || "Not set"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Job level</div>
<div className="font-medium">{selectedJob.jobLevel || "Not set"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Location</div>
<div className="font-medium">{selectedJob.location || "Not set"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Deadline</div>
<div className="font-medium">{selectedDeadline || "Not set"}</div>
</div>
</div>
<Separator />
<Button asChild size="sm" variant="outline" className="gap-2">
<a href={selectedJobLink} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
View job
</a>
</Button>
<div className="space-y-2">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Description
</div>
<div className="rounded-lg border border-border/60 bg-muted/10 p-3 text-sm text-muted-foreground whitespace-pre-wrap">
{selectedDescription}
</div>
</div>
</div>
)}
<div className="min-w-0 rounded-xl border border-border/60 bg-card/40 p-4 lg:sticky lg:top-24 lg:self-start hidden lg:block">
{detailPanelContent}
</div>
</section>
</main>
<Drawer open={isDetailDrawerOpen} onOpenChange={setIsDetailDrawerOpen}>
<DrawerContent className="max-h-[90vh]">
<div className="flex items-center justify-between px-4 pt-2">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Job details</div>
<DrawerClose asChild>
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs">
Close
</Button>
</DrawerClose>
</div>
<div className="max-h-[calc(90vh-3.5rem)] overflow-y-auto px-4 pb-6 pt-3">
{detailPanelContent}
</div>
</DrawerContent>
</Drawer>
</>
);
};

View File

@ -24,6 +24,7 @@ import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
import { cn } from "@/lib/utils";
import {
PageHeader,
@ -86,6 +87,10 @@ export const VisaSponsorsPage: React.FC = () => {
const [isSearching, setIsSearching] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
const [isDesktop, setIsDesktop] = useState(
() => (typeof window !== "undefined" ? window.matchMedia("(min-width: 1024px)").matches : false),
);
// Fetch status on mount
useEffect(() => {
@ -152,6 +157,31 @@ export const VisaSponsorsPage: React.FC = () => {
}
}, [results]);
useEffect(() => {
if (!selectedOrg) {
setIsDetailDrawerOpen(false);
}
}, [selectedOrg]);
useEffect(() => {
if (typeof window === "undefined") return;
const media = window.matchMedia("(min-width: 1024px)");
const handleChange = () => setIsDesktop(media.matches);
handleChange();
if (media.addEventListener) {
media.addEventListener("change", handleChange);
return () => media.removeEventListener("change", handleChange);
}
media.addListener(handleChange);
return () => media.removeListener(handleChange);
}, []);
useEffect(() => {
if (isDesktop && isDetailDrawerOpen) {
setIsDetailDrawerOpen(false);
}
}, [isDesktop, isDetailDrawerOpen]);
// Fetch organization details
const fetchOrgDetails = async (orgName: string) => {
setIsLoadingDetails(true);
@ -186,6 +216,13 @@ export const VisaSponsorsPage: React.FC = () => {
}
};
const handleSelectOrg = (orgName: string) => {
fetchOrgDetails(orgName);
if (!isDesktop) {
setIsDetailDrawerOpen(true);
}
};
const selectedResult = useMemo(
() => results.find((r) => r.sponsor.organisationName === selectedOrg) ?? null,
[results, selectedOrg]
@ -193,6 +230,89 @@ export const VisaSponsorsPage: React.FC = () => {
const isUpdateInProgress = isUpdating || status?.isUpdating;
const detailPanelContent = !selectedOrg ? (
<div className="flex h-full flex-col items-center justify-center gap-2 text-center">
<div className="text-base font-semibold">Select a company</div>
<p className="text-sm text-muted-foreground">
Pick a company from the results to see details here.
</p>
</div>
) : isLoadingDetails ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-4">
{/* Header */}
<div>
<div className="flex items-center gap-2 mb-2">
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-emerald-200">
<CheckCircle2 className="h-3 w-3" />
Licensed Sponsor
</span>
{selectedResult && (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide",
getScoreTokens(selectedResult.score).badge
)}
>
{selectedResult.score}% Match
</span>
)}
</div>
<h2 className="text-lg font-semibold text-foreground">{selectedOrg}</h2>
</div>
{/* Location */}
{orgDetails.length > 0 && (orgDetails[0].townCity || orgDetails[0].county) && (
<div>
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-1">
Location
</div>
<div className="flex items-center gap-2 text-sm text-foreground">
<MapPin className="h-4 w-4 text-muted-foreground" />
{[orgDetails[0].townCity, orgDetails[0].county].filter(Boolean).join(", ")}
</div>
</div>
)}
{/* Licence types / routes */}
<div>
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-2">
Licensed Routes ({orgDetails.length})
</div>
<div className="space-y-2">
{orgDetails.map((entry, index) => (
<div
key={index}
className="rounded-lg border border-border/60 bg-muted/20 p-3"
>
<div className="flex items-start justify-between gap-2 mb-1">
<Badge variant="secondary" className="text-xs">
{entry.route}
</Badge>
</div>
<div className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">Type & Rating:</span>{" "}
{entry.typeRating}
</div>
</div>
))}
</div>
</div>
{/* Info box */}
<div className="rounded-lg border border-sky-500/30 bg-sky-500/10 p-3 text-sm">
<div className="font-medium text-sky-200 mb-1">What does this mean?</div>
<p className="text-xs text-sky-300/80">
This organisation is licensed by the UK Home Office to sponsor workers on the
routes listed above. An "A rating" means they're fully compliant.
</p>
</div>
</div>
);
return (
<>
<PageHeader
@ -323,7 +443,7 @@ export const VisaSponsorsPage: React.FC = () => {
<ListItem
key={`${result.sponsor.organisationName}-${index}`}
selected={selectedOrg === result.sponsor.organisationName}
onClick={() => fetchOrgDetails(result.sponsor.organisationName)}
onClick={() => handleSelectOrg(result.sponsor.organisationName)}
className="gap-3"
>
<div className="flex-1 min-w-0">
@ -351,92 +471,27 @@ export const VisaSponsorsPage: React.FC = () => {
</ListPanel>
{/* Right panel - Details */}
<DetailPanel>
{!selectedOrg ? (
<div className="flex h-full flex-col items-center justify-center gap-2 text-center">
<div className="text-base font-semibold">Select a company</div>
<p className="text-sm text-muted-foreground">
Pick a company from the results to see details here.
</p>
</div>
) : isLoadingDetails ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-4">
{/* Header */}
<div>
<div className="flex items-center gap-2 mb-2">
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-emerald-200">
<CheckCircle2 className="h-3 w-3" />
Licensed Sponsor
</span>
{selectedResult && (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide",
getScoreTokens(selectedResult.score).badge
)}
>
{selectedResult.score}% Match
</span>
)}
</div>
<h2 className="text-lg font-semibold text-foreground">{selectedOrg}</h2>
</div>
{/* Location */}
{orgDetails.length > 0 && (orgDetails[0].townCity || orgDetails[0].county) && (
<div>
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-1">
Location
</div>
<div className="flex items-center gap-2 text-sm text-foreground">
<MapPin className="h-4 w-4 text-muted-foreground" />
{[orgDetails[0].townCity, orgDetails[0].county].filter(Boolean).join(", ")}
</div>
</div>
)}
{/* Licence types / routes */}
<div>
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-2">
Licensed Routes ({orgDetails.length})
</div>
<div className="space-y-2">
{orgDetails.map((entry, index) => (
<div
key={index}
className="rounded-lg border border-border/60 bg-muted/20 p-3"
>
<div className="flex items-start justify-between gap-2 mb-1">
<Badge variant="secondary" className="text-xs">
{entry.route}
</Badge>
</div>
<div className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">Type & Rating:</span>{" "}
{entry.typeRating}
</div>
</div>
))}
</div>
</div>
{/* Info box */}
<div className="rounded-lg border border-sky-500/30 bg-sky-500/10 p-3 text-sm">
<div className="font-medium text-sky-200 mb-1">What does this mean?</div>
<p className="text-xs text-sky-300/80">
This organisation is licensed by the UK Home Office to sponsor workers on the
routes listed above. An "A rating" means they're fully compliant.
</p>
</div>
</div>
)}
<DetailPanel className="hidden lg:block">
{detailPanelContent}
</DetailPanel>
</SplitLayout>
</PageMain>
<Drawer open={isDetailDrawerOpen} onOpenChange={setIsDetailDrawerOpen}>
<DrawerContent className="max-h-[90vh]">
<div className="flex items-center justify-between px-4 pt-2">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Sponsor details</div>
<DrawerClose asChild>
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs">
Close
</Button>
</DrawerClose>
</div>
<div className="max-h-[calc(90vh-3.5rem)] overflow-y-auto px-4 pb-6 pt-3">
{detailPanelContent}
</div>
</DrawerContent>
</Drawer>
</>
);
};

View File

@ -0,0 +1,116 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}