From 23b29b94a14503ad4e08b8473720d1f3f506f219 Mon Sep 17 00:00:00 2001 From: pranavjana Date: Mon, 26 Jan 2026 04:03:03 +0800 Subject: [PATCH] Add job-hunting - Mino use case - ## Demo - Live demo: https://job-huntboard.vercel.app - Contributor: Pranav Janakiraman (@pranavjana) --- job-hunting/.gitignore | 41 + job-hunting/README.md | 144 + job-hunting/app/api/ai/cover-letter/route.ts | 72 + job-hunting/app/api/ai/generate-urls/route.ts | 119 + job-hunting/app/api/ai/match-jobs/route.ts | 105 + job-hunting/app/api/ai/parse-resume/route.ts | 103 + job-hunting/app/api/scrape/route.ts | 95 + job-hunting/app/favicon.ico | Bin 0 -> 25931 bytes job-hunting/app/globals.css | 282 + job-hunting/app/layout.tsx | 53 + job-hunting/app/page.tsx | 43 + .../1e173e48-3235-4016-9a14-2d985b781134.jpg | Bin 0 -> 61354 bytes job-hunting/components.json | 1 + job-hunting/components/command-menu.tsx | 196 + job-hunting/components/dashboard.tsx | 628 ++ job-hunting/components/jobs/job-card.tsx | 289 + .../components/jobs/job-detail-dialog.tsx | 418 + job-hunting/components/jobs/job-feed.tsx | 402 + .../components/jobs/job-tracker-kanban.tsx | 237 + job-hunting/components/jobs/kanban-board.tsx | 296 + job-hunting/components/jobs/scraping-grid.tsx | 230 + .../components/onboarding/onboarding-flow.tsx | 233 + .../components/onboarding/profile-form.tsx | 395 + .../components/onboarding/resume-upload.tsx | 414 + .../components/settings/settings-panel.tsx | 433 + job-hunting/components/theme-provider.tsx | 11 + job-hunting/components/ui/avatar.tsx | 50 + job-hunting/components/ui/badge.tsx | 36 + job-hunting/components/ui/button.tsx | 62 + job-hunting/components/ui/card.tsx | 79 + job-hunting/components/ui/checkbox.tsx | 32 + job-hunting/components/ui/command.tsx | 184 + job-hunting/components/ui/dialog.tsx | 143 + job-hunting/components/ui/dropdown-menu.tsx | 257 + job-hunting/components/ui/form.tsx | 167 + job-hunting/components/ui/input.tsx | 21 + job-hunting/components/ui/kanban-board.tsx | 256 + job-hunting/components/ui/label.tsx | 24 + job-hunting/components/ui/progress.tsx | 31 + job-hunting/components/ui/scroll-area.tsx | 58 + job-hunting/components/ui/select.tsx | 190 + job-hunting/components/ui/separator.tsx | 28 + job-hunting/components/ui/sheet.tsx | 139 + job-hunting/components/ui/skeleton.tsx | 13 + job-hunting/components/ui/sonner.tsx | 40 + job-hunting/components/ui/switch.tsx | 31 + job-hunting/components/ui/table.tsx | 95 + job-hunting/components/ui/tabs.tsx | 66 + job-hunting/components/ui/textarea.tsx | 18 + job-hunting/eslint.config.mjs | 18 + job-hunting/lib/ai/client.ts | 251 + job-hunting/lib/constants.ts | 217 + job-hunting/lib/hooks/use-local-storage.ts | 433 + job-hunting/lib/mino-client.ts | 289 + job-hunting/lib/types.ts | 209 + job-hunting/lib/utils.ts | 154 + job-hunting/next.config.ts | 7 + job-hunting/package-lock.json | 8716 +++++++++++++++++ job-hunting/package.json | 58 + job-hunting/postcss.config.mjs | 7 + job-hunting/public/file.svg | 1 + job-hunting/public/globe.svg | 1 + job-hunting/public/next.svg | 1 + job-hunting/public/vercel.svg | 1 + job-hunting/public/window.svg | 1 + job-hunting/tsconfig.json | 34 + 66 files changed, 17658 insertions(+) create mode 100644 job-hunting/.gitignore create mode 100644 job-hunting/README.md create mode 100644 job-hunting/app/api/ai/cover-letter/route.ts create mode 100644 job-hunting/app/api/ai/generate-urls/route.ts create mode 100644 job-hunting/app/api/ai/match-jobs/route.ts create mode 100644 job-hunting/app/api/ai/parse-resume/route.ts create mode 100644 job-hunting/app/api/scrape/route.ts create mode 100644 job-hunting/app/favicon.ico create mode 100644 job-hunting/app/globals.css create mode 100644 job-hunting/app/layout.tsx create mode 100644 job-hunting/app/page.tsx create mode 100644 job-hunting/assets/1e173e48-3235-4016-9a14-2d985b781134.jpg create mode 100644 job-hunting/components.json create mode 100644 job-hunting/components/command-menu.tsx create mode 100644 job-hunting/components/dashboard.tsx create mode 100644 job-hunting/components/jobs/job-card.tsx create mode 100644 job-hunting/components/jobs/job-detail-dialog.tsx create mode 100644 job-hunting/components/jobs/job-feed.tsx create mode 100644 job-hunting/components/jobs/job-tracker-kanban.tsx create mode 100644 job-hunting/components/jobs/kanban-board.tsx create mode 100644 job-hunting/components/jobs/scraping-grid.tsx create mode 100644 job-hunting/components/onboarding/onboarding-flow.tsx create mode 100644 job-hunting/components/onboarding/profile-form.tsx create mode 100644 job-hunting/components/onboarding/resume-upload.tsx create mode 100644 job-hunting/components/settings/settings-panel.tsx create mode 100644 job-hunting/components/theme-provider.tsx create mode 100644 job-hunting/components/ui/avatar.tsx create mode 100644 job-hunting/components/ui/badge.tsx create mode 100644 job-hunting/components/ui/button.tsx create mode 100644 job-hunting/components/ui/card.tsx create mode 100644 job-hunting/components/ui/checkbox.tsx create mode 100644 job-hunting/components/ui/command.tsx create mode 100644 job-hunting/components/ui/dialog.tsx create mode 100644 job-hunting/components/ui/dropdown-menu.tsx create mode 100644 job-hunting/components/ui/form.tsx create mode 100644 job-hunting/components/ui/input.tsx create mode 100644 job-hunting/components/ui/kanban-board.tsx create mode 100644 job-hunting/components/ui/label.tsx create mode 100644 job-hunting/components/ui/progress.tsx create mode 100644 job-hunting/components/ui/scroll-area.tsx create mode 100644 job-hunting/components/ui/select.tsx create mode 100644 job-hunting/components/ui/separator.tsx create mode 100644 job-hunting/components/ui/sheet.tsx create mode 100644 job-hunting/components/ui/skeleton.tsx create mode 100644 job-hunting/components/ui/sonner.tsx create mode 100644 job-hunting/components/ui/switch.tsx create mode 100644 job-hunting/components/ui/table.tsx create mode 100644 job-hunting/components/ui/tabs.tsx create mode 100644 job-hunting/components/ui/textarea.tsx create mode 100644 job-hunting/eslint.config.mjs create mode 100644 job-hunting/lib/ai/client.ts create mode 100644 job-hunting/lib/constants.ts create mode 100644 job-hunting/lib/hooks/use-local-storage.ts create mode 100644 job-hunting/lib/mino-client.ts create mode 100644 job-hunting/lib/types.ts create mode 100644 job-hunting/lib/utils.ts create mode 100644 job-hunting/next.config.ts create mode 100644 job-hunting/package-lock.json create mode 100644 job-hunting/package.json create mode 100644 job-hunting/postcss.config.mjs create mode 100644 job-hunting/public/file.svg create mode 100644 job-hunting/public/globe.svg create mode 100644 job-hunting/public/next.svg create mode 100644 job-hunting/public/vercel.svg create mode 100644 job-hunting/public/window.svg create mode 100644 job-hunting/tsconfig.json diff --git a/job-hunting/.gitignore b/job-hunting/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/job-hunting/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/job-hunting/README.md b/job-hunting/README.md new file mode 100644 index 0000000..e2fcaf7 --- /dev/null +++ b/job-hunting/README.md @@ -0,0 +1,144 @@ +# Job Hunter - AI-Powered Job Search Automation + +## Demo + +![job-hunting Demo](./assets/1e173e48-3235-4016-9a14-2d985b781134.jpg) + +**Live Demo:** https://job-huntboard.vercel.app + +Job Hunter is a comprehensive platform that automates the job search process using Mino's browser automation API and OpenRouter's AI models. It handles everything from resume parsing and optimized job board search generation to parallel scraping and intelligent job matching. + +--- + +--- + +## Demo + +*[Demo video/screenshot to be added]* + +--- + +## How Mino API is Used + +The Mino API powers browser automation for this use case. See the code snippet below for implementation details. + +### Code Snippet + +```bash +curl -N -X POST "https://mino.ai/v1/automation/run-sse" \ + -H "X-API-Key: $MINO_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "url": "https://sg.indeed.com/jobs?q=frontend+developer", + "goal": "I am looking for: frontend developer. Extract ONLY the 6-7 most relevant job listings...", + "browser_profile": "stealth" + }' +``` + +--- + +## How to Run + +### Prerequisites + +- Node.js 18+ +- Mino API key (get from [mino.ai](https://mino.ai)) + +### Setup + +1. Clone the repository: +```bash +git clone https://github.com/tinyfish-io/TinyFish-cookbook +cd TinyFish-cookbook/job-hunting +``` + +2. Install dependencies: +```bash +npm install +``` + +3. Create `.env.local` file: +```bash +# Add your environment variables here +MINO_API_KEY=sk-mino-... +``` + +4. Run the development server: +```bash +npm run dev +``` + +5. Open [http://localhost:3000](http://localhost:3000) in your browser + +--- + +## Architecture Diagram + +```mermaid +graph TD + subgraph Frontend [Next.js Client] + UI[User Interface - React/Tailwind] + State[State Management - Custom Hooks] + LS[(Local Storage)] + end + + subgraph Backend [Next.js API Routes] + Parse[/api/ai/parse-resume] + GenURLs[/api/ai/generate-urls] + Scrape[/api/scrape] + Match[/api/ai/match-jobs] + Letter[/api/ai/cover-letter] + end + + subgraph External_APIs [External Services] + Mino[Mino API - Browser Automation] + OpenRouter[OpenRouter AI - Minimax M2.1] + end + + %% User Interactions + UI -->|Resume Text| State + UI -->|Search Criteria| State + + %% Internal Orchestration + State <-->|Read/Write Profile & Jobs| LS + State -->|Trigger| Parse + State -->|Trigger| GenURLs + State -->|Trigger Parallel| Scrape + State -->|Trigger| Match + State -->|Trigger| Letter + + %% External Service Calls + Parse -->|Extract JSON| OpenRouter + GenURLs -->|Generate Search URLs| OpenRouter + Match -->|Analyze Fit| OpenRouter + Letter -->|Personalize Content| OpenRouter + + Scrape -->|Orchestrate Agents| Mino + Mino --.->|SSE Stream: Progress + Live Preview| UI + Mino --.->|JSON Result| Scrape + Scrape -->|Job Objects| State +``` + +```mermaid +sequenceDiagram + participant U as User + participant D as Dashboard + participant S as API Route (/api/scrape) + participant M as Mino (Stealth Browser) + + U->>D: Click "Find Jobs" + D->>D: Load Search URLs from State + + par For Each Job Board (LinkedIn, Indeed, etc.) + D->>S: POST /api/scrape (Board URL) + S->>M: POST /v1/automation/run-sse (Goal + Stealth) + M-->>D: Event: streamingUrl (Live Preview) + M-->>D: Event: STEP (Navigating/Scanning) + M-->>D: Event: COMPLETE (JSON Result) + D->>D: Parse & Deduplicate Jobs + end + + D->>U: Update Feed with New Listings +``` + + diff --git a/job-hunting/app/api/ai/cover-letter/route.ts b/job-hunting/app/api/ai/cover-letter/route.ts new file mode 100644 index 0000000..0fbd3f9 --- /dev/null +++ b/job-hunting/app/api/ai/cover-letter/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; +import { generateText } from "ai"; + +export async function POST(request: NextRequest) { + try { + const { job, profile } = await request.json(); + + if (!job || !profile) { + return NextResponse.json( + { error: "Job and profile are required" }, + { status: 400 } + ); + } + + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) { + return NextResponse.json( + { error: "OpenRouter API key not configured" }, + { status: 500 } + ); + } + + const openrouter = createOpenAICompatible({ + name: "openrouter", + baseURL: "https://openrouter.ai/api/v1", + headers: { + Authorization: `Bearer ${apiKey}`, + "HTTP-Referer": "https://jobhunter.app", + "X-Title": "Job Hunter Dashboard", + }, + }); + + const model = openrouter.chatModel("minimax/minimax-m2.1"); + + const { text } = await generateText({ + model, + system: `You are an expert cover letter writer who creates compelling, personalized letters. +Write naturally and professionally, avoiding generic phrases. +Be specific about why this candidate is a great fit for this role. +Never use placeholder text - use actual company and candidate names.`, + prompt: `Write a compelling cover letter for this job application: + +JOB DETAILS: +- Company: ${job.company} +- Title: ${job.title} +- Location: ${job.location} +- Description: ${job.description} +${job.requirements ? `- Requirements: ${job.requirements.join(", ")}` : ""} + +CANDIDATE PROFILE: +- Name: ${profile.fullName} +- Current title: ${profile.currentTitle} +- Years of experience: ${profile.yearsExperience} +- Key skills: ${profile.skills?.slice(0, 8).join(", ") || ""} +- Summary: ${profile.summary} + +Write a 3-4 paragraph cover letter that: +- Opens with a strong hook relevant to the company/role +- Highlights 2-3 specific qualifications that match the job requirements +- Shows enthusiasm for the company and role +- Ends with a clear call to action`, + }); + + return NextResponse.json({ data: text }); + } catch (error) { + return NextResponse.json( + { error: "Failed to generate cover letter" }, + { status: 500 } + ); + } +} diff --git a/job-hunting/app/api/ai/generate-urls/route.ts b/job-hunting/app/api/ai/generate-urls/route.ts new file mode 100644 index 0000000..8f735a0 --- /dev/null +++ b/job-hunting/app/api/ai/generate-urls/route.ts @@ -0,0 +1,119 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; +import { generateText } from "ai"; +import { z } from "zod"; + +const searchUrlsSchema = z.object({ + urls: z.array( + z.object({ + boardName: z.string(), + searchUrl: z.string(), + }) + ), +}); + +function extractJSON(text: string): string { + // Remove markdown code blocks if present + const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/); + if (codeBlockMatch) { + return codeBlockMatch[1].trim(); + } + // Try to find JSON object directly + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (jsonMatch) { + return jsonMatch[0]; + } + return text; +} + +export async function POST(request: NextRequest) { + try { + const { profile, searchConfig } = await request.json(); + + if (!profile) { + return NextResponse.json( + { error: "Profile is required" }, + { status: 400 } + ); + } + + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) { + return NextResponse.json( + { error: "OpenRouter API key not configured" }, + { status: 500 } + ); + } + + const openrouter = createOpenAICompatible({ + name: "openrouter", + baseURL: "https://openrouter.ai/api/v1", + headers: { + Authorization: `Bearer ${apiKey}`, + "HTTP-Referer": "https://jobhunter.app", + "X-Title": "Job Hunter Dashboard", + }, + }); + + const model = openrouter.chatModel("minimax/minimax-m2.1"); + + const locationStr = + searchConfig?.locations?.length > 0 + ? searchConfig.locations.join(", ") + : profile.location; + + const remoteNote = + searchConfig?.remotePreference === "remote-only" + ? "Focus on remote positions only." + : searchConfig?.remotePreference === "hybrid-ok" + ? "Include remote and hybrid positions." + : ""; + + const jobSearchPrompt = searchConfig?.jobSearchPrompt || ""; + + const { text } = await generateText({ + model, + system: `You are an expert at finding jobs on the internet. You know all the best job boards for different regions, industries, and job types. You create working search URLs with proper parameters. Always respond with valid JSON only.`, + prompt: `Generate the BEST job board search URLs for this job search: + +=== JOB SEARCH REQUEST === +${jobSearchPrompt || `Looking for ${profile.preferredTitles?.join(" or ") || profile.currentTitle} positions`} + +=== LOCATION === +${locationStr} + +=== CONTEXT === +- Experience level: ${profile.seniorityLevel} +${remoteNote ? `- Remote preference: ${remoteNote}` : ""} +${searchConfig?.salaryMinimum ? `- Minimum salary: $${searchConfig.salaryMinimum}` : ""} + +=== INSTRUCTIONS === +1. Choose the BEST 10-15 job boards for this search based on the LOCATION and JOB TYPE +2. For each region, include LOCAL job boards (e.g., for Singapore: JobsCentral, JobStreet, TechInAsia; for UK: Reed, TotalJobs; for India: Naukri, etc.) +3. Include global boards like LinkedIn, Indeed (use regional domains like indeed.sg, indeed.co.uk, etc.) +4. Include industry-specific boards if relevant (tech: AngelList, Dice, HackerNews; startups: Y Combinator, etc.) +5. Create working search URLs with the job keywords and location pre-filled +6. Use proper URL encoding (spaces become %20 or +) + +Return ONLY a JSON object: +{ + "urls": [ + { "boardName": "LinkedIn", "searchUrl": "https://www.linkedin.com/jobs/search/?keywords=..." }, + { "boardName": "Indeed Singapore", "searchUrl": "https://sg.indeed.com/jobs?q=..." }, + ... + ] +}`, + }); + + const jsonStr = extractJSON(text); + const parsed = JSON.parse(jsonStr); + const validated = searchUrlsSchema.parse(parsed); + + return NextResponse.json({ data: validated.urls }); + } catch (error) { + return NextResponse.json( + { error: "Failed to generate search URLs" }, + { status: 500 } + ); + } +} diff --git a/job-hunting/app/api/ai/match-jobs/route.ts b/job-hunting/app/api/ai/match-jobs/route.ts new file mode 100644 index 0000000..b8a423b --- /dev/null +++ b/job-hunting/app/api/ai/match-jobs/route.ts @@ -0,0 +1,105 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; +import { generateText } from "ai"; +import { z } from "zod"; + +const jobMatchSchema = z.object({ + matchScore: z.number().min(0).max(100), + matchExplanation: z.string(), + keyStrengths: z.array(z.string()), + potentialConcerns: z.array(z.string()), + isReach: z.boolean(), + isPerfectFit: z.boolean(), +}); + +function extractJSON(text: string): string { + // Remove markdown code blocks if present + const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/); + if (codeBlockMatch) { + return codeBlockMatch[1].trim(); + } + // Try to find JSON object directly + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (jsonMatch) { + return jsonMatch[0]; + } + return text; +} + +export async function POST(request: NextRequest) { + try { + const { job, profile } = await request.json(); + + if (!job || !profile) { + return NextResponse.json( + { error: "Job and profile are required" }, + { status: 400 } + ); + } + + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) { + return NextResponse.json( + { error: "OpenRouter API key not configured" }, + { status: 500 } + ); + } + + const openrouter = createOpenAICompatible({ + name: "openrouter", + baseURL: "https://openrouter.ai/api/v1", + headers: { + Authorization: `Bearer ${apiKey}`, + "HTTP-Referer": "https://jobhunter.app", + "X-Title": "Job Hunter Dashboard", + }, + }); + + const model = openrouter.chatModel("minimax/minimax-m2.1"); + + const { text } = await generateText({ + model, + system: `You are a career matching expert. Analyze how well job listings match candidate profiles. Consider: title alignment, skills match, experience level fit, and any stated preferences. Be honest but encouraging. Always respond with valid JSON only.`, + prompt: `Analyze this job match: + +CANDIDATE PROFILE: +- Current title: ${profile.currentTitle} +- Years of experience: ${profile.yearsExperience} +- Skills: ${profile.skills?.join(", ") || ""} +- Preferred titles: ${profile.preferredTitles?.join(", ") || ""} +- Seniority level: ${profile.seniorityLevel} +- Location: ${profile.location} + +JOB LISTING: +- Title: ${job.title} +- Company: ${job.company} +- Location: ${job.location} +- Remote status: ${job.remoteStatus || "not specified"} +- Description: ${job.description?.slice(0, 500) || "not provided"} +- Salary: ${job.salaryRange || "not specified"} + +Return a JSON object with this exact structure: +{ + "matchScore": number (0-100), + "matchExplanation": "1-2 sentence explanation", + "keyStrengths": ["up to 3 strengths"], + "potentialConcerns": ["up to 2 concerns"], + "isReach": boolean (true if stretch role), + "isPerfectFit": boolean (true if excellent match) +} + +Respond with only the JSON object, no other text.`, + }); + + const jsonStr = extractJSON(text); + const parsed = JSON.parse(jsonStr); + const validated = jobMatchSchema.parse(parsed); + + return NextResponse.json({ data: validated }); + } catch (error) { + return NextResponse.json( + { error: "Failed to analyze job match" }, + { status: 500 } + ); + } +} diff --git a/job-hunting/app/api/ai/parse-resume/route.ts b/job-hunting/app/api/ai/parse-resume/route.ts new file mode 100644 index 0000000..0f3eaaf --- /dev/null +++ b/job-hunting/app/api/ai/parse-resume/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; +import { generateText } from "ai"; +import { z } from "zod"; + +const resumeSchema = z.object({ + fullName: z.string(), + email: z.string(), + phone: z.string().nullable(), + location: z.string(), + currentTitle: z.string(), + yearsExperience: z.number(), + skills: z.array(z.string()), + industries: z.array(z.string()), + education: z.string(), + preferredTitles: z.array(z.string()), + seniorityLevel: z.enum(["entry", "mid", "senior", "lead", "executive"]), + summary: z.string(), +}); + +function extractJSON(text: string): string { + // Remove markdown code blocks if present + const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/); + if (codeBlockMatch) { + return codeBlockMatch[1].trim(); + } + // Try to find JSON object directly + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (jsonMatch) { + return jsonMatch[0]; + } + return text; +} + +export async function POST(request: NextRequest) { + try { + const { resumeText } = await request.json(); + + if (!resumeText || typeof resumeText !== "string") { + return NextResponse.json( + { error: "Resume text is required" }, + { status: 400 } + ); + } + + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) { + return NextResponse.json( + { error: "OpenRouter API key not configured" }, + { status: 500 } + ); + } + + const openrouter = createOpenAICompatible({ + name: "openrouter", + baseURL: "https://openrouter.ai/api/v1", + headers: { + Authorization: `Bearer ${apiKey}`, + "HTTP-Referer": "https://jobhunter.app", + "X-Title": "Job Hunter Dashboard", + }, + }); + + const model = openrouter.chatModel("minimax/minimax-m2.1"); + + const { text } = await generateText({ + model, + system: + "You are an expert resume parser. Extract structured information from resumes accurately. Only extract what is explicitly stated or can be reasonably inferred. Always respond with valid JSON only, no markdown formatting.", + prompt: `Parse this resume and return a JSON object with these exact fields: +{ + "fullName": "Full name of the candidate", + "email": "Email address", + "phone": "Phone number or null if not found", + "location": "City, state or location", + "currentTitle": "Current or most recent job title", + "yearsExperience": number (estimated total years), + "skills": ["array of top 10 technical and soft skills"], + "industries": ["array of industries worked in"], + "education": "Highest education level and field", + "preferredTitles": ["3-5 job titles they would likely apply for"], + "seniorityLevel": "entry" | "mid" | "senior" | "lead" | "executive", + "summary": "2-sentence professional summary" +} + +Resume text: +${resumeText} + +Respond with only the JSON object, no other text.`, + }); + + const jsonStr = extractJSON(text); + const parsed = JSON.parse(jsonStr); + const validated = resumeSchema.parse(parsed); + + return NextResponse.json({ data: validated }); + } catch (error) { + return NextResponse.json( + { error: "Failed to parse resume" }, + { status: 500 } + ); + } +} diff --git a/job-hunting/app/api/scrape/route.ts b/job-hunting/app/api/scrape/route.ts new file mode 100644 index 0000000..157c3af --- /dev/null +++ b/job-hunting/app/api/scrape/route.ts @@ -0,0 +1,95 @@ +import { NextRequest, NextResponse } from "next/server"; + +const MINO_API_URL = "https://mino.ai/v1/automation/run-sse"; + +export async function POST(request: NextRequest) { + try { + const { searchUrl, boardName, jobSearchCriteria } = await request.json(); + + if (!searchUrl) { + return NextResponse.json( + { error: "Search URL is required" }, + { status: 400 } + ); + } + + const apiKey = process.env.MINO_API_KEY; + if (!apiKey) { + return NextResponse.json( + { error: "Mino API key not configured" }, + { status: 500 } + ); + } + + // Goal with specific job criteria to filter relevant jobs + const goal = `I am looking for: ${jobSearchCriteria || "software engineering jobs"} + +Extract ONLY the 6-7 most relevant job listings that match what I'm looking for. Skip jobs that don't match (e.g., if I want frontend developer, skip HR, marketing, admin roles). + +For each relevant job, get: +- title: the job title +- company: company name +- location: location +- fullUrl: link to the job posting (MUST be a valid URL) +- description: short description if visible + +Return as JSON array with ONLY relevant jobs. Example: [{"title":"Frontend Developer","company":"Google","location":"Singapore","fullUrl":"https://...","description":"..."}]`; + + const response = await fetch(MINO_API_URL, { + method: "POST", + headers: { + "X-API-Key": apiKey, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + url: searchUrl, + goal, + browser_profile: "stealth", + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Mino API error: ${response.status} ${errorText}`); + } + + if (!response.body) { + throw new Error("Response body is null"); + } + + // Stream the SSE response back to the client + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + } catch (error) { + controller.error(error); + } + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Scraping failed" }, + { status: 500 } + ); + } +} diff --git a/job-hunting/app/favicon.ico b/job-hunting/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 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 LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/job-hunting/app/globals.css b/job-hunting/app/globals.css new file mode 100644 index 0000000..bd78b2d --- /dev/null +++ b/job-hunting/app/globals.css @@ -0,0 +1,282 @@ +@import "tailwindcss"; + +/* Mino Brand Design System */ +:root { + /* Brand Colors */ + --burnt-orange: #D76228; + --deep-teal: #165762; + --cream: #F4F3F2; + --warm-gray: #e0dfde; + + /* Semantic Colors */ + --background: var(--cream); + --foreground: #1a1a1a; + --card: #ffffff; + --card-foreground: #1a1a1a; + --popover: #ffffff; + --popover-foreground: #1a1a1a; + --primary: var(--burnt-orange); + --primary-foreground: #ffffff; + --secondary: var(--deep-teal); + --secondary-foreground: #ffffff; + --muted: var(--warm-gray); + --muted-foreground: #6b7280; + --accent: var(--deep-teal); + --accent-foreground: #ffffff; + --destructive: #ef4444; + --destructive-foreground: #ffffff; + --border: var(--warm-gray); + --input: var(--warm-gray); + --ring: var(--burnt-orange); + + /* Match Score Colors */ + --match-excellent: #22c55e; + --match-good: #eab308; + --match-poor: #ef4444; + + /* Radius */ + --radius: 0.75rem; +} + +.dark { + --background: #0a1214; + --foreground: var(--cream); + --card: #162329; + --card-foreground: var(--cream); + --popover: #162329; + --popover-foreground: var(--cream); + --primary: var(--burnt-orange); + --primary-foreground: #ffffff; + --secondary: #1e4a52; + --secondary-foreground: var(--cream); + --muted: #1e4a52; + --muted-foreground: #9ca3af; + --accent: #1e4a52; + --accent-foreground: var(--cream); + --destructive: #dc2626; + --destructive-foreground: #ffffff; + --border: #1e4a52; + --input: #1e4a52; + --ring: var(--burnt-orange); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + + /* Brand colors for direct use */ + --color-burnt-orange: var(--burnt-orange); + --color-deep-teal: var(--deep-teal); + --color-cream: var(--cream); + --color-warm-gray: var(--warm-gray); + + /* Match score colors */ + --color-match-excellent: var(--match-excellent); + --color-match-good: var(--match-good); + --color-match-poor: var(--match-poor); + + /* Fonts */ + --font-sans: var(--font-manrope); + --font-display: var(--font-fraunces); + --font-mono: var(--font-space-mono); + + /* Radius */ + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +* { + border-color: var(--border); +} + +body { + background: var(--background); + color: var(--foreground); + font-family: var(--font-sans), system-ui, sans-serif; + font-feature-settings: "rlig" 1, "calt" 1; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Editorial Typography */ +h1, h2, h3, h4, h5, h6 { + font-family: var(--font-display), Georgia, serif; + font-weight: 500; + letter-spacing: -0.02em; +} + +h1 { + font-size: 2.5rem; + line-height: 1.1; +} + +h2 { + font-size: 1.875rem; + line-height: 1.2; +} + +h3 { + font-size: 1.25rem; + line-height: 1.3; +} + +/* Section Labels */ +.section-label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--deep-teal); + opacity: 0.5; +} + +/* Custom Scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--warm-gray); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--deep-teal); + opacity: 0.5; +} + +/* Focus Styles */ +*:focus-visible { + outline: 2px solid var(--burnt-orange); + outline-offset: 2px; +} + +/* Selection */ +::selection { + background: var(--burnt-orange); + color: white; +} + +/* Animations */ +@keyframes fade-in { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slide-in-right { + from { + opacity: 0; + transform: translateX(20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes pulse-orange { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.animate-fade-in { + animation: fade-in 0.4s ease-out forwards; +} + +.animate-slide-in-right { + animation: slide-in-right 0.3s ease-out forwards; +} + +.animate-pulse-orange { + animation: pulse-orange 2s ease-in-out infinite; +} + +/* Stagger children animations */ +.stagger-children > * { + opacity: 0; + animation: fade-in 0.4s ease-out forwards; +} + +.stagger-children > *:nth-child(1) { animation-delay: 0ms; } +.stagger-children > *:nth-child(2) { animation-delay: 50ms; } +.stagger-children > *:nth-child(3) { animation-delay: 100ms; } +.stagger-children > *:nth-child(4) { animation-delay: 150ms; } +.stagger-children > *:nth-child(5) { animation-delay: 200ms; } +.stagger-children > *:nth-child(6) { animation-delay: 250ms; } +.stagger-children > *:nth-child(7) { animation-delay: 300ms; } +.stagger-children > *:nth-child(8) { animation-delay: 350ms; } +.stagger-children > *:nth-child(9) { animation-delay: 400ms; } +.stagger-children > *:nth-child(10) { animation-delay: 450ms; } + +/* Match Score Ring */ +.match-ring { + stroke-linecap: round; + transform: rotate(-90deg); + transform-origin: center; + transition: stroke-dashoffset 0.5s ease-out; +} + +/* Kanban Column Styles */ +.kanban-column { + min-height: 400px; + transition: background-color 0.2s ease; +} + +.kanban-column.is-over { + background-color: var(--accent); + opacity: 0.1; +} + +/* Job Card Hover Effects */ +.job-card { + transition: all 0.2s ease; +} + +.job-card:hover { + transform: translateY(-2px); + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); +} + +/* Drag and Drop */ +.dragging { + opacity: 0.5; + cursor: grabbing; +} + +.drag-over { + border-color: var(--burnt-orange); + border-style: dashed; +} diff --git a/job-hunting/app/layout.tsx b/job-hunting/app/layout.tsx new file mode 100644 index 0000000..45547de --- /dev/null +++ b/job-hunting/app/layout.tsx @@ -0,0 +1,53 @@ +import type { Metadata } from "next"; +import { Fraunces, Manrope, Space_Mono } from "next/font/google"; +import { ThemeProvider } from "@/components/theme-provider"; +import { Toaster } from "@/components/ui/sonner"; +import "./globals.css"; + +const fraunces = Fraunces({ + variable: "--font-fraunces", + subsets: ["latin"], + display: "swap", +}); + +const manrope = Manrope({ + variable: "--font-manrope", + subsets: ["latin"], + display: "swap", +}); + +const spaceMono = Space_Mono({ + variable: "--font-space-mono", + weight: ["400", "700"], + subsets: ["latin"], + display: "swap", +}); + +export const metadata: Metadata = { + title: "Job Hunter | Find Your Perfect Role", + description: "AI-powered job search dashboard that scans multiple job boards to find positions matching your skills and experience.", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + {children} + + + + + ); +} diff --git a/job-hunting/app/page.tsx b/job-hunting/app/page.tsx new file mode 100644 index 0000000..b6b8c53 --- /dev/null +++ b/job-hunting/app/page.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useMemo } from "react"; +import { OnboardingFlow } from "@/components/onboarding/onboarding-flow"; +import { Dashboard } from "@/components/dashboard"; +import { useProfile } from "@/lib/hooks/use-local-storage"; +import { Skeleton } from "@/components/ui/skeleton"; + +export default function Home() { + const { profile, isClient } = useProfile(); + + // Derive state instead of using useEffect + const showOnboarding = useMemo(() => { + return isClient && !profile; + }, [isClient, profile]); + + // Show loading state during hydration + if (!isClient) { + return ( +
+
+
+ +
+ + +
+
+ + +
+
+ ); + } + + // Show onboarding if no profile + if (showOnboarding) { + return {}} />; + } + + // Show dashboard + return ; +} diff --git a/job-hunting/assets/1e173e48-3235-4016-9a14-2d985b781134.jpg b/job-hunting/assets/1e173e48-3235-4016-9a14-2d985b781134.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2b7ee4bb9b3e5382ffd284b1603503b7ae2970b5 GIT binary patch literal 61354 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 rsoSx)9S4d=j_ void; + onFindJobs: () => void; + onGoToTab: (tab: string) => void; + jobs: Job[]; + onJobClick: (job: Job) => void; +} + +export function CommandMenu({ + open, + onOpenChange, + onFindJobs, + onGoToTab, + jobs, + onJobClick, +}: CommandMenuProps) { + const { theme, setTheme } = useTheme(); + const [search, setSearch] = useState(""); + + // Filter jobs by search + const filteredJobs = search + ? jobs + .filter( + (j) => + j.title.toLowerCase().includes(search.toLowerCase()) || + j.company.toLowerCase().includes(search.toLowerCase()) + ) + .slice(0, 5) + : []; + + const handleExport = () => { + const data = exportData(); + const blob = new Blob([data], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `jobhunter-backup-${new Date().toISOString().split("T")[0]}.json`; + a.click(); + URL.revokeObjectURL(url); + toast.success("Data exported"); + onOpenChange(false); + }; + + return ( + + + + No results found. + + {/* Quick actions */} + + { + onFindJobs(); + onOpenChange(false); + }} + > + + Find New Jobs + + + + Export Data + + { + setTheme(theme === "dark" ? "light" : "dark"); + onOpenChange(false); + }} + > + {theme === "dark" ? ( + + ) : ( + + )} + Toggle {theme === "dark" ? "Light" : "Dark"} Mode + + + + + + {/* Navigation */} + + { + onGoToTab("feed"); + onOpenChange(false); + }} + > + + Go to Job Feed + + { + onGoToTab("saved"); + onOpenChange(false); + }} + > + + Go to Saved Jobs + + { + onGoToTab("applications"); + onOpenChange(false); + }} + > + + Go to Applications + + { + onGoToTab("settings"); + onOpenChange(false); + }} + > + + Go to Settings + + + + {/* Job search results */} + {filteredJobs.length > 0 && ( + <> + + + {filteredJobs.map((job) => ( + { + onJobClick(job); + onOpenChange(false); + }} + > +
+ +
+

