Job search tracker: CRUD, AI parse/tailor/cover via OmniRoute, legacy import

This commit is contained in:
Jonathan Sykes
2026-08-16 19:05:19 +08:00
parent 8aebc279db
commit 9ba9ae675a
31 changed files with 2294 additions and 85 deletions

1
.gitignore vendored
View File

@@ -39,3 +39,4 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
.env

1062
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,12 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^6.19.3",
"gray-matter": "^4.0.3",
"marked": "^18.0.9",
"next": "16.3.1", "next": "16.3.1",
"openai": "^7.4.0",
"prisma": "^6.19.3",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
@@ -21,6 +26,7 @@
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.3.1", "eslint-config-next": "16.3.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"tsx": "^4.23.12",
"typescript": "^5" "typescript": "^5"
} }
} }

BIN
prisma/dev.db Normal file

Binary file not shown.

View File

@@ -0,0 +1,91 @@
-- CreateTable
CREATE TABLE "Job" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"company" TEXT NOT NULL,
"source" TEXT,
"url" TEXT,
"applyUrl" TEXT,
"payMin" REAL,
"payMax" REAL,
"payCurrency" TEXT,
"payPeriod" TEXT,
"location" TEXT,
"remoteType" TEXT,
"phEligibility" TEXT NOT NULL DEFAULT 'unclear',
"description" TEXT,
"requirements" TEXT,
"tags" TEXT,
"status" TEXT NOT NULL DEFAULT 'prospect',
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Resume" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"kind" TEXT NOT NULL DEFAULT 'tailored',
"markdown" TEXT NOT NULL,
"htmlRendered" TEXT,
"version" INTEGER NOT NULL DEFAULT 1,
"jobId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Resume_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "Job" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "CoverLetter" (
"id" TEXT NOT NULL PRIMARY KEY,
"markdown" TEXT NOT NULL,
"version" INTEGER NOT NULL DEFAULT 1,
"jobId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "CoverLetter_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "Job" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Application" (
"id" TEXT NOT NULL PRIMARY KEY,
"jobId" TEXT NOT NULL,
"method" TEXT NOT NULL DEFAULT 'manual',
"submittedAt" DATETIME,
"confirmation" TEXT,
"answers" TEXT,
"log" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Application_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "Job" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Profile" (
"id" TEXT NOT NULL PRIMARY KEY DEFAULT 'main',
"fullName" TEXT NOT NULL,
"email" TEXT,
"phone" TEXT,
"location" TEXT,
"linkedin" TEXT,
"github" TEXT,
"portfolio" TEXT,
"headline" TEXT,
"summary" TEXT,
"skills" TEXT,
"workHistory" TEXT,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "AiRun" (
"id" TEXT NOT NULL PRIMARY KEY,
"type" TEXT NOT NULL,
"model" TEXT,
"inputSummary" TEXT,
"resultRef" TEXT,
"ok" BOOLEAN NOT NULL DEFAULT true,
"error" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

97
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,97 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model Job {
id String @id @default(cuid())
title String
company String
source String?
url String?
applyUrl String?
payMin Float?
payMax Float?
payCurrency String? // USD, PHP
payPeriod String? // hour, month, year
location String?
remoteType String? // remote, hybrid, onsite
phEligibility String @default("unclear") // yes, unclear, no
description String?
requirements String?
tags String? // comma-separated
status String @default("prospect") // prospect, reviewing, tailoring, ready, applied, interviewing, offer, rejected, dead
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
resumes Resume[]
coverLetters CoverLetter[]
applications Application[]
}
model Resume {
id String @id @default(cuid())
name String
kind String @default("tailored") // base, tailored
markdown String
htmlRendered String?
version Int @default(1)
jobId String?
job Job? @relation(fields: [jobId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model CoverLetter {
id String @id @default(cuid())
markdown String
version Int @default(1)
jobId String
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Application {
id String @id @default(cuid())
jobId String
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
method String @default("manual") // manual, semi, auto
submittedAt DateTime?
confirmation String?
answers String? // JSON: [{question, answer}]
log String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Profile {
id String @id @default("main")
fullName String
email String?
phone String?
location String?
linkedin String?
github String?
portfolio String?
headline String?
summary String?
skills String? // JSON array
workHistory String? // JSON array of {position, company, unit, start, end, supervisor, bullets[]}
updatedAt DateTime @updatedAt
}
model AiRun {
id String @id @default(cuid())
type String // parse, tailor, cover, answers
model String?
inputSummary String?
resultRef String? // id of created entity
ok Boolean @default(true)
error String?
createdAt DateTime @default(now())
}

169
scripts/import-legacy.ts Normal file
View File

@@ -0,0 +1,169 @@
/**
* One-time import of the legacy markdown job files + resumes into the DB.
* Usage: npx tsx scripts/import-legacy.ts
*/
import { PrismaClient } from "@prisma/client";
import matter from "gray-matter";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
const prisma = new PrismaClient();
const LEGACY = path.join(os.homedir(), "development/personal/ai-job-search");
function section(body: string, name: string): string | undefined {
const re = new RegExp(`##\\s*${name}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, "i");
return body.match(re)?.[1]?.trim();
}
function firstMatch(body: string, re: RegExp): string | undefined {
return body.match(re)?.[1]?.trim();
}
async function importJobs() {
const dir = path.join(LEGACY, "jobs");
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".md"));
for (const file of files) {
const raw = fs.readFileSync(path.join(dir, file), "utf8");
const { data, content } = matter(raw);
const title = (data.title as string) ?? file.replace(/\.md$/, "");
const company = (data.company as string) ?? "Unknown";
const existing = await prisma.job.findFirst({ where: { title, company } });
if (existing) {
console.log(`skip (exists): ${file}`);
continue;
}
const url = firstMatch(content, /\*\*Link:\*\*\s*(\S+)/);
const applyUrl = firstMatch(content, /\*\*Apply(?: link)?:\*\*\s*(\S+)/);
const details = section(content, "Details");
const payLine = details ? firstMatch(details, /\*\*Pay(?:\s*Range)?:\*\*\s*(.+)/) : undefined;
const location = details ? firstMatch(details, /\*\*Location:\*\*\s*(.+)/) : undefined;
// crude pay parse: "$15-20/hr", "₱150-170k/mo", "$65-120/hr"
let payMin: number | undefined, payMax: number | undefined, payCurrency: string | undefined, payPeriod: string | undefined;
if (payLine) {
const m = payLine.match(/([$₱P])\s*([\d,.]+)\s*[-–]\s*([\d,.]+)(k?)\s*\/?\s*(hr|hour|mo|month|yr|year)?/i);
if (m) {
const mult = m[4] === "k" ? 1000 : 1;
payMin = parseFloat(m[2].replace(/,/g, "")) * mult;
payMax = parseFloat(m[3].replace(/,/g, "")) * mult;
payCurrency = m[1] === "$" ? "USD" : "PHP";
payPeriod = /hr|hour/i.test(m[5] ?? "") ? "hour" : /mo/i.test(m[5] ?? "") ? "month" : /yr|year/i.test(m[5] ?? "") ? "year" : undefined;
}
}
const phEligibility = /philippines|ph eligible|170\+ countries|worldwide/i.test(content) ? "yes" : "unclear";
const status = (data.status as string)?.replace(/\s+/g, "-") === "not-applied" ? "prospect" : "prospect";
await prisma.job.create({
data: {
title,
company,
source: file.replace(/\.md$/, ""),
url,
applyUrl,
payMin,
payMax,
payCurrency,
payPeriod,
location,
remoteType: "remote",
phEligibility,
description: section(content, "Description"),
requirements: section(content, "Requirements"),
notes: section(content, "Notes") ?? section(content, "Tailoring notes"),
status,
},
});
console.log(`imported: ${file}`);
}
}
async function importResumes() {
const base = path.join(LEGACY, "resume/resume.md");
if (fs.existsSync(base)) {
const md = fs.readFileSync(base, "utf8");
const exists = await prisma.resume.findFirst({ where: { kind: "base" } });
if (!exists) {
await prisma.resume.create({ data: { name: "Base Resume", kind: "base", markdown: md } });
console.log("imported: base resume");
}
}
const tailoredDir = path.join(LEGACY, "resume/tailored");
if (fs.existsSync(tailoredDir)) {
for (const f of fs.readdirSync(tailoredDir).filter((f) => f.endsWith(".md"))) {
const name = `Tailored: ${f.replace(/\.md$/, "")}`;
const exists = await prisma.resume.findFirst({ where: { name } });
if (exists) continue;
await prisma.resume.create({
data: { name, kind: "tailored", markdown: fs.readFileSync(path.join(tailoredDir, f), "utf8") },
});
console.log(`imported: tailored ${f}`);
}
}
}
async function seedProfile() {
const exists = await prisma.profile.findUnique({ where: { id: "main" } });
if (exists) return;
await prisma.profile.create({
data: {
id: "main",
fullName: "Joshua C. Sarmiento",
email: "joshuacsarmiento@gmail.com",
location: "Philippines",
headline: "Software Developer / AI Engineer — Prompt Engineering, Conversational AI, Backend Systems",
summary:
"Software developer with 8+ years of experience spanning enterprise IT, full-stack web development, and applied AI: building LLM-powered APIs and RAG systems with FastAPI and Amazon Bedrock.",
skills: JSON.stringify([
"Prompt engineering", "Amazon Bedrock", "RAG", "Conversational AI", "Python", "FastAPI",
"REST API design", "Active Directory", "Full-stack web development", "Bash/PowerShell automation",
]),
workHistory: JSON.stringify([
{
position: "Project Technical Specialist III",
company: "Philippine Institute of Volcanology and Seismology (PHIVOLCS)",
unit: "Geomatics and Hazard Assessment Services Section",
start: "2025-03", end: null, supervisor: "Eric Santos",
bullets: [
"Contributed to CLUP and CDRA systems",
"Gates Handa: AI APIs with FastAPI + Amazon Bedrock, chatbot, RAG document/report analyzer",
"Design system diagrams and plans; support project management",
],
},
{
position: "Computer Programmer II",
company: "Presidential Commission for the Urban Poor (PCUP)",
unit: "IT Unit",
start: "2019-08", end: "2024-02", supervisor: "Jason Ronidel",
bullets: [
"Developed major web apps: Attendance Monitoring, Service Workflow Management, UPO Accreditation, Reservation, Directory",
"Managed commission network infrastructure and devices",
"MS Access + VBA database applications",
],
},
{
position: "IT Associate",
company: "Manulife Business Process Services",
unit: "Global Access Management (GAM) Division",
start: "2016-11", end: "2017-10", supervisor: "Emory Lopez",
bullets: [
"Automated Active Directory user management with scripts (.sh, .ps1, .bat)",
"Managed AD, mainframe, Oracle, FTP, and email accounts (Exchange, Lotus Notes, O365)",
"ServiceNow request handling; VPN token creation",
],
},
]),
},
});
console.log("seeded: profile");
}
async function main() {
await importJobs();
await importResumes();
await seedProfile();
console.log("done");
}
main().finally(() => prisma.$disconnect());

View File

@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { aiComplete } from "@/lib/ai";
const SYSTEM = `You write concise, specific cover letters (250-350 words, markdown).
Truthful only — never invent experience. Reference the company and role by name.
Output ONLY the letter markdown.`;
export async function POST(req: NextRequest) {
const { jobId } = await req.json();
const job = await prisma.job.findUnique({ where: { id: jobId } });
if (!job) return NextResponse.json({ error: "job not found" }, { status: 404 });
const profile = await prisma.profile.findUnique({ where: { id: "main" } });
const base = await prisma.resume.findFirst({ where: { kind: "base" } });
const markdown = await aiComplete({
type: "cover",
system: SYSTEM,
user: `CANDIDATE PROFILE:\n${JSON.stringify(profile)}\n\nRESUME:\n${base?.markdown ?? ""}\n\nJOB: ${job.title} at ${job.company}\n${job.description ?? ""}\n${job.requirements ?? ""}`,
inputSummary: `${job.company} — ${job.title}`,
});
const version = (await prisma.coverLetter.count({ where: { jobId } })) + 1;
const letter = await prisma.coverLetter.create({ data: { jobId, markdown, version } });
return NextResponse.json(letter, { status: 201 });
}

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import { aiComplete, extractJson } from "@/lib/ai";
const SYSTEM = `You are a job-posting parser. Extract structured data from the job posting text.
Reply with ONLY a JSON object with these keys (null when unknown):
{"title","company","url","applyUrl","payMin","payMax","payCurrency","payPeriod","location","remoteType","phEligibility","description","requirements","tags"}
- payCurrency: "USD" or "PHP". payPeriod: "hour"|"month"|"year".
- remoteType: "remote"|"hybrid"|"onsite".
- phEligibility: "yes" if Philippines applicants are explicitly allowed or the role is worldwide; "no" if excluded; otherwise "unclear".
- tags: comma-separated keywords.
- description: 2-4 sentence summary. requirements: bullet list as plain text.`;
export async function POST(req: NextRequest) {
const { text, url } = await req.json();
let input = text as string | undefined;
if (!input && url) {
// Server-side fetch; many boards (JobStreet) will block — caller falls back to pasting text.
const res = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0" } });
if (!res.ok) {
return NextResponse.json(
{ error: `Fetch failed (${res.status}). Paste the job text instead.` },
{ status: 422 },
);
}
const html = await res.text();
input = html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").slice(0, 20000);
}
if (!input) return NextResponse.json({ error: "Provide text or url" }, { status: 400 });
const reply = await aiComplete({
type: "parse",
system: SYSTEM,
user: input,
inputSummary: url ?? input.slice(0, 120),
});
try {
const parsed = extractJson<Record<string, unknown>>(reply);
if (url && !parsed.url) parsed.url = url;
return NextResponse.json(parsed);
} catch {
return NextResponse.json({ error: "AI reply was not valid JSON", raw: reply }, { status: 502 });
}
}

View File

@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { aiComplete } from "@/lib/ai";
const SYSTEM = `You are a resume-tailoring assistant. Given a base resume (markdown) and a job posting, produce a tailored resume in markdown.
Rules:
- Keep all facts truthful — never invent experience, employers, or dates.
- Reorder and reword to emphasize what the job asks for.
- Keep it one page worth of content.
- Keep the candidate's real contact lines exactly as in the base resume.
- Output ONLY the resume markdown, no commentary.`;
export async function POST(req: NextRequest) {
const { jobId } = await req.json();
const job = await prisma.job.findUnique({ where: { id: jobId } });
if (!job) return NextResponse.json({ error: "job not found" }, { status: 404 });
const base = await prisma.resume.findFirst({ where: { kind: "base" }, orderBy: { updatedAt: "desc" } });
if (!base) return NextResponse.json({ error: "no base resume" }, { status: 400 });
const jobText = `# ${job.title} — ${job.company}\n${job.description ?? ""}\nRequirements:\n${job.requirements ?? ""}\nNotes:\n${job.notes ?? ""}`;
const markdown = await aiComplete({
type: "tailor",
system: SYSTEM,
user: `BASE RESUME:\n${base.markdown}\n\nJOB POSTING:\n${jobText}`,
inputSummary: `${job.company} — ${job.title}`,
});
const version = (await prisma.resume.count({ where: { jobId } })) + 1;
const resume = await prisma.resume.create({
data: { name: `Tailored: ${job.company} — ${job.title} v${version}`, kind: "tailored", markdown, jobId, version },
});
if (job.status === "prospect" || job.status === "reviewing") {
await prisma.job.update({ where: { id: jobId }, data: { status: "tailoring" } });
}
return NextResponse.json(resume, { status: 201 });
}

View File

@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET() {
const apps = await prisma.application.findMany({
orderBy: { updatedAt: "desc" },
include: { job: { select: { title: true, company: true } } },
});
return NextResponse.json(apps);
}
export async function POST(req: NextRequest) {
const data = await req.json();
const app = await prisma.application.create({ data });
if (data.submittedAt) {
await prisma.job.update({ where: { id: data.jobId }, data: { status: "applied" } });
}
return NextResponse.json(app, { status: 201 });
}

View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
type Params = { params: Promise<{ id: string }> };
export async function GET(_req: NextRequest, { params }: Params) {
const { id } = await params;
const job = await prisma.job.findUnique({
where: { id },
include: { resumes: true, coverLetters: true, applications: true },
});
if (!job) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json(job);
}
export async function PATCH(req: NextRequest, { params }: Params) {
const { id } = await params;
const data = await req.json();
const job = await prisma.job.update({ where: { id }, data });
return NextResponse.json(job);
}
export async function DELETE(_req: NextRequest, { params }: Params) {
const { id } = await params;
await prisma.job.delete({ where: { id } });
return NextResponse.json({ ok: true });
}

22
src/app/api/jobs/route.ts Normal file
View File

@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET(req: NextRequest) {
const status = req.nextUrl.searchParams.get("status") ?? undefined;
const q = req.nextUrl.searchParams.get("q") ?? undefined;
const jobs = await prisma.job.findMany({
where: {
...(status ? { status } : {}),
...(q ? { OR: [{ title: { contains: q } }, { company: { contains: q } }] } : {}),
},
orderBy: { updatedAt: "desc" },
include: { applications: true, resumes: { select: { id: true, name: true } } },
});
return NextResponse.json(jobs);
}
export async function POST(req: NextRequest) {
const data = await req.json();
const job = await prisma.job.create({ data });
return NextResponse.json(job, { status: 201 });
}

View File

@@ -0,0 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET() {
const profile = await prisma.profile.findUnique({ where: { id: "main" } });
return NextResponse.json(profile);
}
export async function PATCH(req: NextRequest) {
const data = await req.json();
const profile = await prisma.profile.upsert({
where: { id: "main" },
update: data,
create: { id: "main", fullName: data.fullName ?? "Me", ...data },
});
return NextResponse.json(profile);
}

View File

@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
type Params = { params: Promise<{ id: string }> };
export async function GET(_req: NextRequest, { params }: Params) {
const { id } = await params;
const resume = await prisma.resume.findUnique({ where: { id } });
if (!resume) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json(resume);
}
export async function PATCH(req: NextRequest, { params }: Params) {
const { id } = await params;
const data = await req.json();
const resume = await prisma.resume.update({ where: { id }, data });
return NextResponse.json(resume);
}
export async function DELETE(_req: NextRequest, { params }: Params) {
const { id } = await params;
await prisma.resume.delete({ where: { id } });
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET() {
const resumes = await prisma.resume.findMany({
orderBy: { updatedAt: "desc" },
include: { job: { select: { title: true, company: true } } },
});
return NextResponse.json(resumes);
}
export async function POST(req: NextRequest) {
const data = await req.json();
const resume = await prisma.resume.create({ data });
return NextResponse.json(resume, { status: 201 });
}

View File

@@ -0,0 +1,27 @@
import { prisma } from "@/lib/db";
import Link from "next/link";
export const dynamic = "force-dynamic";
export default async function ApplicationsPage() {
const apps = await prisma.application.findMany({
orderBy: { updatedAt: "desc" },
include: { job: { select: { id: true, title: true, company: true, status: true } } },
});
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold">Applications ({apps.length})</h1>
{apps.length === 0 && <p className="text-zinc-500 text-sm">No applications yet. Open a job and click “Mark Applied”.</p>}
<ul className="space-y-2">
{apps.map((a) => (
<li key={a.id} className="rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-4 text-sm">
<Link href={`/jobs/${a.job.id}`} className="font-medium hover:text-blue-600">{a.job.title} — {a.job.company}</Link>
<div className="text-zinc-500">
{a.method} · {a.submittedAt ? `submitted ${a.submittedAt.toISOString().slice(0, 10)}` : "not submitted"} · job status: {a.job.status}
</div>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,79 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
const STATUSES = ["prospect", "reviewing", "tailoring", "ready", "applied", "interviewing", "offer", "rejected", "dead"];
export default function JobActions({ job }: { job: { id: string; status: string } }) {
const router = useRouter();
const [busy, setBusy] = useState("");
const [msg, setMsg] = useState("");
async function setStatus(status: string) {
await fetch(`/api/jobs/${job.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
});
router.refresh();
}
async function run(kind: "tailor" | "cover") {
setBusy(kind); setMsg("");
try {
const res = await fetch(`/api/ai/${kind}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jobId: job.id }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "failed");
setMsg(kind === "tailor" ? "Tailored resume created ✅" : "Cover letter created ✅");
router.refresh();
} catch (e) { setMsg(String(e)); } finally { setBusy(""); }
}
async function markApplied() {
await fetch("/api/applications", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jobId: job.id, method: "manual", submittedAt: new Date().toISOString() }),
});
router.refresh();
}
async function del() {
if (!confirm("Delete this job?")) return;
await fetch(`/api/jobs/${job.id}`, { method: "DELETE" });
router.push("/jobs");
}
return (
<div className="rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-4 space-y-3">
<div className="flex items-center gap-2 text-sm">
<span className="text-zinc-500">Status:</span>
<select value={job.status} onChange={(e) => setStatus(e.target.value)}
className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-1">
{STATUSES.map((s) => <option key={s}>{s}</option>)}
</select>
</div>
<div className="flex flex-wrap gap-2">
<button onClick={() => run("tailor")} disabled={!!busy}
className="rounded bg-purple-600 text-white px-3 py-1.5 text-sm disabled:opacity-50 hover:bg-purple-700">
{busy === "tailor" ? "Generating…" : "🪄 AI Tailor Resume"}
</button>
<button onClick={() => run("cover")} disabled={!!busy}
className="rounded bg-indigo-600 text-white px-3 py-1.5 text-sm disabled:opacity-50 hover:bg-indigo-700">
{busy === "cover" ? "Generating…" : "✉️ AI Cover Letter"}
</button>
<button onClick={markApplied} className="rounded bg-green-600 text-white px-3 py-1.5 text-sm hover:bg-green-700">
✅ Mark Applied
</button>
<button onClick={del} className="rounded border border-red-300 text-red-600 px-3 py-1.5 text-sm hover:bg-red-50 dark:hover:bg-red-950">
Delete
</button>
</div>
{msg && <div className="text-sm">{msg}</div>}
</div>
);
}

View File

@@ -0,0 +1,71 @@
import { prisma } from "@/lib/db";
import { notFound } from "next/navigation";
import Link from "next/link";
import JobActions from "./job-actions";
export const dynamic = "force-dynamic";
export default async function JobDetail({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const job = await prisma.job.findUnique({
where: { id },
include: { resumes: true, coverLetters: true, applications: true },
});
if (!job) notFound();
return (
<div className="max-w-3xl space-y-6">
<div>
<Link href="/jobs" className="text-sm text-zinc-500 hover:text-blue-600">← Jobs</Link>
<h1 className="text-2xl font-bold mt-1">{job.title}</h1>
<div className="text-zinc-500">{job.company} · {job.location ?? "Remote"} · PH: {job.phEligibility}</div>
<div className="mt-2 flex gap-3 text-sm">
{job.url && <a href={job.url} target="_blank" className="text-blue-600 hover:underline">Listing ↗</a>}
{job.applyUrl && <a href={job.applyUrl} target="_blank" className="text-blue-600 hover:underline">Apply page ↗</a>}
</div>
</div>
<JobActions job={JSON.parse(JSON.stringify(job))} />
{job.description && (
<section>
<h2 className="font-semibold mb-1">Description</h2>
<p className="text-sm whitespace-pre-wrap">{job.description}</p>
</section>
)}
{job.requirements && (
<section>
<h2 className="font-semibold mb-1">Requirements</h2>
<p className="text-sm whitespace-pre-wrap">{job.requirements}</p>
</section>
)}
{job.notes && (
<section>
<h2 className="font-semibold mb-1">Notes</h2>
<p className="text-sm whitespace-pre-wrap">{job.notes}</p>
</section>
)}
{job.resumes.length > 0 && (
<section>
<h2 className="font-semibold mb-1">Tailored resumes</h2>
<ul className="text-sm space-y-1">
{job.resumes.map((r) => (
<li key={r.id}><Link href={`/resumes/${r.id}`} className="text-blue-600 hover:underline">{r.name}</Link></li>
))}
</ul>
</section>
)}
{job.coverLetters.length > 0 && (
<section>
<h2 className="font-semibold mb-1">Cover letters</h2>
<ul className="text-sm space-y-2">
{job.coverLetters.map((c) => (
<li key={c.id} className="rounded border border-zinc-200 dark:border-zinc-800 p-3 whitespace-pre-wrap bg-white dark:bg-zinc-900">{c.markdown}</li>
))}
</ul>
</section>
)}
</div>
);
}

85
src/app/jobs/new/page.tsx Normal file
View File

@@ -0,0 +1,85 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
type Parsed = Record<string, string | number | null>;
export default function NewJobPage() {
const router = useRouter();
const [url, setUrl] = useState("");
const [text, setText] = useState("");
const [parsed, setParsed] = useState<Parsed | null>(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
async function parse() {
setBusy(true); setErr("");
try {
const res = await fetch("/api/ai/parse", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: url || undefined, text: text || undefined }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "parse failed");
setParsed(data);
} catch (e) { setErr(String(e)); } finally { setBusy(false); }
}
async function save() {
if (!parsed) return;
setBusy(true);
const res = await fetch("/api/jobs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(parsed),
});
const job = await res.json();
router.push(`/jobs/${job.id}`);
}
function set(k: string, v: string) { setParsed((p) => ({ ...p!, [k]: v || null })); }
return (
<div className="max-w-2xl space-y-4">
<h1 className="text-2xl font-bold">Add Job</h1>
{!parsed && (
<>
<input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="Job URL (may fail on Cloudflare sites)"
className="w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2 text-sm" />
<div className="text-center text-zinc-400 text-sm">— or paste job text (more reliable) —</div>
<textarea value={text} onChange={(e) => setText(e.target.value)} rows={10} placeholder="Paste the full job posting text here"
className="w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2 text-sm" />
<button onClick={parse} disabled={busy || (!url && !text)}
className="rounded bg-blue-600 text-white px-4 py-2 text-sm disabled:opacity-50 hover:bg-blue-700">
{busy ? "Parsing with AI…" : "Parse with AI"}
</button>
</>
)}
{err && <div className="rounded bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-200 p-3 text-sm">{err}</div>}
{parsed && (
<div className="space-y-3">
<h2 className="font-semibold">Review parsed fields</h2>
{["title", "company", "url", "applyUrl", "location", "remoteType", "phEligibility", "payMin", "payMax", "payCurrency", "payPeriod", "tags"].map((k) => (
<label key={k} className="block text-sm">
<span className="text-zinc-500">{k}</span>
<input value={String(parsed[k] ?? "")} onChange={(e) => set(k, e.target.value)}
className="mt-1 w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2" />
</label>
))}
{["description", "requirements"].map((k) => (
<label key={k} className="block text-sm">
<span className="text-zinc-500">{k}</span>
<textarea value={String(parsed[k] ?? "")} onChange={(e) => set(k, e.target.value)} rows={4}
className="mt-1 w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2" />
</label>
))}
<div className="flex gap-2">
<button onClick={save} disabled={busy} className="rounded bg-green-600 text-white px-4 py-2 text-sm hover:bg-green-700">Save Job</button>
<button onClick={() => setParsed(null)} className="rounded border border-zinc-300 dark:border-zinc-700 px-4 py-2 text-sm">Back</button>
</div>
</div>
)}
</div>
);
}

68
src/app/jobs/page.tsx Normal file
View File

@@ -0,0 +1,68 @@
import { prisma } from "@/lib/db";
import Link from "next/link";
export const dynamic = "force-dynamic";
const badge: Record<string, string> = {
prospect: "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
reviewing: "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200",
tailoring: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200",
ready: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
applied: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
interviewing: "bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200",
offer: "bg-emerald-200 text-emerald-900 dark:bg-emerald-800 dark:text-emerald-100",
rejected: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
dead: "bg-zinc-300 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-400",
};
function pay(j: { payMin: number | null; payMax: number | null; payCurrency: string | null; payPeriod: string | null }) {
if (!j.payMin && !j.payMax) return "—";
const cur = j.payCurrency === "PHP" ? "₱" : "$";
const per = j.payPeriod === "hour" ? "/hr" : j.payPeriod === "month" ? "/mo" : j.payPeriod === "year" ? "/yr" : "";
const fmt = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`);
return `${cur}${fmt(j.payMin ?? 0)}${j.payMax ? `–${fmt(j.payMax)}` : ""}${per}`;
}
export default async function JobsPage({ searchParams }: { searchParams: Promise<{ status?: string }> }) {
const { status } = await searchParams;
const jobs = await prisma.job.findMany({
where: status ? { status } : undefined,
orderBy: { updatedAt: "desc" },
});
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Jobs {status ? `— ${status}` : ""} ({jobs.length})</h1>
<Link href="/jobs/new" className="rounded bg-blue-600 text-white px-4 py-2 text-sm hover:bg-blue-700">+ Add Job</Link>
</div>
<div className="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-800">
<table className="w-full text-sm bg-white dark:bg-zinc-900">
<thead className="text-left text-zinc-500 border-b border-zinc-200 dark:border-zinc-800">
<tr>
<th className="p-3">Job</th>
<th className="p-3">Pay</th>
<th className="p-3">PH?</th>
<th className="p-3">Status</th>
</tr>
</thead>
<tbody>
{jobs.map((j) => (
<tr key={j.id} className="border-b border-zinc-100 dark:border-zinc-800 hover:bg-zinc-50 dark:hover:bg-zinc-800/50">
<td className="p-3">
<Link href={`/jobs/${j.id}`} className="font-medium hover:text-blue-600">{j.title}</Link>
<div className="text-zinc-500">{j.company}</div>
</td>
<td className="p-3 whitespace-nowrap">{pay(j)}</td>
<td className="p-3">{j.phEligibility === "yes" ? "✅" : j.phEligibility === "no" ? "❌" : "❓"}</td>
<td className="p-3">
<span className={`rounded-full px-2 py-0.5 text-xs ${badge[j.status] ?? ""}`}>{j.status}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -1,29 +1,38 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import Link from "next/link";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "AI Job Search",
description: "Generated by create next app", description: "Track jobs, tailor resumes, apply with AI",
}; };
export default function RootLayout({ children }: LayoutProps<"/">) { const nav = [
{ href: "/", label: "Dashboard" },
{ href: "/jobs", label: "Jobs" },
{ href: "/resumes", label: "Resumes" },
{ href: "/applications", label: "Applications" },
{ href: "/profile", label: "Profile" },
];
export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html <html lang="en">
lang="en" <body className="min-h-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100 antialiased">
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} <header className="border-b border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900">
> <div className="mx-auto max-w-6xl flex items-center gap-6 px-4 h-14">
<body className="min-h-full flex flex-col">{children}</body> <span className="font-bold">🎯 AI Job Search</span>
<nav className="flex gap-4 text-sm">
{nav.map((n) => (
<Link key={n.href} href={n.href} className="hover:text-blue-600 dark:hover:text-blue-400">
{n.label}
</Link>
))}
</nav>
</div>
</header>
<main className="mx-auto max-w-6xl px-4 py-6">{children}</main>
</body>
</html> </html>
); );
} }

View File

@@ -1,69 +1,47 @@
import Image from "next/image"; import { prisma } from "@/lib/db";
import Link from "next/link";
export const dynamic = "force-dynamic";
const STATUSES = ["prospect", "reviewing", "tailoring", "ready", "applied", "interviewing", "offer", "rejected", "dead"];
export default async function Dashboard() {
const jobs = await prisma.job.findMany();
const apps = await prisma.application.count();
const resumes = await prisma.resume.count();
const byStatus = Object.fromEntries(STATUSES.map((s) => [s, jobs.filter((j) => j.status === s).length]));
const phYes = jobs.filter((j) => j.phEligibility === "yes").length;
export default function Home() {
return ( return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black"> <div className="space-y-6">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start"> <h1 className="text-2xl font-bold">Dashboard</h1>
<Image <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
className="dark:invert h-5 w-[100px]" {[
src="/next.svg" ["Total jobs", jobs.length],
alt="Next.js logo" ["PH-eligible", phYes],
width={100} ["Resumes", resumes],
height={20} ["Applications", apps],
priority ].map(([label, n]) => (
/> <div key={label as string} className="rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-4">
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left"> <div className="text-3xl font-bold">{n as number}</div>
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50"> <div className="text-sm text-zinc-500">{label as string}</div>
To get started, edit the{" "} </div>
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]"> ))}
page.tsx </div>
</code>{" "} <div>
file. <h2 className="font-semibold mb-2">Pipeline</h2>
</h1> <div className="flex flex-wrap gap-2">
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400"> {STATUSES.map((s) => (
Looking for a starting point or more instructions? Head over to{" "} <Link key={s} href={`/jobs?status=${s}`} className="rounded-full border border-zinc-300 dark:border-zinc-700 px-3 py-1 text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800">
<a {s} <span className="font-bold">{byStatus[s]}</span>
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" </Link>
className="font-medium text-zinc-950 dark:text-zinc-50" ))}
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div> </div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row"> </div>
<a <div className="flex gap-3">
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]" <Link href="/jobs/new" className="rounded bg-blue-600 text-white px-4 py-2 text-sm hover:bg-blue-700">+ Add Job (AI parse)</Link>
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" <Link href="/jobs" className="rounded border border-zinc-300 dark:border-zinc-700 px-4 py-2 text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800">View all jobs</Link>
target="_blank" </div>
rel="noopener noreferrer"
>
<Image
className="dark:invert h-[14px] w-4"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={14}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div> </div>
); );
} }

9
src/app/profile/page.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { prisma } from "@/lib/db";
import ProfileEditor from "./profile-editor";
export const dynamic = "force-dynamic";
export default async function ProfilePage() {
const profile = await prisma.profile.findUnique({ where: { id: "main" } });
return <ProfileEditor profile={JSON.parse(JSON.stringify(profile ?? {}))} />;
}

View File

@@ -0,0 +1,62 @@
"use client";
import { useState } from "react";
type P = Record<string, string | null>;
const FIELDS: [string, string][] = [
["fullName", "Full name"], ["email", "Email"], ["phone", "Phone"], ["location", "Location"],
["linkedin", "LinkedIn"], ["github", "GitHub"], ["portfolio", "Portfolio URL"], ["headline", "Headline"],
];
export default function ProfileEditor({ profile }: { profile: P }) {
const [p, setP] = useState<P>(profile);
const [saved, setSaved] = useState(true);
async function save() {
const { ...data } = p;
delete (data as Record<string, unknown>).id;
delete (data as Record<string, unknown>).updatedAt;
await fetch("/api/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
setSaved(true);
}
function set(k: string, v: string) { setP((x) => ({ ...x, [k]: v })); setSaved(false); }
return (
<div className="max-w-2xl space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Profile</h1>
<button onClick={save} disabled={saved} className="rounded bg-blue-600 text-white px-4 py-2 text-sm disabled:opacity-50 hover:bg-blue-700">
{saved ? "Saved" : "Save"}
</button>
</div>
<div className="grid md:grid-cols-2 gap-3">
{FIELDS.map(([k, label]) => (
<label key={k} className="block text-sm">
<span className="text-zinc-500">{label}</span>
<input value={p[k] ?? ""} onChange={(e) => set(k, e.target.value)}
className="mt-1 w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2" />
</label>
))}
</div>
<label className="block text-sm">
<span className="text-zinc-500">Summary</span>
<textarea value={p.summary ?? ""} onChange={(e) => set("summary", e.target.value)} rows={4}
className="mt-1 w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2" />
</label>
<label className="block text-sm">
<span className="text-zinc-500">Skills (JSON array)</span>
<textarea value={p.skills ?? ""} onChange={(e) => set("skills", e.target.value)} rows={3}
className="mt-1 w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2 font-mono" />
</label>
<label className="block text-sm">
<span className="text-zinc-500">Work history (JSON)</span>
<textarea value={p.workHistory ?? ""} onChange={(e) => set("workHistory", e.target.value)} rows={12}
className="mt-1 w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2 font-mono" />
</label>
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { prisma } from "@/lib/db";
import { notFound } from "next/navigation";
import ResumeEditor from "./resume-editor";
export const dynamic = "force-dynamic";
export default async function ResumePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const resume = await prisma.resume.findUnique({ where: { id } });
if (!resume) notFound();
return <ResumeEditor resume={JSON.parse(JSON.stringify(resume))} />;
}

View File

@@ -0,0 +1,59 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { marked } from "marked";
export default function ResumeEditor({ resume }: { resume: { id: string; name: string; markdown: string } }) {
const [md, setMd] = useState(resume.markdown);
const [saved, setSaved] = useState(true);
const [preview, setPreview] = useState(true);
async function save() {
await fetch(`/api/resumes/${resume.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ markdown: md }),
});
setSaved(true);
}
function printResume() {
const w = window.open("", "_blank");
if (!w) return;
w.document.write(`<!DOCTYPE html><html><head><title>${resume.name}</title><style>
body{font-family:Georgia,serif;max-width:800px;margin:2rem auto;padding:0 1rem;line-height:1.5;color:#222}
h1{font-size:1.6rem;margin-bottom:0} h2{font-size:1.1rem;border-bottom:1px solid #ccc;padding-bottom:2px;margin-top:1.2rem}
h3{font-size:1rem;margin-bottom:0} ul{margin-top:.3rem} li{margin:.15rem 0} @media print{body{margin:0}}
</style></head><body>${marked.parse(md)}</body></html>`);
w.document.close();
setTimeout(() => w.print(), 300);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<div>
<Link href="/resumes" className="text-sm text-zinc-500 hover:text-blue-600">← Resumes</Link>
<h1 className="text-xl font-bold">{resume.name}</h1>
</div>
<div className="flex gap-2">
<button onClick={() => setPreview(!preview)} className="rounded border border-zinc-300 dark:border-zinc-700 px-3 py-1.5 text-sm">
{preview ? "Hide" : "Show"} preview
</button>
<button onClick={printResume} className="rounded border border-zinc-300 dark:border-zinc-700 px-3 py-1.5 text-sm">🖨 Print / PDF</button>
<button onClick={save} disabled={saved} className="rounded bg-blue-600 text-white px-4 py-1.5 text-sm disabled:opacity-50 hover:bg-blue-700">
{saved ? "Saved" : "Save"}
</button>
</div>
</div>
<div className={`grid gap-4 ${preview ? "md:grid-cols-2" : ""}`}>
<textarea value={md} onChange={(e) => { setMd(e.target.value); setSaved(false); }} rows={30}
className="w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-3 font-mono text-sm" />
{preview && (
<div className="rounded border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-4 prose prose-sm dark:prose-invert max-w-none overflow-auto"
dangerouslySetInnerHTML={{ __html: marked.parse(md) as string }} />
)}
</div>
</div>
);
}

26
src/app/resumes/page.tsx Normal file
View File

@@ -0,0 +1,26 @@
import { prisma } from "@/lib/db";
import Link from "next/link";
export const dynamic = "force-dynamic";
export default async function ResumesPage() {
const resumes = await prisma.resume.findMany({
orderBy: { updatedAt: "desc" },
include: { job: { select: { title: true, company: true } } },
});
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold">Resumes ({resumes.length})</h1>
<ul className="space-y-2">
{resumes.map((r) => (
<li key={r.id} className="rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-4">
<Link href={`/resumes/${r.id}`} className="font-medium hover:text-blue-600">{r.name}</Link>
<div className="text-sm text-zinc-500">
{r.kind}{r.job ? ` · for ${r.job.company} — ${r.job.title}` : ""} · updated {r.updatedAt.toISOString().slice(0, 10)}
</div>
</li>
))}
</ul>
</div>
);
}

63
src/lib/ai.ts Normal file
View File

@@ -0,0 +1,63 @@
import OpenAI from "openai";
import { prisma } from "./db";
// OmniRoute gateway is OpenAI-compatible. IMPORTANT: the ClinePass node is
// streaming-only — non-stream requests come back with empty choices. Always
// stream and accumulate.
let _client: OpenAI | undefined;
function client(): OpenAI {
_client ??= new OpenAI({
baseURL: process.env.OMNIROUTE_BASE_URL,
apiKey: process.env.OMNIROUTE_API_KEY || "unset",
});
return _client;
}
const MODEL = process.env.OMNIROUTE_MODEL ?? "deepseek-v4-flash";
export async function aiComplete(opts: {
type: string; // parse | tailor | cover | answers
system: string;
user: string;
inputSummary?: string;
}): Promise<string> {
try {
const stream = await client().chat.completions.create({
model: MODEL,
stream: true,
messages: [
{ role: "system", content: opts.system },
{ role: "user", content: opts.user },
],
});
let out = "";
for await (const chunk of stream) {
out += chunk.choices[0]?.delta?.content ?? "";
}
await prisma.aiRun.create({
data: { type: opts.type, model: MODEL, inputSummary: opts.inputSummary, ok: true },
});
return out;
} catch (e) {
await prisma.aiRun.create({
data: {
type: opts.type,
model: MODEL,
inputSummary: opts.inputSummary,
ok: false,
error: String(e),
},
});
throw e;
}
}
/** Extract the first JSON object from a model reply (handles ```json fences). */
export function extractJson<T>(text: string): T {
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
const raw = fenced ? fenced[1] : text;
const start = raw.indexOf("{");
const end = raw.lastIndexOf("}");
if (start === -1 || end === -1) throw new Error("No JSON object in AI reply");
return JSON.parse(raw.slice(start, end + 1)) as T;
}

7
src/lib/db.ts Normal file
View File

@@ -0,0 +1,7 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;