diff --git a/orchestrator/package-lock.json b/orchestrator/package-lock.json index b3e1494..5322da3 100644 --- a/orchestrator/package-lock.json +++ b/orchestrator/package-lock.json @@ -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", diff --git a/orchestrator/package.json b/orchestrator/package.json index 225d693..1130faf 100644 --- a/orchestrator/package.json +++ b/orchestrator/package.json @@ -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", diff --git a/orchestrator/src/client/App.tsx b/orchestrator/src/client/App.tsx index ba5f6f5..04cea17 100644 --- a/orchestrator/src/client/App.tsx +++ b/orchestrator/src/client/App.tsx @@ -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 = () => ( - <> - - } /> - } /> - } /> - } /> - +export const App: React.FC = () => { + const location = useLocation(); + const nodeRef = useRef(null); - - -); + return ( + <> + + +
+ + } /> + } /> + } /> + } /> + +
+
+
+ + + + ); +}; diff --git a/orchestrator/src/client/components/Header.tsx b/orchestrator/src/client/components/Header.tsx index 0365d6f..7fd9c57 100644 --- a/orchestrator/src/client/components/Header.tsx +++ b/orchestrator/src/client/components/Header.tsx @@ -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 = ({ pipelineSources, onPipelineSourcesChange, }) => { + const location = useLocation(); + const [sheetOpen, setSheetOpen] = React.useState(false); + const sourceLabel: Record = { gradcracker: "Gradcracker", indeed: "Indeed", @@ -63,6 +64,13 @@ export const Header: React.FC = ({ 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 = ({ return (
- -
- Job Ops Logo -
-
-
Job Ops
-
Orchestrator
-
- +
+ + + + + + + + JobOps + + + + + + + +
+ Job Ops Logo +
+
+
Job Ops
+
Orchestrator
+
+ +
- - - -
- ))} - {actions} + const handleNavClick = (to: string) => { + if (location.pathname === to) { + setNavOpen(false); + return; + } + setNavOpen(false); + setTimeout(() => navigate(to), 150); + }; + + return ( +
+
+
+ + + + + + + JobOps + + + + + +
+ +
+
+
{title}
+
{subtitle}
+
+ {badge && ( + + {badge} + + )} + {statusIndicator} +
+ +
+ {actions} +
-
-
-); + + ); +}; // ============================================================================ // Status Indicator (Pipeline running, Updating, etc.) diff --git a/orchestrator/src/client/pages/OrchestratorPage.tsx b/orchestrator/src/client/pages/OrchestratorPage.tsx index 590ce1e..fac2efc 100644 --- a/orchestrator/src/client/pages/OrchestratorPage.tsx +++ b/orchestrator/src/client/pages/OrchestratorPage.tsx @@ -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([]); const [stats, setStats] = useState>({ 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(null); @@ -1044,49 +1064,74 @@ export const OrchestratorPage: React.FC = () => { return ( <>
-
-
-
- -
-
-
Job Ops
-
Orchestrator
+
+
+ + + + + + + JobOps + + + + + +
+
+ +
+
+
Job Ops
+
Orchestrator
+
+ {isPipelineRunning && ( - + Pipeline running )}
-
- - - - -
+
+
@@ -1160,13 +1205,13 @@ export const OrchestratorPage: React.FC = () => { {/* Compact metrics summary - demoted visual weight */}
- {totalJobs} jobs total - {stats.ready} ready {stats.discovered + stats.processing} discovered {stats.applied} applied + + {totalJobs} jobs total {(stats.skipped > 0 || stats.expired > 0) && ( <> @@ -1191,8 +1236,8 @@ export const OrchestratorPage: React.FC = () => { ))} -
-
+
+
{ className="h-8 pl-8 text-sm" />
- + + + + JobOps + + + + +
@@ -342,22 +400,6 @@ export const UkVisaJobsPage: React.FC = () => {
UK Visa Jobs
Live search console
- - API search - -
- -
- -
@@ -367,7 +409,7 @@ export const UkVisaJobsPage: React.FC = () => {
{ placeholder="e.g. data analyst" className="h-10" /> -

- Single keyword or phrase. Leave blank to fetch the newest jobs. -

diff --git a/orchestrator/src/client/pages/VisaSponsorsPage.tsx b/orchestrator/src/client/pages/VisaSponsorsPage.tsx index 40fcdc7..00c1a10 100644 --- a/orchestrator/src/client/pages/VisaSponsorsPage.tsx +++ b/orchestrator/src/client/pages/VisaSponsorsPage.tsx @@ -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 ? : undefined} - nav={[{ icon: Sparkles, label: "Back to Orchestrator", to: "/" }]} actions={ <> {status && ( diff --git a/orchestrator/src/components/ui/sheet.tsx b/orchestrator/src/components/ui/sheet.tsx new file mode 100644 index 0000000..02942db --- /dev/null +++ b/orchestrator/src/components/ui/sheet.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, 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, + VariantProps {} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = "right", className, children, ...props }, ref) => ( + + + + + + Close + + {children} + + +)) +SheetContent.displayName = SheetPrimitive.Content.displayName + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +SheetHeader.displayName = "SheetHeader" + +const SheetFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +SheetFooter.displayName = "SheetFooter" + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetTitle.displayName = SheetPrimitive.Title.displayName + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetDescription.displayName = SheetPrimitive.Description.displayName + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/orchestrator/src/index.css b/orchestrator/src/index.css index 977af18..a9bb87e 100644 --- a/orchestrator/src/index.css +++ b/orchestrator/src/index.css @@ -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; } \ No newline at end of file