{job.title}

+

+ {job.company} +

+
+ = 80 + ? "text-match-excellent" + : job.matchScore >= 50 + ? "text-match-good" + : "text-match-poor" + }`} + > + {job.matchScore}% + +
+
+ ))} +
+ + )} +
+
+ ); +} diff --git a/job-hunting/components/dashboard.tsx b/job-hunting/components/dashboard.tsx new file mode 100644 index 0000000..73f3d22 --- /dev/null +++ b/job-hunting/components/dashboard.tsx @@ -0,0 +1,628 @@ +"use client"; + +import { useState, useCallback, useEffect, useRef } from "react"; +import { + Briefcase, + Kanban, + Settings, + Search, + Moon, + Sun, + Bot, + ExternalLink, + Check, + X, + Loader2, + Eye, + StopCircle, +} from "lucide-react"; +import { useTheme } from "next-themes"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import { Textarea } from "@/components/ui/textarea"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { toast } from "sonner"; +import { JobFeed } from "./jobs/job-feed"; +import { JobDetailDialog } from "./jobs/job-detail-dialog"; +import { JobTrackerKanban } from "./jobs/job-tracker-kanban"; +import { SettingsPanel } from "./settings/settings-panel"; +import { CommandMenu } from "./command-menu"; +import { + useProfile, + useJobs, + useSavedJobs, + useSearchConfig, + useScanHistory, +} from "@/lib/hooks/use-local-storage"; +import { + generateSearchUrls, + batchAnalyzeJobs, + quickMatchEstimate, + generateQuickExplanation, +} from "@/lib/ai/client"; +import { scrapeMultipleBoards, parseScrapedJobs, type BoardUpdate } from "@/lib/mino-client"; +import { generateId, createJobHash, cn } from "@/lib/utils"; +import type { Job, ApplicationStatus, GeneratedSearchUrl, JobBoardScan } from "@/lib/types"; + +export function Dashboard() { + const { theme, setTheme } = useTheme(); + const { profile, updateProfile } = useProfile(); + const { jobs, addJobs, clearJobs } = useJobs(); + const { savedJobs, saveJob, updateSavedJob, removeSavedJob, updateStatus } = + useSavedJobs(); + const { config: searchConfig, updateConfig: updateSearchConfig } = + useSearchConfig(); + const { history, addScan, updateScan } = useScanHistory(); + + const [activeTab, setActiveTab] = useState("feed"); + const [selectedJob, setSelectedJob] = useState(null); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [isScanning, setIsScanning] = useState(false); + const [commandMenuOpen, setCommandMenuOpen] = useState(false); + const [showSearchDialog, setShowSearchDialog] = useState(false); + const [searchPrompt, setSearchPrompt] = useState(""); + + // Agent tab state + const [boardScans, setBoardScans] = useState>(new Map()); + const [selectedBoard, setSelectedBoard] = useState(null); + const [currentStreamingUrl, setCurrentStreamingUrl] = useState(null); + const abortControllerRef = useRef(null); + + // Listen for keyboard shortcut + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + setCommandMenuOpen(true); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + + // Process completed board results immediately + const processCompletedBoard = useCallback((boardName: string, rawJobs: unknown[]) => { + if (!Array.isArray(rawJobs) || rawJobs.length === 0) { + return; + } + + const parsed = parseScrapedJobs(rawJobs, boardName); + if (parsed.length === 0) { + return; + } + + // Create Job objects with quick estimates + const newJobs: Job[] = parsed.map((scraped) => { + const hash = createJobHash(scraped.company, scraped.title, scraped.location); + const quickScore = profile + ? quickMatchEstimate(scraped as Partial, profile) + : 50; + const quickExplanation = profile + ? generateQuickExplanation(scraped as Partial, profile) + : "Complete your profile for personalized match insights."; + + return { + id: generateId(), + title: scraped.title, + company: scraped.company, + location: scraped.location, + salaryRange: scraped.salaryRange, + remoteStatus: scraped.remoteStatus, + description: scraped.description, + fullUrl: scraped.fullUrl, + postedDate: scraped.postedDate, + sourceBoard: scraped.sourceBoard, + matchScore: quickScore, + matchExplanation: quickExplanation, + hash, + firstSeenAt: new Date().toISOString(), + }; + }); + + // Add jobs immediately (dedupe will happen on addJobs) + addJobs(newJobs); + toast.success(`Found ${newJobs.length} jobs from ${boardName}`); + }, [profile, addJobs]); + + const handleFindJobsClick = () => { + if (!profile) { + toast.error("Please complete your profile first"); + return; + } + // Pre-fill with existing prompt if any + setSearchPrompt(searchConfig.jobSearchPrompt || ""); + setShowSearchDialog(true); + }; + + const handleStartScan = async () => { + setShowSearchDialog(false); + + // Save the search prompt to config + if (searchPrompt !== searchConfig.jobSearchPrompt) { + updateSearchConfig({ jobSearchPrompt: searchPrompt }); + } + + // Switch to Agent tab + setActiveTab("agent"); + setIsScanning(true); + setCurrentStreamingUrl(null); + setSelectedBoard(null); + setBoardScans(new Map()); + + toast.info("Generating search queries based on your preferences..."); + + try { + // Generate search URLs using AI with the updated prompt + const configWithPrompt = { ...searchConfig, jobSearchPrompt: searchPrompt }; + + const urls = await generateSearchUrls(profile!, configWithPrompt); + + if (urls.length === 0) { + toast.error("Failed to generate search URLs"); + setIsScanning(false); + return; + } + + toast.success(`Generated ${urls.length} search URLs`); + + // Initialize board scans + const initialScans = new Map(); + urls.forEach(({ boardName, searchUrl }) => { + initialScans.set(boardName, { + board: boardName, + searchUrl, + status: "pending", + steps: [], + jobsFound: 0, + }); + }); + setBoardScans(initialScans); + + // Start scraping + abortControllerRef.current = new AbortController(); + + await scrapeMultipleBoards( + urls, + searchPrompt, // Pass the job search criteria to filter relevant jobs + (boardName, update: BoardUpdate) => { + setBoardScans((prev) => { + const newMap = new Map(prev); + const existing = newMap.get(boardName); + if (existing) { + const updated: JobBoardScan = { + ...existing, + status: update.status, + steps: update.step + ? [...existing.steps, update.step].slice(-5) + : existing.steps, + jobsFound: update.jobsFound ?? existing.jobsFound, + error: update.error, + streamingUrl: update.streamingUrl ?? existing.streamingUrl, + }; + newMap.set(boardName, updated); + } + return newMap; + }); + + // Auto-select first board with streaming URL + if (update.streamingUrl) { + setSelectedBoard((prev) => prev || boardName); + setCurrentStreamingUrl((prev) => prev || update.streamingUrl || null); + } + + // Process completed results immediately when jobs are included + if (update.status === "complete" && update.jobs && update.jobs.length > 0) { + processCompletedBoard(boardName, update.jobs); + } + }, + abortControllerRef.current.signal + ); + + setIsScanning(false); + toast.success("Scan complete!"); + + } catch (error) { + toast.error("Scanning failed"); + setIsScanning(false); + } + }; + + const handleCancelScan = () => { + abortControllerRef.current?.abort(); + setIsScanning(false); + toast.info("Scan cancelled"); + }; + + const handleSelectBoard = (boardName: string) => { + setSelectedBoard(boardName); + const scan = boardScans.get(boardName); + if (scan?.streamingUrl) { + setCurrentStreamingUrl(scan.streamingUrl); + } + }; + + const handleSaveJob = useCallback( + (jobId: string) => { + saveJob(jobId, "saved"); + toast.success("Job saved!"); + }, + [saveJob] + ); + + const handleUnsaveJob = useCallback( + (savedJobId: string) => { + removeSavedJob(savedJobId); + toast.info("Job removed from saved"); + }, + [removeSavedJob] + ); + + const handleJobClick = (job: Job) => { + setSelectedJob(job); + setIsDialogOpen(true); + }; + + const handleUpdateStatus = (status: ApplicationStatus) => { + if (selectedJob) { + updateStatus(selectedJob.id, status); + toast.success(`Status updated to ${status}`); + } + }; + + const getSavedJobForJob = (jobId: string) => + savedJobs.find((sj) => sj.jobId === jobId); + + const lastScan = history[0]; + const completedCount = Array.from(boardScans.values()).filter( + (s) => s.status === "complete" || s.status === "error" + ).length; + const totalBoards = boardScans.size; + const progressPercent = totalBoards > 0 ? Math.round((completedCount / totalBoards) * 100) : 0; + + const getStatusIcon = (status: JobBoardScan["status"]) => { + switch (status) { + case "pending": + return
; + case "searching": + return ; + case "extracting": + return ; + case "complete": + return ; + case "error": + return ; + } + }; + + return ( +
+ {/* Notion-style minimal header */} +
+
+
+ + Job Hunter +
+ +
+ + + +
+
+
+ + {/* Tabs at the top */} +
+
+ + + + + Agent + {isScanning && ( + + )} + + + + Jobs + {jobs.length > 0 && ( + + {jobs.length} + + )} + + + + Tracker + {savedJobs.length > 0 && ( + + {savedJobs.length} + + )} + + + + Settings + + + +
+
+ + {/* Main content */} +
+ + {/* Agent Tab - Split view: list left, browser right */} + + {boardScans.size > 0 ? ( +
+ {/* Progress header */} +
+
+ + {completedCount} / {totalBoards} complete + + +
+ {isScanning && ( + + )} +
+ + {/* Split layout */} +
+ {/* Left: Board list */} +
+ {Array.from(boardScans.values()).map((scan) => ( + + ))} +
+ + {/* Right: Live browser preview */} +
+ {currentStreamingUrl ? ( +
+
+ {selectedBoard} + +
+