Merge pull request #5 from DaKheera47/navbar-and-ui-improvements

Navbar and UI improvements
This commit is contained in:
Shaheer Sarfaraz 2026-01-18 01:41:28 +00:00 committed by GitHub
commit 3b2aa70e8c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 603 additions and 171 deletions

View File

@ -27,6 +27,7 @@
"lucide-react": "^0.561.0",
"next-themes": "^0.4.6",
"react-markdown": "^10.1.0",
"react-transition-group": "^4.4.5",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
@ -45,6 +46,7 @@
"@types/node": "^22.10.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/react-transition-group": "^4.4.12",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.22",
"concurrently": "^9.1.0",
@ -336,7 +338,6 @@
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"dev": true,
"engines": {
"node": ">=6.9.0"
}
@ -3254,6 +3255,15 @@
"@types/react": "^18.0.0"
}
},
"node_modules/@types/react-transition-group": {
"version": "4.4.12",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
"integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==",
"dev": true,
"peerDependencies": {
"@types/react": "*"
}
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
@ -4199,6 +4209,15 @@
"dev": true,
"peer": true
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dotenv": {
"version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
@ -6855,6 +6874,21 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/prop-types/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/property-information": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
@ -7138,6 +7172,21 @@
}
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",

View File

@ -7,7 +7,7 @@
"scripts": {
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
"dev:server": "tsx watch src/server/index.ts",
"dev:client": "vite",
"dev:client": "vite --host",
"build": "npm run build:client && npm run build:server",
"build:server": "tsc -p tsconfig.server.json",
"build:client": "vite build",
@ -39,6 +39,7 @@
"lucide-react": "^0.561.0",
"next-themes": "^0.4.6",
"react-markdown": "^10.1.0",
"react-transition-group": "^4.4.5",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
@ -57,6 +58,7 @@
"@types/node": "^22.10.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/react-transition-group": "^4.4.12",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.22",
"concurrently": "^9.1.0",

View File

@ -2,8 +2,9 @@
* Main App component.
*/
import React from "react";
import { Route, Routes } from "react-router-dom";
import React, { useRef } from "react";
import { Route, Routes, useLocation } from "react-router-dom";
import { CSSTransition, SwitchTransition } from "react-transition-group";
import { Toaster } from "@/components/ui/sonner";
import { OrchestratorPage } from "./pages/OrchestratorPage";
@ -11,15 +12,32 @@ import { SettingsPage } from "./pages/SettingsPage";
import { UkVisaJobsPage } from "./pages/UkVisaJobsPage";
import { VisaSponsorsPage } from "./pages/VisaSponsorsPage";
export const App: React.FC = () => (
<>
<Routes>
<Route path="/" element={<OrchestratorPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/ukvisajobs" element={<UkVisaJobsPage />} />
<Route path="/visa-sponsors" element={<VisaSponsorsPage />} />
</Routes>
export const App: React.FC = () => {
const location = useLocation();
const nodeRef = useRef<HTMLDivElement>(null);
<Toaster position="bottom-right" richColors closeButton />
</>
);
return (
<>
<SwitchTransition mode="out-in">
<CSSTransition
key={location.pathname}
nodeRef={nodeRef}
timeout={100}
classNames="page"
unmountOnExit
>
<div ref={nodeRef}>
<Routes location={location}>
<Route path="/" element={<OrchestratorPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/ukvisajobs" element={<UkVisaJobsPage />} />
<Route path="/visa-sponsors" element={<VisaSponsorsPage />} />
</Routes>
</div>
</CSSTransition>
</SwitchTransition>
<Toaster position="bottom-right" richColors closeButton />
</>
);
};

View File

@ -4,28 +4,19 @@
import React from "react";
import {
Briefcase,
ChevronDown,
Home,
Loader2,
Menu,
Play,
RefreshCcw,
Settings,
Shield,
Trash2,
} from "lucide-react";
import { Link } from "react-router-dom";
import { Link, useLocation } from "react-router-dom";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
@ -35,6 +26,13 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import type { JobSource } from "../../shared/types";
interface HeaderProps {
@ -54,6 +52,9 @@ export const Header: React.FC<HeaderProps> = ({
pipelineSources,
onPipelineSourcesChange,
}) => {
const location = useLocation();
const [sheetOpen, setSheetOpen] = React.useState(false);
const sourceLabel: Record<JobSource, string> = {
gradcracker: "Gradcracker",
indeed: "Indeed",
@ -63,6 +64,13 @@ export const Header: React.FC<HeaderProps> = ({
const orderedSources: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"];
const navLinks = [
{ to: "/", label: "Dashboard", icon: Home },
{ to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield },
{ to: "/ukvisajobs", label: "UK Visa Jobs", icon: Briefcase },
{ to: "/settings", label: "Settings", icon: Settings },
];
const toggleSource = (source: JobSource, checked: boolean) => {
const next = checked
? Array.from(new Set([...pipelineSources, source]))
@ -75,22 +83,57 @@ export const Header: React.FC<HeaderProps> = ({
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'>
<Link
to='/'
className='flex items-center gap-3 hover:opacity-80 transition-opacity'
>
<div className='flex h-9 w-9 items-center justify-center overflow-hidden rounded-lg bg-transparent shadow-sm'>
<img
src='/favicon.png'
alt='Job Ops Logo'
className='h-full w-full object-contain'
/>
</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>
</Link>
<div className='flex items-center gap-3'>
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetTrigger asChild>
<Button variant='ghost' size='icon'>
<Menu className='h-5 w-5' />
<span className='sr-only'>Open navigation menu</span>
</Button>
</SheetTrigger>
<SheetContent side='left' className='w-64'>
<SheetHeader>
<SheetTitle>
JobOps
</SheetTitle>
</SheetHeader>
<nav className='mt-6 flex flex-col gap-2'>
{navLinks.map(({ to, label, icon: Icon }) => (
<Link
key={to}
to={to}
onClick={() => setSheetOpen(false)}
className={`flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground ${
location.pathname === to
? "bg-accent text-accent-foreground"
: "text-muted-foreground"
}`}
>
<Icon className='h-4 w-4' />
{label}
</Link>
))}
</nav>
</SheetContent>
</Sheet>
<Link
to='/'
className='flex items-center gap-3 hover:opacity-80 transition-opacity'
>
<div className='flex h-9 w-9 items-center justify-center overflow-hidden rounded-lg bg-transparent shadow-sm'>
<img
src='/favicon.png'
alt='Job Ops Logo'
className='h-full w-full object-contain'
/>
</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>
</Link>
</div>
<div className='flex flex-wrap items-center gap-1.5'>
<Button
@ -103,28 +146,6 @@ export const Header: React.FC<HeaderProps> = ({
<span className='hidden sm:inline'>Refresh</span>
</Button>
<Button
asChild
variant='outline'
size='sm'
>
<Link to='/visa-sponsors'>
<Shield className='h-4 w-4' />
<span className='hidden sm:inline'>Visa Sponsors</span>
</Link>
</Button>
<Button
asChild
variant='outline'
size='sm'
>
<Link to='/settings'>
<Settings className='h-4 w-4' />
<span className='hidden sm:inline'>Settings</span>
</Link>
</Button>
<div>
<Button
size='sm'

View File

@ -2,23 +2,31 @@
* Shared layout components for consistent page structure.
*/
import React from "react";
import { Link } from "react-router-dom";
import { LucideIcon } from "lucide-react";
import React, { useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { Briefcase, Home, LucideIcon, Menu, Settings, Shield } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
// ============================================================================
// Page Header
// ============================================================================
interface HeaderNavItem {
icon: LucideIcon;
label: string;
to: string;
}
const navLinks = [
{ to: "/", label: "Dashboard", icon: Home },
{ to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield },
{ to: "/ukvisajobs", label: "UK Visa Jobs", icon: Briefcase },
{ to: "/settings", label: "Settings", icon: Settings },
];
interface PageHeaderProps {
icon: LucideIcon;
@ -26,7 +34,6 @@ interface PageHeaderProps {
subtitle: string;
badge?: string;
statusIndicator?: React.ReactNode;
nav?: HeaderNavItem[];
actions?: React.ReactNode;
}
@ -36,40 +43,79 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
subtitle,
badge,
statusIndicator,
nav,
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 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="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>
{badge && (
<Badge variant="outline" className="uppercase tracking-wide">
{badge}
</Badge>
)}
{statusIndicator}
</div>
}) => {
const location = useLocation();
const navigate = useNavigate();
const [navOpen, setNavOpen] = useState(false);
<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}>
<item.icon className="h-4 w-4" />
</Link>
</Button>
))}
{actions}
const handleNavClick = (to: string) => {
if (location.pathname === to) {
setNavOpen(false);
return;
}
setNavOpen(false);
setTimeout(() => navigate(to), 150);
};
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">
<Sheet open={navOpen} onOpenChange={setNavOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon">
<Menu className="h-5 w-5" />
<span className="sr-only">Open navigation menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-64">
<SheetHeader>
<SheetTitle>JobOps</SheetTitle>
</SheetHeader>
<nav className="mt-6 flex flex-col gap-2">
{navLinks.map(({ to, label, icon: NavIcon }) => (
<button
key={to}
type="button"
onClick={() => handleNavClick(to)}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left",
location.pathname === to
? "bg-accent text-accent-foreground"
: "text-muted-foreground"
)}
>
<NavIcon className="h-4 w-4" />
{label}
</button>
))}
</nav>
</SheetContent>
</Sheet>
<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="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>
{badge && (
<Badge variant="outline" className="uppercase tracking-wide">
{badge}
</Badge>
)}
{statusIndicator}
</div>
<div className="flex items-center gap-2">
{actions}
</div>
</div>
</div>
</header>
);
</header>
);
};
// ============================================================================
// Status Indicator (Pipeline running, Updating, etc.)

View File

@ -5,6 +5,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ArrowUpDown,
Briefcase,
Calendar,
CheckCircle2,
ChevronDown,
@ -14,8 +15,10 @@ import {
ExternalLink,
FileText,
Filter,
Home,
Loader2,
MapPin,
Menu,
MoreHorizontal,
Play,
RefreshCcw,
@ -26,7 +29,7 @@ import {
Sparkles,
XCircle,
} from "lucide-react";
import { Link } from "react-router-dom";
import { Link, useLocation, useNavigate } from "react-router-dom";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { toast } from "sonner";
@ -48,6 +51,13 @@ 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 {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import { copyTextToClipboard, formatJobForWebhook } from "@client/lib/jobCopy";
import { PipelineProgress, DiscoveredPanel } from "../components";
@ -293,6 +303,9 @@ const ScoreMeter: React.FC<{ score: number | null }> = ({ score }) => {
};
export const OrchestratorPage: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const [navOpen, setNavOpen] = useState(false);
const [jobs, setJobs] = useState<Job[]>([]);
const [stats, setStats] = useState<Record<JobStatus, number>>({
discovered: 0,
@ -302,6 +315,13 @@ export const OrchestratorPage: React.FC = () => {
skipped: 0,
expired: 0,
});
const navLinks = [
{ to: "/", label: "Dashboard", icon: Home },
{ to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield },
{ to: "/ukvisajobs", label: "UK Visa Jobs", icon: Briefcase },
{ to: "/settings", label: "Settings", icon: Settings },
];
const [isLoading, setIsLoading] = useState(true);
const [isPipelineRunning, setIsPipelineRunning] = useState(false);
const [processingJobId, setProcessingJobId] = useState<string | null>(null);
@ -1044,49 +1064,74 @@ export const OrchestratorPage: React.FC = () => {
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 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">
<Sheet open={navOpen} onOpenChange={setNavOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon">
<Menu className="h-5 w-5" />
<span className="sr-only">Open navigation menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-64">
<SheetHeader>
<SheetTitle>JobOps</SheetTitle>
</SheetHeader>
<nav className="mt-6 flex flex-col gap-2">
{navLinks.map(({ to, label, icon: Icon }) => (
<button
key={to}
type="button"
onClick={() => {
if (location.pathname === to) {
setNavOpen(false);
return;
}
setNavOpen(false);
setTimeout(() => navigate(to), 150);
}}
className={`flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left ${
location.pathname === to
? "bg-accent text-accent-foreground"
: "text-muted-foreground"
}`}
>
<Icon className="h-4 w-4" />
{label}
</button>
))}
</nav>
</SheetContent>
</Sheet>
<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="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>
</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="hidden sm: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">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<Button
size="sm"
onClick={handleRunPipeline}
disabled={isPipelineRunning}
className="w-full gap-2 sm:w-auto"
className="gap-2"
>
{isPipelineRunning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
{isPipelineRunning ? "Running" : "Run pipeline"}
<span className="hidden sm:inline">{isPipelineRunning ? "Running" : "Run pipeline"}</span>
</Button>
<DropdownMenu>
@ -1160,13 +1205,13 @@ export const OrchestratorPage: React.FC = () => {
{/* 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>
<span className="text-border"></span>
<span className="font-medium text-foreground/60">{totalJobs} jobs total</span>
{(stats.skipped > 0 || stats.expired > 0) && (
<>
<span className="text-border"></span>
@ -1191,8 +1236,8 @@ export const OrchestratorPage: React.FC = () => {
))}
</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">
<div className="flex lg:flex-nowrap flex-wrap items-center justify-end gap-2">
<div className="relative w-full flex-1 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}
@ -1201,13 +1246,12 @@ export const OrchestratorPage: React.FC = () => {
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"
className="h-8 gap-1.5 text-xs text-muted-foreground hover:text-foreground w-auto"
>
<Filter className="h-3.5 w-3.5" />
{sourceFilter === "all" ? "All sources" : sourceLabel[sourceFilter]}
@ -1235,7 +1279,7 @@ export const OrchestratorPage: React.FC = () => {
<Button
variant="ghost"
size="sm"
className="h-8 w-full gap-1.5 text-xs text-muted-foreground hover:text-foreground sm:w-auto"
className="h-8 gap-1.5 text-xs text-muted-foreground hover:text-foreground w-auto"
>
<ArrowUpDown className="h-3.5 w-3.5" />
{sortLabels[sort.key]}

View File

@ -3,7 +3,7 @@
*/
import React, { useEffect, useMemo, useState } from "react"
import { AlertTriangle, ArrowLeft, Settings, Trash2 } from "lucide-react"
import { AlertTriangle, Settings, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { PageHeader } from "../components/layout"
@ -418,7 +418,6 @@ export const SettingsPage: React.FC = () => {
icon={Settings}
title="Settings"
subtitle="Configure runtime behavior for this app."
nav={[{ icon: ArrowLeft, label: "Back to Orchestrator", to: "/" }]}
/>
<main className="container mx-auto max-w-3xl space-y-6 px-4 py-6 pb-12">

View File

@ -4,7 +4,6 @@
import React, { useEffect, useMemo, useState } from "react";
import {
ArrowLeft,
Briefcase,
Calendar,
ChevronLeft,
@ -14,12 +13,15 @@ import {
DollarSign,
ExternalLink,
GraduationCap,
Home,
Loader2,
MapPin,
Menu,
Search,
Settings,
Shield,
} from "lucide-react";
import { Link } from "react-router-dom";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
@ -28,6 +30,13 @@ 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 {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import * as api from "../api";
import type { CreateJobInput } from "../../shared/types";
@ -72,7 +81,17 @@ const clampText = (value: string, max = 160) => (value.length > max ? `${value.s
const jobKey = (job: CreateJobInput) => job.sourceJobId || job.jobUrl;
const navLinks = [
{ to: "/", label: "Dashboard", icon: Home },
{ to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield },
{ to: "/ukvisajobs", label: "UK Visa Jobs", icon: Briefcase },
{ to: "/settings", label: "Settings", icon: Settings },
];
export const UkVisaJobsPage: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const [navOpen, setNavOpen] = useState(false);
const [searchTermInput, setSearchTermInput] = useState("");
const [activeSearchTerm, setActiveSearchTerm] = useState<string | null>(null);
const [results, setResults] = useState<CreateJobInput[]>([]);
@ -333,8 +352,47 @@ export const UkVisaJobsPage: React.FC = () => {
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="container mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4">
<div className="flex items-center gap-3">
<Sheet open={navOpen} onOpenChange={setNavOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon">
<Menu className="h-5 w-5" />
<span className="sr-only">Open navigation menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-64">
<SheetHeader>
<SheetTitle>JobOps</SheetTitle>
</SheetHeader>
<nav className="mt-6 flex flex-col gap-2">
{navLinks.map(({ to, label, icon: Icon }) => (
<button
key={to}
type="button"
onClick={() => {
if (location.pathname === to) {
setNavOpen(false);
return;
}
setNavOpen(false);
setTimeout(() => navigate(to), 150);
}}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left",
location.pathname === to
? "bg-accent text-accent-foreground"
: "text-muted-foreground"
)}
>
<Icon className="h-4 w-4" />
{label}
</button>
))}
</nav>
</SheetContent>
</Sheet>
<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>
@ -342,22 +400,6 @@ export const UkVisaJobsPage: React.FC = () => {
<div className="text-sm font-semibold tracking-tight">UK Visa Jobs</div>
<div className="text-xs text-muted-foreground">Live search console</div>
</div>
<Badge variant="outline" className="uppercase tracking-wide">
API search
</Badge>
</div>
<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" />
</Link>
</Button>
<Button asChild variant="ghost" size="icon" aria-label="Settings">
<Link to="/settings">
<Settings className="h-4 w-4" />
</Link>
</Button>
</div>
</div>
</header>
@ -367,7 +409,7 @@ export const UkVisaJobsPage: React.FC = () => {
<form className="grid gap-4 md:grid-cols-[minmax(0,1fr)_160px]" onSubmit={handleSearch}>
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Search term
Job title search term
</label>
<Input
value={searchTermInput}
@ -375,9 +417,6 @@ export const UkVisaJobsPage: React.FC = () => {
placeholder="e.g. data analyst"
className="h-10"
/>
<p className="text-xs text-muted-foreground">
Single keyword or phrase. Leave blank to fetch the newest jobs.
</p>
</div>
<div className="flex items-end">

View File

@ -16,7 +16,6 @@ import {
MapPin,
Search,
Shield,
Sparkles,
X,
} from "lucide-react";
import { toast } from "sonner";
@ -320,7 +319,6 @@ export const VisaSponsorsPage: React.FC = () => {
title="Visa Sponsors"
subtitle="UK Register Search"
statusIndicator={isUpdateInProgress ? <StatusIndicator label="Updating" /> : undefined}
nav={[{ icon: Sparkles, label: "Back to Orchestrator", to: "/" }]}
actions={
<>
{status && (

View File

@ -0,0 +1,138 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.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}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -132,6 +132,68 @@
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
--tracking-normal: 0em;
--animate-in: in 0.5s ease-out forwards;
--animate-out: out 0.3s ease-in forwards;
--animate-fade-in: fade-in 0.5s ease-out forwards;
--animate-fade-out: fade-out 0.3s ease-in forwards;
--animate-slide-in-from-left: slide-in-from-left 0.5s ease-out forwards;
--animate-slide-out-to-left: slide-out-to-left 0.3s ease-in forwards;
--animate-slide-in-from-right: slide-in-from-right 0.5s ease-out forwards;
--animate-slide-out-to-right: slide-out-to-right 0.3s ease-in forwards;
--animate-slide-in-from-top: slide-in-from-top 0.5s ease-out forwards;
--animate-slide-out-to-top: slide-out-to-top 0.3s ease-in forwards;
--animate-slide-in-from-bottom: slide-in-from-bottom 0.5s ease-out forwards;
--animate-slide-out-to-bottom: slide-out-to-bottom 0.3s ease-in forwards;
@keyframes in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes slide-in-from-left {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
@keyframes slide-out-to-left {
from { transform: translateX(0); }
to { transform: translateX(-100%); }
}
@keyframes slide-in-from-right {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
@keyframes slide-out-to-right {
from { transform: translateX(0); }
to { transform: translateX(100%); }
}
@keyframes slide-in-from-top {
from { transform: translateY(-100%); }
to { transform: translateY(0); }
}
@keyframes slide-out-to-top {
from { transform: translateY(0); }
to { transform: translateY(-100%); }
}
@keyframes slide-in-from-bottom {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
@keyframes slide-out-to-bottom {
from { transform: translateY(0); }
to { transform: translateY(100%); }
}
}
.dark {
@ -198,4 +260,20 @@
@apply bg-background text-foreground antialiased;
letter-spacing: var(--tracking-normal);
}
}
/* Page transition animations */
.page-enter {
opacity: 0;
}
.page-enter-active {
opacity: 1;
transition: opacity 100ms ease-out;
}
.page-exit {
opacity: 1;
}
.page-exit-active {
opacity: 0;
transition: opacity 75ms ease-in;
}