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 0000000..718d6fe Binary files /dev/null and b/job-hunting/app/favicon.ico differ 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 0000000..2b7ee4b Binary files /dev/null and b/job-hunting/assets/1e173e48-3235-4016-9a14-2d985b781134.jpg differ diff --git a/job-hunting/components.json b/job-hunting/components.json new file mode 100644 index 0000000..111f572 --- /dev/null +++ b/job-hunting/components.json @@ -0,0 +1 @@ +{"$schema": "https://ui.shadcn.com/schema.json", "style": "new-york", "rsc": true, "tsx": true, "tailwind": {"config": "", "css": "app/globals.css", "baseColor": "zinc", "cssVariables": true, "prefix": ""}, "aliases": {"components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/lib/hooks"}, "iconLibrary": "lucide"} diff --git a/job-hunting/components/command-menu.tsx b/job-hunting/components/command-menu.tsx new file mode 100644 index 0000000..18aa2f0 --- /dev/null +++ b/job-hunting/components/command-menu.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + Search, + Briefcase, + Bookmark, + Kanban, + Settings, + FileText, + Download, + Moon, + Sun, +} from "lucide-react"; +import { useTheme } from "next-themes"; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; +import type { Job } from "@/lib/types"; +import { exportData } from "@/lib/hooks/use-local-storage"; +import { toast } from "sonner"; + +interface CommandMenuProps { + open: boolean; + onOpenChange: (open: boolean) => 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} + +
+