Redesign UI: modern SaaS dashboard styling with indigo/violet accents

This commit is contained in:
Jonathan Sykes
2026-08-16 19:32:21 +08:00
parent 9ba9ae675a
commit 5e9084b64e
11 changed files with 2113 additions and 312 deletions

View File

@@ -3,25 +3,212 @@ import Link from "next/link";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const STATUS_CONFIG: Record<
string,
{ label: string; bg: string; text: string; border: string; dot: string }
> = {
prospect: {
label: "Prospect",
bg: "bg-zinc-100 dark:bg-zinc-800/80",
text: "text-zinc-700 dark:text-zinc-300",
border: "border-zinc-200 dark:border-zinc-700",
dot: "bg-zinc-400 dark:bg-zinc-500",
},
reviewing: {
label: "Reviewing",
bg: "bg-amber-50 dark:bg-amber-950/40",
text: "text-amber-700 dark:text-amber-300",
border: "border-amber-200 dark:border-amber-800/60",
dot: "bg-amber-500",
},
tailoring: {
label: "Tailoring",
bg: "bg-purple-50 dark:bg-purple-950/40",
text: "text-purple-700 dark:text-purple-300",
border: "border-purple-200 dark:border-purple-800/60",
dot: "bg-purple-500",
},
ready: {
label: "Ready",
bg: "bg-blue-50 dark:bg-blue-950/40",
text: "text-blue-700 dark:text-blue-300",
border: "border-blue-200 dark:border-blue-800/60",
dot: "bg-blue-500",
},
applied: {
label: "Applied",
bg: "bg-indigo-50 dark:bg-indigo-950/40",
text: "text-indigo-700 dark:text-indigo-300",
border: "border-indigo-200 dark:border-indigo-800/60",
dot: "bg-indigo-500",
},
interviewing: {
label: "Interviewing",
bg: "bg-teal-50 dark:bg-teal-950/40",
text: "text-teal-700 dark:text-teal-300",
border: "border-teal-200 dark:border-teal-800/60",
dot: "bg-teal-500",
},
offer: {
label: "Offer",
bg: "bg-emerald-50 dark:bg-emerald-950/40",
text: "text-emerald-700 dark:text-emerald-300",
border: "border-emerald-200 dark:border-emerald-800/60",
dot: "bg-emerald-500",
},
rejected: {
label: "Rejected",
bg: "bg-rose-50 dark:bg-rose-950/40",
text: "text-rose-700 dark:text-rose-300",
border: "border-rose-200 dark:border-rose-800/60",
dot: "bg-rose-500",
},
dead: {
label: "Dead",
bg: "bg-zinc-100 dark:bg-zinc-800/60",
text: "text-zinc-500 dark:text-zinc-400",
border: "border-zinc-200 dark:border-zinc-700/60",
dot: "bg-zinc-400",
},
};
function getCompanyColor(name: string) {
const colors = [
"bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-950/80 dark:text-indigo-300 dark:border-indigo-800",
"bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950/80 dark:text-violet-300 dark:border-violet-800",
"bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950/80 dark:text-blue-300 dark:border-blue-800",
"bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-950/80 dark:text-emerald-300 dark:border-emerald-800",
"bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-950/80 dark:text-amber-300 dark:border-amber-800",
"bg-rose-100 text-rose-700 border-rose-200 dark:bg-rose-950/80 dark:text-rose-300 dark:border-rose-800",
"bg-teal-100 text-teal-700 border-teal-200 dark:bg-teal-950/80 dark:text-teal-300 dark:border-teal-800",
"bg-cyan-100 text-cyan-700 border-cyan-200 dark:bg-cyan-950/80 dark:text-cyan-300 dark:border-cyan-800",
];
let hash = 0;
for (let i = 0; i < (name || "").length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
}
function getInitials(name: string) {
if (!name) return "?";
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
export default async function ApplicationsPage() { export default async function ApplicationsPage() {
const apps = await prisma.application.findMany({ const apps = await prisma.application.findMany({
orderBy: { updatedAt: "desc" }, orderBy: { updatedAt: "desc" },
include: { job: { select: { id: true, title: true, company: true, status: true } } }, include: { job: { select: { id: true, title: true, company: true, status: true } } },
}); });
return ( return (
<div className="space-y-4"> <div className="space-y-6">
<h1 className="text-2xl font-bold">Applications ({apps.length})</h1> {/* Header */}
{apps.length === 0 && <p className="text-zinc-500 text-sm">No applications yet. Open a job and click “Mark Applied”.</p>} <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<ul className="space-y-2"> <div>
{apps.map((a) => ( <div className="flex items-center gap-3">
<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"> <h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">Applications</h1>
<Link href={`/jobs/${a.job.id}`} className="font-medium hover:text-blue-600">{a.job.title} — {a.job.company}</Link> <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-indigo-50 text-indigo-700 dark:bg-indigo-950/70 dark:text-indigo-300 border border-indigo-200/60 dark:border-indigo-800/60">
<div className="text-zinc-500"> {apps.length} {apps.length === 1 ? "application" : "applications"}
{a.method} · {a.submittedAt ? `submitted ${a.submittedAt.toISOString().slice(0, 10)}` : "not submitted"} · job status: {a.job.status} </span>
</div> </div>
</li> <p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
))} Track submitted job applications and their current recruitment pipeline status.
</ul> </p>
</div>
</div>
{/* Applications List */}
{apps.length === 0 ? (
<div className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-12 text-center shadow-xs">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-indigo-50 dark:bg-indigo-950/60 text-indigo-600 dark:text-indigo-400 mb-3">
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
</svg>
</div>
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">No applications recorded yet</h3>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-1 max-w-sm mx-auto">
Open any job in your list and click <strong>“Mark Applied”</strong> to log an application submission here.
</p>
<div className="mt-4">
<Link
href="/jobs"
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-violet-600 hover:from-indigo-500 hover:to-violet-500 shadow-sm shadow-indigo-500/20"
>
Browse Jobs
</Link>
</div>
</div>
) : (
<div className="space-y-3">
{apps.map((a) => {
const statusInfo = STATUS_CONFIG[a.job.status] || STATUS_CONFIG.applied;
const companyColor = getCompanyColor(a.job.company);
const initials = getInitials(a.job.company);
return (
<div
key={a.id}
className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-5 shadow-xs hover:shadow-md hover:border-zinc-300 dark:hover:border-zinc-700 transition-all group"
>
<div className="flex items-center gap-3.5 min-w-0">
<div
className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl font-bold text-xs border shadow-2xs ${companyColor}`}
>
{initials}
</div>
<div className="min-w-0 flex-1">
<Link
href={`/jobs/${a.job.id}`}
className="font-semibold text-sm text-zinc-900 dark:text-zinc-100 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors truncate block"
>
{a.job.title}
</Link>
<div className="flex flex-wrap items-center gap-2 mt-1 text-xs text-zinc-500 dark:text-zinc-400">
<span className="font-medium text-zinc-700 dark:text-zinc-300">{a.job.company}</span>
<span>·</span>
<span className="capitalize">{a.method} submission</span>
<span>·</span>
<span>
{a.submittedAt
? `Submitted on ${new Date(a.submittedAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}`
: "Not submitted"}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-3 self-end sm:self-center shrink-0">
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border capitalize ${statusInfo.bg} ${statusInfo.text} ${statusInfo.border}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${statusInfo.dot}`} />
{a.job.status}
</span>
<Link
href={`/jobs/${a.job.id}`}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-xl text-xs font-medium text-zinc-700 dark:text-zinc-300 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors"
>
<span>View Job</span>
<svg className="w-3.5 h-3.5 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</Link>
</div>
</div>
);
})}
</div>
)}
</div> </div>
); );
} }

View File

@@ -1,26 +1,147 @@
@import "tailwindcss"; @import "tailwindcss";
:root { :root {
--background: #ffffff; --background: #f8fafc;
--foreground: #171717; --foreground: #0f172a;
--card-bg: #ffffff;
--card-border: #e2e8f0;
} }
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--background: #0a0a0a; --background: #09090b;
--foreground: #ededed; --foreground: #f4f4f5;
--card-bg: #121215;
--card-border: #27272a;
} }
} }
body { body {
background: var(--background); background-color: var(--background);
color: var(--foreground); color: var(--foreground);
font-family: Arial, Helvetica, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
::selection {
background-color: rgba(99, 102, 241, 0.25);
color: inherit;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.4);
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(148, 163, 184, 0.6);
}
/* Resume & Markdown Preview Styling */
.resume-preview {
font-family: "Charter", "Bitstream Vera Serif", "Cambria", "Times New Roman", serif;
color: #1e293b;
line-height: 1.6;
}
.dark .resume-preview {
color: #e2e8f0;
}
.resume-preview h1 {
font-size: 1.75rem;
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 0.25rem;
color: #0f172a;
border-bottom: 2px solid #e2e8f0;
padding-bottom: 0.5rem;
}
.dark .resume-preview h1 {
color: #f8fafc;
border-bottom-color: #334155;
}
.resume-preview h2 {
font-size: 1.2rem;
font-weight: 600;
letter-spacing: -0.01em;
margin-top: 1.5rem;
margin-bottom: 0.5rem;
color: #1e293b;
border-bottom: 1px solid #e2e8f0;
padding-bottom: 0.25rem;
text-transform: uppercase;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 0.85rem;
letter-spacing: 0.08em;
}
.dark .resume-preview h2 {
color: #94a3b8;
border-bottom-color: #334155;
}
.resume-preview h3 {
font-size: 1.05rem;
font-weight: 600;
margin-top: 0.75rem;
margin-bottom: 0.25rem;
color: #0f172a;
}
.dark .resume-preview h3 {
color: #f1f5f9;
}
.resume-preview p {
margin-bottom: 0.5rem;
font-size: 0.95rem;
}
.resume-preview ul {
list-style-type: disc;
padding-left: 1.25rem;
margin-top: 0.25rem;
margin-bottom: 0.75rem;
}
.resume-preview li {
margin-bottom: 0.25rem;
font-size: 0.925rem;
}
.resume-preview strong {
font-weight: 600;
color: #0f172a;
}
.dark .resume-preview strong {
color: #f8fafc;
}
.resume-preview a {
color: #4f46e5;
text-decoration: underline;
}
.dark .resume-preview a {
color: #818cf8;
} }

View File

@@ -4,22 +4,39 @@ import { useRouter } from "next/navigation";
const STATUSES = ["prospect", "reviewing", "tailoring", "ready", "applied", "interviewing", "offer", "rejected", "dead"]; const STATUSES = ["prospect", "reviewing", "tailoring", "ready", "applied", "interviewing", "offer", "rejected", "dead"];
const STATUS_CONFIG: Record<string, { label: string; dot: string }> = {
prospect: { label: "Prospect", dot: "bg-zinc-400" },
reviewing: { label: "Reviewing", dot: "bg-amber-500" },
tailoring: { label: "Tailoring", dot: "bg-purple-500" },
ready: { label: "Ready", dot: "bg-blue-500" },
applied: { label: "Applied", dot: "bg-indigo-500" },
interviewing: { label: "Interviewing", dot: "bg-teal-500" },
offer: { label: "Offer", dot: "bg-emerald-500" },
rejected: { label: "Rejected", dot: "bg-rose-500" },
dead: { label: "Dead", dot: "bg-zinc-400" },
};
export default function JobActions({ job }: { job: { id: string; status: string } }) { export default function JobActions({ job }: { job: { id: string; status: string } }) {
const router = useRouter(); const router = useRouter();
const [busy, setBusy] = useState(""); const [busy, setBusy] = useState("");
const [msg, setMsg] = useState(""); const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
async function setStatus(status: string) { async function setStatus(status: string) {
try {
await fetch(`/api/jobs/${job.id}`, { await fetch(`/api/jobs/${job.id}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }), body: JSON.stringify({ status }),
}); });
router.refresh(); router.refresh();
} catch {
setMsg({ type: "error", text: "Failed to update status." });
}
} }
async function run(kind: "tailor" | "cover") { async function run(kind: "tailor" | "cover") {
setBusy(kind); setMsg(""); setBusy(kind);
setMsg(null);
try { try {
const res = await fetch(`/api/ai/${kind}`, { const res = await fetch(`/api/ai/${kind}`, {
method: "POST", method: "POST",
@@ -27,53 +44,163 @@ export default function JobActions({ job }: { job: { id: string; status: string
body: JSON.stringify({ jobId: job.id }), body: JSON.stringify({ jobId: job.id }),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "failed"); if (!res.ok) throw new Error(data.error ?? "Generation failed");
setMsg(kind === "tailor" ? "Tailored resume created ✅" : "Cover letter created ✅"); setMsg({
type: "success",
text: kind === "tailor" ? "Tailored resume created successfully! ✨" : "Cover letter created successfully! 📄",
});
router.refresh(); router.refresh();
} catch (e) { setMsg(String(e)); } finally { setBusy(""); } } catch (e) {
setMsg({ type: "error", text: String(e) });
} finally {
setBusy("");
}
} }
async function markApplied() { async function markApplied() {
setBusy("applied");
setMsg(null);
try {
await fetch("/api/applications", { await fetch("/api/applications", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jobId: job.id, method: "manual", submittedAt: new Date().toISOString() }), body: JSON.stringify({ jobId: job.id, method: "manual", submittedAt: new Date().toISOString() }),
}); });
setMsg({ type: "success", text: "Marked as applied! Application logged. ✅" });
router.refresh(); router.refresh();
} catch (e) {
setMsg({ type: "error", text: String(e) });
} finally {
setBusy("");
}
} }
async function del() { async function del() {
if (!confirm("Delete this job?")) return; if (!confirm("Are you sure you want to delete this job and its associated data?")) return;
try {
await fetch(`/api/jobs/${job.id}`, { method: "DELETE" }); await fetch(`/api/jobs/${job.id}`, { method: "DELETE" });
router.push("/jobs"); router.push("/jobs");
} catch (e) {
setMsg({ type: "error", text: String(e) });
}
} }
return ( 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="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-5 shadow-xs space-y-4">
<div className="flex items-center gap-2 text-sm"> <div className="flex flex-wrap items-center justify-between gap-4">
<span className="text-zinc-500">Status:</span> {/* Status Dropdown */}
<select value={job.status} onChange={(e) => setStatus(e.target.value)} <div className="flex items-center gap-2.5">
className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-1"> <span className="text-xs font-semibold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
{STATUSES.map((s) => <option key={s}>{s}</option>)} Status:
</span>
<div className="relative inline-flex items-center">
<select
value={job.status}
onChange={(e) => setStatus(e.target.value)}
className="appearance-none rounded-xl border border-zinc-300 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/80 py-1.5 pl-3 pr-8 text-xs font-semibold capitalize text-zinc-900 dark:text-zinc-100 focus:border-indigo-500 focus:outline-hidden focus:ring-2 focus:ring-indigo-500/20 transition-all cursor-pointer"
>
{STATUSES.map((s) => (
<option key={s} value={s}>
{STATUS_CONFIG[s]?.label || s}
</option>
))}
</select> </select>
<div className="pointer-events-none absolute right-2.5 flex items-center text-zinc-400">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</div> </div>
<div className="flex flex-wrap gap-2"> </div>
<button onClick={() => run("tailor")} disabled={!!busy} </div>
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"} {/* Action Buttons */}
<div className="flex flex-wrap items-center gap-2">
{/* AI Tailor Resume */}
<button
onClick={() => run("tailor")}
disabled={!!busy}
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold text-white bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 shadow-sm shadow-purple-500/20 disabled:opacity-50 disabled:cursor-not-allowed active:scale-95 transition-all"
>
{busy === "tailor" ? (
<>
<svg className="animate-spin w-3.5 h-3.5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<span>Generating…</span>
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
</svg>
<span>AI Tailor Resume</span>
</>
)}
</button> </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"> {/* AI Cover Letter */}
{busy === "cover" ? "Generating…" : "✉️ AI Cover Letter"} <button
onClick={() => run("cover")}
disabled={!!busy}
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-blue-600 hover:from-indigo-500 hover:to-blue-500 shadow-sm shadow-indigo-500/20 disabled:opacity-50 disabled:cursor-not-allowed active:scale-95 transition-all"
>
{busy === "cover" ? (
<>
<svg className="animate-spin w-3.5 h-3.5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<span>Generating…</span>
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
</svg>
<span>AI Cover Letter</span>
</>
)}
</button> </button>
<button onClick={markApplied} className="rounded bg-green-600 text-white px-3 py-1.5 text-sm hover:bg-green-700">
✅ Mark Applied {/* Mark Applied */}
<button
onClick={markApplied}
disabled={!!busy}
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold text-white bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 shadow-sm shadow-emerald-500/20 disabled:opacity-50 active:scale-95 transition-all"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
<span>Mark Applied</span>
</button> </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 {/* Delete Button */}
<button
onClick={del}
disabled={!!busy}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-medium text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-900/60 bg-rose-50/50 dark:bg-rose-950/30 hover:bg-rose-100 dark:hover:bg-rose-900/50 transition-all ml-auto"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
<span>Delete</span>
</button> </button>
</div> </div>
{msg && <div className="text-sm">{msg}</div>} </div>
{/* Alert / Toast Message */}
{msg && (
<div
className={`rounded-xl p-3 text-xs font-medium flex items-center gap-2 border ${
msg.type === "success"
? "bg-emerald-50 dark:bg-emerald-950/40 text-emerald-800 dark:text-emerald-200 border-emerald-200 dark:border-emerald-800/60"
: "bg-rose-50 dark:bg-rose-950/40 text-rose-800 dark:text-rose-200 border-rose-200 dark:border-rose-800/60"
}`}
>
<span>{msg.text}</span>
</div>
)}
</div> </div>
); );
} }

View File

@@ -5,65 +5,308 @@ import JobActions from "./job-actions";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
function getCompanyColor(name: string) {
const colors = [
"bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-950/80 dark:text-indigo-300 dark:border-indigo-800",
"bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950/80 dark:text-violet-300 dark:border-violet-800",
"bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950/80 dark:text-blue-300 dark:border-blue-800",
"bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-950/80 dark:text-emerald-300 dark:border-emerald-800",
"bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-950/80 dark:text-amber-300 dark:border-amber-800",
"bg-rose-100 text-rose-700 border-rose-200 dark:bg-rose-950/80 dark:text-rose-300 dark:border-rose-800",
"bg-teal-100 text-teal-700 border-teal-200 dark:bg-teal-950/80 dark:text-teal-300 dark:border-teal-800",
"bg-cyan-100 text-cyan-700 border-cyan-200 dark:bg-cyan-950/80 dark:text-cyan-300 dark:border-cyan-800",
];
let hash = 0;
for (let i = 0; i < (name || "").length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
}
function getInitials(name: string) {
if (!name) return "?";
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
function formatCompensation(j: { payMin: number | null; payMax: number | null; payCurrency: string | null; payPeriod: string | null }) {
if (!j.payMin && !j.payMax) return null;
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 JobDetail({ params }: { params: Promise<{ id: string }> }) { export default async function JobDetail({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
const job = await prisma.job.findUnique({ const job = await prisma.job.findUnique({
where: { id }, where: { id },
include: { resumes: true, coverLetters: true, applications: true }, include: {
resumes: { orderBy: { updatedAt: "desc" } },
coverLetters: { orderBy: { updatedAt: "desc" } },
applications: { orderBy: { updatedAt: "desc" } },
},
}); });
if (!job) notFound(); if (!job) notFound();
const comp = formatCompensation(job);
const companyColor = getCompanyColor(job.company);
const initials = getInitials(job.company);
return ( return (
<div className="max-w-3xl space-y-6"> <div className="max-w-4xl mx-auto space-y-6">
{/* Breadcrumb & Top Bar */}
<div> <div>
<Link href="/jobs" className="text-sm text-zinc-500 hover:text-blue-600">← Jobs</Link> <Link
<h1 className="text-2xl font-bold mt-1">{job.title}</h1> href="/jobs"
<div className="text-zinc-500">{job.company} · {job.location ?? "Remote"} · PH: {job.phEligibility}</div> className="inline-flex items-center gap-1.5 text-xs font-medium text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors mb-3"
<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>} <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
{job.applyUrl && <a href={job.applyUrl} target="_blank" className="text-blue-600 hover:underline">Apply page ↗</a>} <path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
</svg>
<span>Back to Jobs</span>
</Link>
{/* Main Header Card */}
<div className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-6 shadow-xs space-y-4">
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
<div className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl font-bold text-base border shadow-xs ${companyColor}`}>
{initials}
</div>
<div className="flex-1 min-w-0">
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white sm:text-3xl">
{job.title}
</h1>
<div className="flex flex-wrap items-center gap-2.5 mt-2 text-sm text-zinc-500 dark:text-zinc-400">
<span className="font-semibold text-zinc-800 dark:text-zinc-200">{job.company}</span>
<span>·</span>
<span className="inline-flex items-center gap-1">
<svg className="w-3.5 h-3.5 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
{job.location ?? "Remote"}
</span>
{job.remoteType && (
<>
<span>·</span>
<span className="capitalize">{job.remoteType}</span>
</>
)}
</div>
{/* Badges & Meta */}
<div className="flex flex-wrap items-center gap-2 mt-3">
{job.phEligibility === "yes" ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-50 text-emerald-700 border border-emerald-200 dark:bg-emerald-950/50 dark:text-emerald-300 dark:border-emerald-800/60">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
PH Eligible
</span>
) : job.phEligibility === "no" ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-rose-50 text-rose-700 border border-rose-200 dark:bg-rose-950/50 dark:text-rose-300 dark:border-rose-800/60">
<span className="w-1.5 h-1.5 rounded-full bg-rose-500" />
Not Eligible
</span>
) : (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-zinc-100 text-zinc-600 border border-zinc-200 dark:bg-zinc-800/60 dark:text-zinc-400 dark:border-zinc-700/60">
<span className="w-1.5 h-1.5 rounded-full bg-zinc-400" />
PH: Unclear
</span>
)}
{comp && (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-50 text-indigo-700 border border-indigo-200/80 dark:bg-indigo-950/50 dark:text-indigo-300 dark:border-indigo-800/60">
💰 {comp}
</span>
)}
{job.tags && (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
🏷️ {job.tags}
</span>
)}
</div> </div>
</div> </div>
{/* External Links */}
<div className="flex sm:flex-col gap-2 shrink-0 pt-2 sm:pt-0">
{job.url && (
<a
href={job.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-medium text-zinc-700 dark:text-zinc-300 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors"
>
<span>Listing</span>
<svg className="w-3.5 h-3.5 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</a>
)}
{job.applyUrl && (
<a
href={job.applyUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-medium text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-950/60 border border-indigo-200 dark:border-indigo-900/60 hover:bg-indigo-100 dark:hover:bg-indigo-900/80 transition-colors"
>
<span>Apply Page</span>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</a>
)}
</div>
</div>
</div>
</div>
{/* Action Toolbar */}
<JobActions job={JSON.parse(JSON.stringify(job))} /> <JobActions job={JSON.parse(JSON.stringify(job))} />
{job.description && ( {/* Tailored Resumes Section */}
<section> {job.resumes.length > 0 && (
<h2 className="font-semibold mb-1">Description</h2> <section className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-6 shadow-xs space-y-3">
<p className="text-sm whitespace-pre-wrap">{job.description}</p> <div className="flex items-center justify-between">
</section> <h2 className="text-base font-bold text-zinc-900 dark:text-white flex items-center gap-2">
)} <svg className="w-5 h-5 text-purple-600 dark:text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
{job.requirements && ( <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
<section> </svg>
<h2 className="font-semibold mb-1">Requirements</h2> <span>Tailored Resumes ({job.resumes.length})</span>
<p className="text-sm whitespace-pre-wrap">{job.requirements}</p> </h2>
</section> </div>
)} <div className="grid gap-2 sm:grid-cols-2">
{job.notes && ( {job.resumes.map((r) => (
<section> <div
<h2 className="font-semibold mb-1">Notes</h2> key={r.id}
<p className="text-sm whitespace-pre-wrap">{job.notes}</p> className="flex items-center justify-between p-3.5 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-zinc-50/50 dark:bg-zinc-800/40 hover:border-purple-300 dark:hover:border-purple-800 transition-all group"
>
<div className="min-w-0 flex-1">
<Link
href={`/resumes/${r.id}`}
className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 group-hover:text-purple-600 dark:group-hover:text-purple-400 transition-colors truncate block"
>
{r.name}
</Link>
<p className="text-xs text-zinc-400 mt-0.5 capitalize">
{r.kind} · v{r.version}
</p>
</div>
<Link
href={`/resumes/${r.id}`}
className="px-3 py-1 rounded-lg text-xs font-medium text-purple-700 dark:text-purple-300 bg-purple-50 dark:bg-purple-950/60 border border-purple-200/80 dark:border-purple-900/60 hover:bg-purple-100 transition-colors"
>
Edit / Print →
</Link>
</div>
))}
</div>
</section> </section>
)} )}
{job.resumes.length > 0 && ( {/* Cover Letters Section */}
<section> {job.coverLetters.length > 0 && (
<h2 className="font-semibold mb-1">Tailored resumes</h2> <section className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-6 shadow-xs space-y-3">
<ul className="text-sm space-y-1"> <h2 className="text-base font-bold text-zinc-900 dark:text-white flex items-center gap-2">
{job.resumes.map((r) => ( <svg className="w-5 h-5 text-indigo-600 dark:text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<li key={r.id}><Link href={`/resumes/${r.id}`} className="text-blue-600 hover:underline">{r.name}</Link></li> <path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
</svg>
<span>Cover Letters ({job.coverLetters.length})</span>
</h2>
<div className="space-y-3">
{job.coverLetters.map((c) => (
<div
key={c.id}
className="rounded-xl border border-zinc-200 dark:border-zinc-800 p-4 bg-zinc-50/50 dark:bg-zinc-950/40 text-sm text-zinc-700 dark:text-zinc-300 whitespace-pre-wrap leading-relaxed font-sans"
>
{c.markdown}
</div>
))} ))}
</ul> </div>
</section> </section>
)} )}
{job.coverLetters.length > 0 && (
<section> {/* Description */}
<h2 className="font-semibold mb-1">Cover letters</h2> {job.description && (
<ul className="text-sm space-y-2"> <section className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-6 shadow-xs space-y-3">
{job.coverLetters.map((c) => ( <h2 className="text-base font-bold text-zinc-900 dark:text-white flex items-center gap-2">
<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> <svg className="w-5 h-5 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25H12" />
</svg>
<span>Job Description</span>
</h2>
<div className="text-sm text-zinc-700 dark:text-zinc-300 whitespace-pre-wrap leading-relaxed">
{job.description}
</div>
</section>
)}
{/* Requirements */}
{job.requirements && (
<section className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-6 shadow-xs space-y-3">
<h2 className="text-base font-bold text-zinc-900 dark:text-white flex items-center gap-2">
<svg className="w-5 h-5 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Requirements</span>
</h2>
<div className="text-sm text-zinc-700 dark:text-zinc-300 whitespace-pre-wrap leading-relaxed">
{job.requirements}
</div>
</section>
)}
{/* Notes */}
{job.notes && (
<section className="rounded-2xl border border-amber-200/80 dark:border-amber-900/60 bg-amber-50/30 dark:bg-amber-950/20 p-6 shadow-xs space-y-2">
<h2 className="text-sm font-bold text-amber-800 dark:text-amber-300 flex items-center gap-2">
<span>📝 Private Notes</span>
</h2>
<p className="text-sm text-amber-900/80 dark:text-amber-200/80 whitespace-pre-wrap leading-relaxed">
{job.notes}
</p>
</section>
)}
{/* Applications Log */}
{job.applications.length > 0 && (
<section className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-6 shadow-xs space-y-3">
<h2 className="text-base font-bold text-zinc-900 dark:text-white flex items-center gap-2">
<svg className="w-5 h-5 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
</svg>
<span>Application Submissions</span>
</h2>
<div className="space-y-2">
{job.applications.map((a) => (
<div
key={a.id}
className="flex items-center justify-between p-3 rounded-xl bg-zinc-50/70 dark:bg-zinc-800/50 text-xs text-zinc-600 dark:text-zinc-400"
>
<div className="flex items-center gap-2">
<span className="capitalize font-medium text-zinc-800 dark:text-zinc-200">
Method: {a.method}
</span>
<span>·</span>
<span>
{a.submittedAt
? `Submitted on ${new Date(a.submittedAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}`
: "Pending submission"}
</span>
</div>
{a.confirmation && (
<span className="font-mono text-[11px] text-zinc-500">Ref: {a.confirmation}</span>
)}
</div>
))} ))}
</ul> </div>
</section> </section>
)} )}
</div> </div>

View File

@@ -1,9 +1,25 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link";
type Parsed = Record<string, string | number | null>; type Parsed = Record<string, string | number | null>;
const FIELD_LABELS: Record<string, string> = {
title: "Job Title",
company: "Company Name",
url: "Listing URL",
applyUrl: "Application / Portal URL",
location: "Location",
remoteType: "Remote Type (e.g. remote, hybrid, onsite)",
phEligibility: "PH Eligibility (yes / no / unclear)",
payMin: "Minimum Pay",
payMax: "Maximum Pay",
payCurrency: "Currency (USD, PHP, etc.)",
payPeriod: "Pay Period (hour, month, year)",
tags: "Tags (comma-separated)",
};
export default function NewJobPage() { export default function NewJobPage() {
const router = useRouter(); const router = useRouter();
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
@@ -13,7 +29,8 @@ export default function NewJobPage() {
const [err, setErr] = useState(""); const [err, setErr] = useState("");
async function parse() { async function parse() {
setBusy(true); setErr(""); setBusy(true);
setErr("");
try { try {
const res = await fetch("/api/ai/parse", { const res = await fetch("/api/ai/parse", {
method: "POST", method: "POST",
@@ -21,14 +38,19 @@ export default function NewJobPage() {
body: JSON.stringify({ url: url || undefined, text: text || undefined }), body: JSON.stringify({ url: url || undefined, text: text || undefined }),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "parse failed"); if (!res.ok) throw new Error(data.error ?? "Parse failed");
setParsed(data); setParsed(data);
} catch (e) { setErr(String(e)); } finally { setBusy(false); } } catch (e) {
setErr(String(e));
} finally {
setBusy(false);
}
} }
async function save() { async function save() {
if (!parsed) return; if (!parsed) return;
setBusy(true); setBusy(true);
try {
const res = await fetch("/api/jobs", { const res = await fetch("/api/jobs", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -36,47 +58,202 @@ export default function NewJobPage() {
}); });
const job = await res.json(); const job = await res.json();
router.push(`/jobs/${job.id}`); router.push(`/jobs/${job.id}`);
} catch (e) {
setErr(String(e));
setBusy(false);
}
} }
function set(k: string, v: string) { setParsed((p) => ({ ...p!, [k]: v || null })); } function set(k: string, v: string) {
setParsed((p) => ({ ...p!, [k]: v || null }));
}
return ( return (
<div className="max-w-2xl space-y-4"> <div className="max-w-3xl mx-auto space-y-6">
<h1 className="text-2xl font-bold">Add Job</h1> {/* Header */}
{!parsed && ( <div>
<Link
href="/jobs"
className="inline-flex items-center gap-1 text-xs font-medium text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors mb-2"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
</svg>
<span>Back to Jobs</span>
</Link>
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">Add New Job</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
Provide a job URL or paste the job posting description. AI will automatically extract key details and structured fields.
</p>
</div>
{err && (
<div className="rounded-xl bg-rose-50 dark:bg-rose-950/40 border border-rose-200 dark:border-rose-800/60 p-4 text-sm text-rose-800 dark:text-rose-200 flex items-start gap-3">
<svg className="w-5 h-5 text-rose-500 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
<div className="flex-1">{err}</div>
</div>
)}
{!parsed ? (
<div className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-6 shadow-xs space-y-6">
{/* URL Input */}
<div className="space-y-2">
<label className="block text-sm font-semibold text-zinc-800 dark:text-zinc-200">
Job Listing URL
</label>
<div className="relative">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-zinc-400">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
</svg>
</div>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com/careers/senior-engineer..."
className="w-full rounded-xl border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950/60 pl-10 pr-4 py-2.5 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:border-indigo-500 focus:outline-hidden focus:ring-2 focus:ring-indigo-500/20 transition-all"
/>
</div>
<p className="text-xs text-zinc-400 dark:text-zinc-500">
Note: Some sites with anti-bot or Cloudflare protection may block scraping; pasting raw text below is recommended.
</p>
</div>
{/* Divider */}
<div className="relative flex items-center justify-center">
<div className="border-t border-zinc-200 dark:border-zinc-800 w-full" />
<span className="bg-white dark:bg-zinc-900 px-3 text-xs font-medium text-zinc-400 uppercase tracking-wider absolute">
or paste job text
</span>
</div>
{/* Textarea */}
<div className="space-y-2">
<label className="block text-sm font-semibold text-zinc-800 dark:text-zinc-200">
Job Description / Posting Text
</label>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
rows={9}
placeholder="Paste the full job posting, responsibilities, requirements, and salary details here..."
className="w-full rounded-xl border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950/60 p-4 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:border-indigo-500 focus:outline-hidden focus:ring-2 focus:ring-indigo-500/20 transition-all leading-relaxed"
/>
</div>
{/* Parse Button */}
<div className="flex justify-end pt-2">
<button
onClick={parse}
disabled={busy || (!url && !text)}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-semibold text-white bg-gradient-to-r from-indigo-600 to-violet-600 hover:from-indigo-500 hover:to-violet-500 shadow-sm shadow-indigo-500/20 disabled:opacity-50 disabled:cursor-not-allowed active:scale-98 transition-all"
>
{busy ? (
<> <>
<input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="Job URL (may fail on Cloudflare sites)" <svg className="animate-spin w-4 h-4 text-white" fill="none" viewBox="0 0 24 24">
className="w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2 text-sm" /> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<div className="text-center text-zinc-400 text-sm">— or paste job text (more reliable) —</div> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
<textarea value={text} onChange={(e) => setText(e.target.value)} rows={10} placeholder="Paste the full job posting text here" </svg>
className="w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2 text-sm" /> <span>Parsing with AI…</span>
<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> <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
</svg>
<span>Parse with AI</span>
</> </>
)} )}
{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>} </button>
{parsed && ( </div>
<div className="space-y-3"> </div>
<h2 className="font-semibold">Review parsed fields</h2> ) : (
<div className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-6 shadow-xs space-y-6">
<div className="flex items-center justify-between pb-4 border-b border-zinc-200/80 dark:border-zinc-800/80">
<div>
<h2 className="text-lg font-bold text-zinc-900 dark:text-white flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950/80 text-emerald-600 dark:text-emerald-400 text-xs">
✓
</span>
Review Extracted Fields
</h2>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">
Verify and edit the structured data before saving to your tracker.
</p>
</div>
<button
type="button"
onClick={() => setParsed(null)}
className="text-xs font-medium text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100"
>
← Edit Raw Text
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{["title", "company", "url", "applyUrl", "location", "remoteType", "phEligibility", "payMin", "payMax", "payCurrency", "payPeriod", "tags"].map((k) => ( {["title", "company", "url", "applyUrl", "location", "remoteType", "phEligibility", "payMin", "payMax", "payCurrency", "payPeriod", "tags"].map((k) => (
<label key={k} className="block text-sm"> <div key={k} className="space-y-1">
<span className="text-zinc-500">{k}</span> <label className="block text-xs font-semibold text-zinc-600 dark:text-zinc-300">
<input value={String(parsed[k] ?? "")} onChange={(e) => set(k, e.target.value)} {FIELD_LABELS[k] || k}
className="mt-1 w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2" />
</label> </label>
<input
type="text"
value={String(parsed[k] ?? "")}
onChange={(e) => set(k, e.target.value)}
className="w-full rounded-xl border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950/60 px-3.5 py-2 text-sm text-zinc-900 dark:text-zinc-100 focus:border-indigo-500 focus:outline-hidden focus:ring-2 focus:ring-indigo-500/20 transition-all"
/>
</div>
))} ))}
</div>
{["description", "requirements"].map((k) => ( {["description", "requirements"].map((k) => (
<label key={k} className="block text-sm"> <div key={k} className="space-y-1">
<span className="text-zinc-500">{k}</span> <label className="block text-xs font-semibold text-zinc-600 dark:text-zinc-300 capitalize">
<textarea value={String(parsed[k] ?? "")} onChange={(e) => set(k, e.target.value)} rows={4} {k}
className="mt-1 w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2" />
</label> </label>
<textarea
value={String(parsed[k] ?? "")}
onChange={(e) => set(k, e.target.value)}
rows={5}
className="w-full rounded-xl border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950/60 p-3.5 text-sm text-zinc-900 dark:text-zinc-100 focus:border-indigo-500 focus:outline-hidden focus:ring-2 focus:ring-indigo-500/20 transition-all leading-relaxed"
/>
</div>
))} ))}
<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> <div className="flex items-center justify-end gap-3 pt-4 border-t border-zinc-200/80 dark:border-zinc-800/80">
<button onClick={() => setParsed(null)} className="rounded border border-zinc-300 dark:border-zinc-700 px-4 py-2 text-sm">Back</button> <button
type="button"
onClick={() => setParsed(null)}
className="px-4 py-2 rounded-xl text-sm font-medium text-zinc-700 dark:text-zinc-300 bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-all"
>
Back
</button>
<button
type="button"
onClick={save}
disabled={busy}
className="inline-flex items-center gap-2 px-5 py-2 rounded-xl text-sm font-semibold text-white bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 shadow-sm shadow-emerald-500/20 disabled:opacity-50 active:scale-98 transition-all"
>
{busy ? (
<>
<svg className="animate-spin w-4 h-4 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<span>Saving Job…</span>
</>
) : (
<>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
<span>Save Job</span>
</>
)}
</button>
</div> </div>
</div> </div>
)} )}

View File

@@ -3,18 +3,104 @@ import Link from "next/link";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const badge: Record<string, string> = { const STATUSES = ["prospect", "reviewing", "tailoring", "ready", "applied", "interviewing", "offer", "rejected", "dead"];
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", const STATUS_CONFIG: Record<
tailoring: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200", string,
ready: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200", { label: string; bg: string; text: string; border: string; dot: string }
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", prospect: {
offer: "bg-emerald-200 text-emerald-900 dark:bg-emerald-800 dark:text-emerald-100", label: "Prospect",
rejected: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", bg: "bg-zinc-100 dark:bg-zinc-800/80",
dead: "bg-zinc-300 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-400", text: "text-zinc-700 dark:text-zinc-300",
border: "border-zinc-200 dark:border-zinc-700",
dot: "bg-zinc-400 dark:bg-zinc-500",
},
reviewing: {
label: "Reviewing",
bg: "bg-amber-50 dark:bg-amber-950/40",
text: "text-amber-700 dark:text-amber-300",
border: "border-amber-200 dark:border-amber-800/60",
dot: "bg-amber-500",
},
tailoring: {
label: "Tailoring",
bg: "bg-purple-50 dark:bg-purple-950/40",
text: "text-purple-700 dark:text-purple-300",
border: "border-purple-200 dark:border-purple-800/60",
dot: "bg-purple-500",
},
ready: {
label: "Ready",
bg: "bg-blue-50 dark:bg-blue-950/40",
text: "text-blue-700 dark:text-blue-300",
border: "border-blue-200 dark:border-blue-800/60",
dot: "bg-blue-500",
},
applied: {
label: "Applied",
bg: "bg-indigo-50 dark:bg-indigo-950/40",
text: "text-indigo-700 dark:text-indigo-300",
border: "border-indigo-200 dark:border-indigo-800/60",
dot: "bg-indigo-500",
},
interviewing: {
label: "Interviewing",
bg: "bg-teal-50 dark:bg-teal-950/40",
text: "text-teal-700 dark:text-teal-300",
border: "border-teal-200 dark:border-teal-800/60",
dot: "bg-teal-500",
},
offer: {
label: "Offer",
bg: "bg-emerald-50 dark:bg-emerald-950/40",
text: "text-emerald-700 dark:text-emerald-300",
border: "border-emerald-200 dark:border-emerald-800/60",
dot: "bg-emerald-500",
},
rejected: {
label: "Rejected",
bg: "bg-rose-50 dark:bg-rose-950/40",
text: "text-rose-700 dark:text-rose-300",
border: "border-rose-200 dark:border-rose-800/60",
dot: "bg-rose-500",
},
dead: {
label: "Dead",
bg: "bg-zinc-100 dark:bg-zinc-800/60",
text: "text-zinc-500 dark:text-zinc-400",
border: "border-zinc-200 dark:border-zinc-700/60",
dot: "bg-zinc-400",
},
}; };
function getCompanyColor(name: string) {
const colors = [
"bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-950/80 dark:text-indigo-300 dark:border-indigo-800",
"bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950/80 dark:text-violet-300 dark:border-violet-800",
"bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950/80 dark:text-blue-300 dark:border-blue-800",
"bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-950/80 dark:text-emerald-300 dark:border-emerald-800",
"bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-950/80 dark:text-amber-300 dark:border-amber-800",
"bg-rose-100 text-rose-700 border-rose-200 dark:bg-rose-950/80 dark:text-rose-300 dark:border-rose-800",
"bg-teal-100 text-teal-700 border-teal-200 dark:bg-teal-950/80 dark:text-teal-300 dark:border-teal-800",
"bg-cyan-100 text-cyan-700 border-cyan-200 dark:bg-cyan-950/80 dark:text-cyan-300 dark:border-cyan-800",
];
let hash = 0;
for (let i = 0; i < (name || "").length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
}
function getInitials(name: string) {
if (!name) return "?";
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
function pay(j: { payMin: number | null; payMax: number | null; payCurrency: string | null; payPeriod: string | null }) { function pay(j: { payMin: number | null; payMax: number | null; payCurrency: string | null; payPeriod: string | null }) {
if (!j.payMin && !j.payMax) return "—"; if (!j.payMin && !j.payMax) return "—";
const cur = j.payCurrency === "PHP" ? "₱" : "$"; const cur = j.payCurrency === "PHP" ? "₱" : "$";
@@ -25,44 +111,228 @@ function pay(j: { payMin: number | null; payMax: number | null; payCurrency: str
export default async function JobsPage({ searchParams }: { searchParams: Promise<{ status?: string }> }) { export default async function JobsPage({ searchParams }: { searchParams: Promise<{ status?: string }> }) {
const { status } = await searchParams; const { status } = await searchParams;
const jobs = await prisma.job.findMany({ const [jobs, allJobs] = await Promise.all([
prisma.job.findMany({
where: status ? { status } : undefined, where: status ? { status } : undefined,
orderBy: { updatedAt: "desc" }, orderBy: { updatedAt: "desc" },
}); }),
prisma.job.findMany({ select: { status: true } }),
]);
const countByStatus = Object.fromEntries(
STATUSES.map((s) => [s, allJobs.filter((j) => j.status === s).length])
);
return ( return (
<div className="space-y-4"> <div className="space-y-6">
<div className="flex items-center justify-between"> {/* Header */}
<h1 className="text-2xl font-bold">Jobs {status ? `— ${status}` : ""} ({jobs.length})</h1> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<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="flex items-center gap-3">
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">
Jobs
</h1>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
{jobs.length} {jobs.length === 1 ? "position" : "positions"}
</span>
</div> </div>
<div className="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-800"> <p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
<table className="w-full text-sm bg-white dark:bg-zinc-900"> {status ? (
<thead className="text-left text-zinc-500 border-b border-zinc-200 dark:border-zinc-800"> <span>
Filtering by status: <strong className="text-zinc-800 dark:text-zinc-200 capitalize">{status}</strong>
</span>
) : (
"All tracked job opportunities across your application pipeline."
)}
</p>
</div>
<Link
href="/jobs/new"
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl text-sm font-semibold text-white bg-gradient-to-r from-indigo-600 to-violet-600 hover:from-indigo-500 hover:to-violet-500 shadow-sm shadow-indigo-500/20 active:scale-98 transition-all"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span>Add Job</span>
</Link>
</div>
{/* Filter Tabs */}
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 text-xs">
<Link
href="/jobs"
className={`px-3 py-1.5 rounded-lg font-medium whitespace-nowrap transition-all ${
!status
? "bg-zinc-900 text-white dark:bg-white dark:text-zinc-900 shadow-xs"
: "text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800"
}`}
>
All ({allJobs.length})
</Link>
{STATUSES.map((s) => {
const count = countByStatus[s] || 0;
const isActive = status === s;
return (
<Link
key={s}
href={`/jobs?status=${s}`}
className={`px-2.5 py-1.5 rounded-lg font-medium whitespace-nowrap capitalize transition-all ${
isActive
? "bg-indigo-600 text-white shadow-xs font-semibold"
: "text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800"
}`}
>
{s} <span className={`ml-1 text-[11px] ${isActive ? "text-indigo-100" : "text-zinc-400"}`}>({count})</span>
</Link>
);
})}
</div>
{/* Jobs Table Card */}
<div className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 shadow-xs overflow-hidden">
{jobs.length === 0 ? (
<div className="py-16 px-4 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-zinc-100 dark:bg-zinc-800 text-zinc-400 mb-3">
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 14.15v4.25c0 1.094-.787 2.036-1.872 2.18-2.087.277-4.216.42-6.378.42s-4.291-.143-6.378-.42c-1.085-.144-1.872-1.086-1.872-2.18v-4.25m16.5 0a2.18 2.18 0 00.75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 00-3.413-.387m4.5 8.006c-.194.165-.42.295-.673.38A23.978 23.978 0 0112 15.75c-2.648 0-5.195-.429-7.577-1.22a2.016 2.016 0 01-.673-.38m0 0A2.18 2.18 0 013 12.489V8.706c0-1.081.768-2.015 1.837-2.175a48.111 48.111 0 013.413-.387m7.5 0V5.25A2.25 2.25 0 0013.5 3h-3a2.25 2.25 0 00-2.25 2.25v.894m7.5 0a48.667 48.667 0 00-7.5 0M12 12.75h.008v.008H12v-.008z" />
</svg>
</div>
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">No jobs found</h3>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-1 max-w-sm mx-auto">
{status
? `There are currently no job listings with status "${status}".`
: "Get started by adding your first job opportunity."}
</p>
<div className="mt-4 flex items-center justify-center gap-3">
{status && (
<Link
href="/jobs"
className="px-3.5 py-1.5 rounded-lg text-xs font-medium text-zinc-700 dark:text-zinc-300 bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700"
>
Clear filter
</Link>
)}
<Link
href="/jobs/new"
className="px-3.5 py-1.5 rounded-lg text-xs font-semibold text-white bg-indigo-600 hover:bg-indigo-500 shadow-xs"
>
+ Add Job
</Link>
</div>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b border-zinc-200/80 dark:border-zinc-800/80 bg-zinc-50/70 dark:bg-zinc-900/50 text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
<tr> <tr>
<th className="p-3">Job</th> <th scope="col" className="py-3.5 pl-5 pr-3">Job & Company</th>
<th className="p-3">Pay</th> <th scope="col" className="py-3.5 px-3">Compensation</th>
<th className="p-3">PH?</th> <th scope="col" className="py-3.5 px-3">PH Eligibility</th>
<th className="p-3">Status</th> <th scope="col" className="py-3.5 px-3">Status</th>
<th scope="col" className="py-3.5 pl-3 pr-5 text-right">Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody className="divide-y divide-zinc-100 dark:divide-zinc-800/60 font-normal">
{jobs.map((j) => ( {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"> const statusInfo = STATUS_CONFIG[j.status] || STATUS_CONFIG.prospect;
<td className="p-3"> const companyColor = getCompanyColor(j.company);
<Link href={`/jobs/${j.id}`} className="font-medium hover:text-blue-600">{j.title}</Link> const initials = getInitials(j.company);
<div className="text-zinc-500">{j.company}</div>
return (
<tr
key={j.id}
className="hover:bg-zinc-50/80 dark:hover:bg-zinc-800/40 transition-colors group"
>
{/* Job & Company with Avatar */}
<td className="py-4 pl-5 pr-3">
<div className="flex items-center gap-3">
<div
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-xl font-bold text-xs border shadow-2xs ${companyColor}`}
>
{initials}
</div>
<div className="min-w-0 flex-1">
<Link
href={`/jobs/${j.id}`}
className="font-semibold text-zinc-900 dark:text-zinc-100 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors line-clamp-1"
>
{j.title}
</Link>
<div className="flex items-center gap-2 text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">
<span className="font-medium text-zinc-700 dark:text-zinc-300">{j.company}</span>
{j.location && (
<>
<span>·</span>
<span className="truncate">{j.location}</span>
</>
)}
{j.remoteType && (
<span className="inline-flex items-center px-1.5 py-0.2 rounded text-[10px] font-medium bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300">
{j.remoteType}
</span>
)}
</div>
</div>
</div>
</td> </td>
<td className="p-3 whitespace-nowrap">{pay(j)}</td>
<td className="p-3">{j.phEligibility === "yes" ? "✅" : j.phEligibility === "no" ? "❌" : "❓"}</td> {/* Pay */}
<td className="p-3"> <td className="py-4 px-3 whitespace-nowrap">
<span className={`rounded-full px-2 py-0.5 text-xs ${badge[j.status] ?? ""}`}>{j.status}</span> <span className="font-medium text-zinc-700 dark:text-zinc-300">
{pay(j)}
</span>
</td>
{/* PH Eligibility */}
<td className="py-4 px-3 whitespace-nowrap">
{j.phEligibility === "yes" ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-50 text-emerald-700 border border-emerald-200 dark:bg-emerald-950/50 dark:text-emerald-300 dark:border-emerald-800/60">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
PH Eligible
</span>
) : j.phEligibility === "no" ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-rose-50 text-rose-700 border border-rose-200 dark:bg-rose-950/50 dark:text-rose-300 dark:border-rose-800/60">
<span className="w-1.5 h-1.5 rounded-full bg-rose-500" />
Not Eligible
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-zinc-100 text-zinc-600 border border-zinc-200 dark:bg-zinc-800/60 dark:text-zinc-400 dark:border-zinc-700/60">
<span className="w-1.5 h-1.5 rounded-full bg-zinc-400" />
Unverified
</span>
)}
</td>
{/* Status Badge */}
<td className="py-4 px-3 whitespace-nowrap">
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border capitalize ${statusInfo.bg} ${statusInfo.text} ${statusInfo.border}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${statusInfo.dot}`} />
{j.status}
</span>
</td>
{/* View Action Link */}
<td className="py-4 pl-3 pr-5 text-right whitespace-nowrap">
<Link
href={`/jobs/${j.id}`}
className="inline-flex items-center gap-1 text-xs font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 opacity-80 group-hover:opacity-100 transition-opacity"
>
<span>View</span>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</Link>
</td> </td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
</div> </div>
)}
</div>
</div> </div>
); );
} }

View File

@@ -3,8 +3,8 @@ import Link from "next/link";
import "./globals.css"; import "./globals.css";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "AI Job Search", title: "AI Job Search — Smart Career Tracker & Resume Tailor",
description: "Track jobs, tailor resumes, apply with AI", description: "Track job applications, tailor resumes, and optimize your job hunt with AI.",
}; };
const nav = [ const nav = [
@@ -17,21 +17,63 @@ const nav = [
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en"> <html lang="en" className="h-full">
<body className="min-h-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100 antialiased"> <body className="min-h-full flex flex-col bg-zinc-50/70 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100 antialiased selection:bg-indigo-500/20 selection:text-indigo-900 dark:selection:text-indigo-200">
<header className="border-b border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900"> <header className="sticky top-0 z-40 border-b border-zinc-200/80 dark:border-zinc-800/80 bg-white/85 dark:bg-zinc-950/85 backdrop-blur-md transition-colors">
<div className="mx-auto max-w-6xl flex items-center gap-6 px-4 h-14"> <div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
<span className="font-bold">🎯 AI Job Search</span> <div className="flex h-16 items-center justify-between gap-4">
<nav className="flex gap-4 text-sm"> {/* Logo / Brand */}
<div className="flex items-center gap-8">
<Link href="/" className="flex items-center gap-2.5 group">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-tr from-indigo-600 to-violet-500 text-white shadow-md shadow-indigo-500/20 group-hover:scale-105 transition-transform">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div className="flex flex-col">
<div className="flex items-center gap-1.5">
<span className="font-bold tracking-tight text-zinc-900 dark:text-white text-base">JobFlow</span>
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-indigo-50 text-indigo-700 dark:bg-indigo-950/70 dark:text-indigo-300 border border-indigo-200/60 dark:border-indigo-800/60">
AI
</span>
</div>
<span className="text-[11px] text-zinc-400 dark:text-zinc-500 -mt-0.5 font-medium hidden sm:inline-block">Job Search Tracker</span>
</div>
</Link>
{/* Main Navigation */}
<nav className="flex items-center gap-1">
{nav.map((n) => ( {nav.map((n) => (
<Link key={n.href} href={n.href} className="hover:text-blue-600 dark:hover:text-blue-400"> <Link
key={n.href}
href={n.href}
className="px-3 py-1.5 rounded-lg text-sm font-medium text-zinc-600 hover:text-zinc-900 hover:bg-zinc-100/80 dark:text-zinc-400 dark:hover:text-zinc-100 dark:hover:bg-zinc-800/60 transition-all"
>
{n.label} {n.label}
</Link> </Link>
))} ))}
</nav> </nav>
</div> </div>
{/* Right CTA */}
<div className="flex items-center gap-3">
<Link
href="/jobs/new"
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-violet-600 hover:from-indigo-500 hover:to-violet-500 shadow-sm shadow-indigo-500/20 active:scale-95 transition-all"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span>Add Job</span>
</Link>
</div>
</div>
</div>
</header> </header>
<main className="mx-auto max-w-6xl px-4 py-6">{children}</main>
<main className="flex-1 mx-auto w-full max-w-6xl px-4 sm:px-6 lg:px-8 py-8">
{children}
</main>
</body> </body>
</html> </html>
); );

View File

@@ -5,43 +5,309 @@ export const dynamic = "force-dynamic";
const STATUSES = ["prospect", "reviewing", "tailoring", "ready", "applied", "interviewing", "offer", "rejected", "dead"]; const STATUSES = ["prospect", "reviewing", "tailoring", "ready", "applied", "interviewing", "offer", "rejected", "dead"];
const STAGE_CONFIG: Record<
string,
{ label: string; description: string; color: string; badge: string; border: string; bgActive: string }
> = {
prospect: {
label: "Prospect",
description: "Saved positions",
color: "bg-zinc-400 dark:bg-zinc-500",
badge: "bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
border: "border-zinc-200 dark:border-zinc-800 hover:border-zinc-400 dark:hover:border-zinc-600",
bgActive: "bg-zinc-50 dark:bg-zinc-800/40",
},
reviewing: {
label: "Reviewing",
description: "Evaluating fit",
color: "bg-amber-500",
badge: "bg-amber-100 text-amber-800 dark:bg-amber-950/80 dark:text-amber-300",
border: "border-amber-200/80 dark:border-amber-900/60 hover:border-amber-400 dark:hover:border-amber-600",
bgActive: "bg-amber-50/50 dark:bg-amber-950/20",
},
tailoring: {
label: "Tailoring",
description: "Customizing resume",
color: "bg-purple-500",
badge: "bg-purple-100 text-purple-800 dark:bg-purple-950/80 dark:text-purple-300",
border: "border-purple-200/80 dark:border-purple-900/60 hover:border-purple-400 dark:hover:border-purple-600",
bgActive: "bg-purple-50/50 dark:bg-purple-950/20",
},
ready: {
label: "Ready",
description: "Ready to submit",
color: "bg-blue-500",
badge: "bg-blue-100 text-blue-800 dark:bg-blue-950/80 dark:text-blue-300",
border: "border-blue-200/80 dark:border-blue-900/60 hover:border-blue-400 dark:hover:border-blue-600",
bgActive: "bg-blue-50/50 dark:bg-blue-950/20",
},
applied: {
label: "Applied",
description: "Application sent",
color: "bg-indigo-500",
badge: "bg-indigo-100 text-indigo-800 dark:bg-indigo-950/80 dark:text-indigo-300",
border: "border-indigo-200/80 dark:border-indigo-900/60 hover:border-indigo-400 dark:hover:border-indigo-600",
bgActive: "bg-indigo-50/50 dark:bg-indigo-950/20",
},
interviewing: {
label: "Interviewing",
description: "In discussions",
color: "bg-teal-500",
badge: "bg-teal-100 text-teal-800 dark:bg-teal-950/80 dark:text-teal-300",
border: "border-teal-200/80 dark:border-teal-900/60 hover:border-teal-400 dark:hover:border-teal-600",
bgActive: "bg-teal-50/50 dark:bg-teal-950/20",
},
offer: {
label: "Offer",
description: "Offer received",
color: "bg-emerald-500",
badge: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950/80 dark:text-emerald-300",
border: "border-emerald-200/80 dark:border-emerald-900/60 hover:border-emerald-400 dark:hover:border-emerald-600",
bgActive: "bg-emerald-50/50 dark:bg-emerald-950/20",
},
rejected: {
label: "Rejected",
description: "Not selected",
color: "bg-rose-500",
badge: "bg-rose-100 text-rose-800 dark:bg-rose-950/80 dark:text-rose-300",
border: "border-rose-200/80 dark:border-rose-900/60 hover:border-rose-400 dark:hover:border-rose-600",
bgActive: "bg-rose-50/50 dark:bg-rose-950/20",
},
dead: {
label: "Dead",
description: "Closed / Inactive",
color: "bg-zinc-400",
badge: "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400",
border: "border-zinc-200/80 dark:border-zinc-800 hover:border-zinc-400 dark:hover:border-zinc-600",
bgActive: "bg-zinc-50 dark:bg-zinc-800/30",
},
};
export default async function Dashboard() { export default async function Dashboard() {
const jobs = await prisma.job.findMany(); const jobs = await prisma.job.findMany({
orderBy: { updatedAt: "desc" },
take: 5,
});
const totalJobsCount = await prisma.job.count();
const allJobs = await prisma.job.findMany({ select: { status: true, phEligibility: true } });
const apps = await prisma.application.count(); const apps = await prisma.application.count();
const resumes = await prisma.resume.count(); const resumes = await prisma.resume.count();
const byStatus = Object.fromEntries(STATUSES.map((s) => [s, jobs.filter((j) => j.status === s).length])); const byStatus = Object.fromEntries(STATUSES.map((s) => [s, allJobs.filter((j) => j.status === s).length]));
const phYes = jobs.filter((j) => j.phEligibility === "yes").length; const phYes = allJobs.filter((j) => j.phEligibility === "yes").length;
const statCards = [
{
label: "Total Jobs Tracked",
value: totalJobsCount,
sub: "All pipeline opportunities",
icon: (
<svg className="w-5 h-5 text-indigo-600 dark:text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 14.15v4.25c0 1.094-.787 2.036-1.872 2.18-2.087.277-4.216.42-6.378.42s-4.291-.143-6.378-.42c-1.085-.144-1.872-1.086-1.872-2.18v-4.25m16.5 0a2.18 2.18 0 00.75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 00-3.413-.387m4.5 8.006c-.194.165-.42.295-.673.38A23.978 23.978 0 0112 15.75c-2.648 0-5.195-.429-7.577-1.22a2.016 2.016 0 01-.673-.38m0 0A2.18 2.18 0 013 12.489V8.706c0-1.081.768-2.015 1.837-2.175a48.111 48.111 0 013.413-.387m7.5 0V5.25A2.25 2.25 0 0013.5 3h-3a2.25 2.25 0 00-2.25 2.25v.894m7.5 0a48.667 48.667 0 00-7.5 0M12 12.75h.008v.008H12v-.008z" />
</svg>
),
iconBg: "bg-indigo-50 dark:bg-indigo-950/60 border-indigo-100 dark:border-indigo-900/50",
},
{
label: "PH-Eligible Roles",
value: phYes,
sub: `${totalJobsCount > 0 ? Math.round((phYes / totalJobsCount) * 100) : 0}% of all positions`,
icon: (
<svg className="w-5 h-5 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
iconBg: "bg-emerald-50 dark:bg-emerald-950/60 border-emerald-100 dark:border-emerald-900/50",
},
{
label: "Tailored Resumes",
value: resumes,
sub: "AI-customized versions",
icon: (
<svg className="w-5 h-5 text-purple-600 dark:text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
),
iconBg: "bg-purple-50 dark:bg-purple-950/60 border-purple-100 dark:border-purple-900/50",
},
{
label: "Applications Sent",
value: apps,
sub: "Active submissions",
icon: (
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
</svg>
),
iconBg: "bg-blue-50 dark:bg-blue-950/60 border-blue-100 dark:border-blue-900/50",
},
];
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<h1 className="text-2xl font-bold">Dashboard</h1> {/* Header & Quick Action */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 pb-2 border-b border-zinc-200/70 dark:border-zinc-800/70">
{[ <div>
["Total jobs", jobs.length], <h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">Dashboard</h1>
["PH-eligible", phYes], <p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
["Resumes", resumes], Track applications, monitor your hiring pipeline, and tailor resumes with AI.
["Applications", apps], </p>
].map(([label, n]) => ( </div>
<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 items-center gap-3">
<div className="text-3xl font-bold">{n as number}</div> <Link
<div className="text-sm text-zinc-500">{label as string}</div> href="/jobs"
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl text-sm font-medium text-zinc-700 dark:text-zinc-200 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 hover:bg-zinc-50 dark:hover:bg-zinc-800/60 shadow-xs transition-all"
>
<svg className="w-4 h-4 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
<span>View all jobs</span>
</Link>
<Link
href="/jobs/new"
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold text-white bg-gradient-to-r from-indigo-600 to-violet-600 hover:from-indigo-500 hover:to-violet-500 shadow-sm shadow-indigo-500/20 active:scale-98 transition-all"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span>Add Job (AI parse)</span>
</Link>
</div>
</div>
{/* Stat Cards Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{statCards.map((card) => (
<div
key={card.label}
className="relative overflow-hidden rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-5 shadow-xs transition-all hover:shadow-md hover:border-zinc-300 dark:hover:border-zinc-700"
>
<div className="flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
{card.label}
</span>
<div className={`flex h-9 w-9 items-center justify-center rounded-xl border ${card.iconBg}`}>
{card.icon}
</div>
</div>
<div className="mt-3 flex items-baseline gap-2">
<span className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-white">
{card.value}
</span>
</div>
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
{card.sub}
</p>
</div> </div>
))} ))}
</div> </div>
{/* Visual Pipeline Section */}
<div className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-6 shadow-xs space-y-5">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div>
<h2 className="text-lg font-bold tracking-tight text-zinc-900 dark:text-white">Application Pipeline</h2>
<p className="text-xs text-zinc-500 dark:text-zinc-400">Click any stage to filter jobs by status</p>
</div>
<div className="text-xs font-medium text-zinc-500 dark:text-zinc-400">
{totalJobsCount} total opportunities
</div>
</div>
{/* Pipeline Stage Cards */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-9 gap-2.5">
{STATUSES.map((s) => {
const count = byStatus[s] ?? 0;
const config = STAGE_CONFIG[s] || {
label: s,
description: "",
color: "bg-zinc-400",
badge: "bg-zinc-100 text-zinc-700",
border: "border-zinc-200",
bgActive: "bg-zinc-50",
};
const hasItems = count > 0;
return (
<Link
key={s}
href={`/jobs?status=${s}`}
className={`group flex flex-col justify-between p-3 rounded-xl border transition-all ${
config.border
} ${
hasItems
? `${config.bgActive} shadow-xs hover:shadow-sm hover:-translate-y-0.5`
: "bg-zinc-50/50 dark:bg-zinc-900/40 opacity-75 hover:opacity-100"
}`}
>
<div className="flex items-center justify-between mb-2">
<span className={`w-2 h-2 rounded-full ${config.color}`} />
<span
className={`inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full text-xs font-bold ${
hasItems ? config.badge : "text-zinc-400 dark:text-zinc-600 bg-zinc-100 dark:bg-zinc-800"
}`}
>
{count}
</span>
</div>
<div> <div>
<h2 className="font-semibold mb-2">Pipeline</h2> <div className="text-xs font-semibold text-zinc-800 dark:text-zinc-200 capitalize group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors">
<div className="flex flex-wrap gap-2"> {config.label}
{STATUSES.map((s) => ( </div>
<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"> <div className="text-[10px] text-zinc-400 dark:text-zinc-500 truncate mt-0.5">
{s} <span className="font-bold">{byStatus[s]}</span> {config.description}
</div>
</div>
</Link> </Link>
);
})}
</div>
</div>
{/* Recent Jobs Preview */}
{jobs.length > 0 && (
<div className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-6 shadow-xs space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold tracking-tight text-zinc-900 dark:text-white">Recently Updated Jobs</h2>
<Link href="/jobs" className="text-xs font-medium text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300">
View all ({totalJobsCount}) →
</Link>
</div>
<div className="divide-y divide-zinc-100 dark:divide-zinc-800/80">
{jobs.map((j) => (
<div key={j.id} className="py-3 flex items-center justify-between gap-4 first:pt-0 last:pb-0 group">
<div className="min-w-0 flex-1">
<Link
href={`/jobs/${j.id}`}
className="font-medium text-sm text-zinc-900 dark:text-zinc-100 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors truncate block"
>
{j.title}
</Link>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">
{j.company} · {j.location ?? "Remote"}
</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${
STAGE_CONFIG[j.status]?.badge ?? "bg-zinc-100 text-zinc-700"
}`}
>
{j.status}
</span>
<Link
href={`/jobs/${j.id}`}
className="p-1 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</Link>
</div>
</div>
))} ))}
</div> </div>
</div> </div>
<div className="flex gap-3"> )}
<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>
<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>
</div>
</div> </div>
); );
} }

View File

@@ -2,16 +2,29 @@
import { useState } from "react"; import { useState } from "react";
type P = Record<string, string | null>; type P = Record<string, string | null>;
const FIELDS: [string, string][] = [
["fullName", "Full name"], ["email", "Email"], ["phone", "Phone"], ["location", "Location"], const PERSONAL_FIELDS: [string, string, string][] = [
["linkedin", "LinkedIn"], ["github", "GitHub"], ["portfolio", "Portfolio URL"], ["headline", "Headline"], ["fullName", "Full Name", "e.g. Alex Morgan"],
["email", "Email Address", "e.g. alex@example.com"],
["phone", "Phone Number", "e.g. +1 555 123 4567"],
["location", "Location / City", "e.g. Manila, Philippines / Remote"],
];
const LINK_FIELDS: [string, string, string][] = [
["headline", "Professional Headline", "e.g. Senior Full-Stack Engineer | React, Node.js, AI"],
["linkedin", "LinkedIn URL", "e.g. https://linkedin.com/in/alexmorgan"],
["github", "GitHub URL", "e.g. https://github.com/alexmorgan"],
["portfolio", "Portfolio URL", "e.g. https://alexmorgan.dev"],
]; ];
export default function ProfileEditor({ profile }: { profile: P }) { export default function ProfileEditor({ profile }: { profile: P }) {
const [p, setP] = useState<P>(profile); const [p, setP] = useState<P>(profile);
const [saved, setSaved] = useState(true); const [saved, setSaved] = useState(true);
const [saving, setSaving] = useState(false);
async function save() { async function save() {
setSaving(true);
try {
const { ...data } = p; const { ...data } = p;
delete (data as Record<string, unknown>).id; delete (data as Record<string, unknown>).id;
delete (data as Record<string, unknown>).updatedAt; delete (data as Record<string, unknown>).updatedAt;
@@ -21,42 +34,179 @@ export default function ProfileEditor({ profile }: { profile: P }) {
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
setSaved(true); setSaved(true);
} finally {
setSaving(false);
}
} }
function set(k: string, v: string) { setP((x) => ({ ...x, [k]: v })); setSaved(false); } function set(k: string, v: string) {
setP((x) => ({ ...x, [k]: v }));
setSaved(false);
}
return ( return (
<div className="max-w-2xl space-y-4"> <div className="max-w-4xl mx-auto space-y-6">
<div className="flex items-center justify-between"> {/* Header & Save Bar */}
<h1 className="text-2xl font-bold">Profile</h1> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 pb-4 border-b border-zinc-200/80 dark:border-zinc-800/80">
<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"> <div>
{saved ? "Saved" : "Save"} <div className="flex items-center gap-3">
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">Profile & AI Knowledge Base</h1>
{saved ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-50 text-emerald-700 border border-emerald-200 dark:bg-emerald-950/50 dark:text-emerald-300 dark:border-emerald-800/60">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
Saved
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-700 border border-amber-200 dark:bg-amber-950/50 dark:text-amber-300 dark:border-amber-800/60 animate-pulse">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500" />
Unsaved changes
</span>
)}
</div>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
This profile data serves as the source of truth for AI when generating tailored resumes and cover letters.
</p>
</div>
<button
onClick={save}
disabled={saved || saving}
className={`inline-flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-semibold text-white transition-all shadow-sm ${
saved
? "bg-zinc-400 dark:bg-zinc-700 opacity-60 cursor-default"
: "bg-gradient-to-r from-indigo-600 to-violet-600 hover:from-indigo-500 hover:to-violet-500 shadow-indigo-500/20 active:scale-95 cursor-pointer"
}`}
>
{saving ? (
<>
<svg className="animate-spin w-4 h-4 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<span>Saving…</span>
</>
) : (
<>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
<span>{saved ? "Saved" : "Save Profile"}</span>
</>
)}
</button> </button>
</div> </div>
<div className="grid md:grid-cols-2 gap-3">
{FIELDS.map(([k, label]) => ( {/* Section 1: Contact Information */}
<label key={k} className="block text-sm"> <div className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-6 shadow-xs space-y-4">
<span className="text-zinc-500">{label}</span> <h2 className="text-base font-bold text-zinc-900 dark:text-white flex items-center gap-2">
<input value={p[k] ?? ""} onChange={(e) => set(k, e.target.value)} <svg className="w-4 h-4 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
className="mt-1 w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
<span>Personal Information</span>
</h2>
<div className="grid md:grid-cols-2 gap-4">
{PERSONAL_FIELDS.map(([k, label, placeholder]) => (
<div key={k} className="space-y-1.5">
<label className="block text-xs font-semibold text-zinc-700 dark:text-zinc-300">
{label}
</label> </label>
<input
type="text"
value={p[k] ?? ""}
onChange={(e) => set(k, e.target.value)}
placeholder={placeholder}
className="w-full rounded-xl border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950/60 px-3.5 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:border-indigo-500 focus:outline-hidden focus:ring-2 focus:ring-indigo-500/20 transition-all"
/>
</div>
))} ))}
</div> </div>
<label className="block text-sm"> </div>
<span className="text-zinc-500">Summary</span>
<textarea value={p.summary ?? ""} onChange={(e) => set("summary", e.target.value)} rows={4} {/* Section 2: Online Presence & Links */}
className="mt-1 w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2" /> <div className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-6 shadow-xs space-y-4">
</label> <h2 className="text-base font-bold text-zinc-900 dark:text-white flex items-center gap-2">
<label className="block text-sm"> <svg className="w-4 h-4 text-violet-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<span className="text-zinc-500">Skills (JSON array)</span> <path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
<textarea value={p.skills ?? ""} onChange={(e) => set("skills", e.target.value)} rows={3} </svg>
className="mt-1 w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2 font-mono" /> <span>Online Presence & Headline</span>
</label> </h2>
<label className="block text-sm"> <div className="grid md:grid-cols-2 gap-4">
<span className="text-zinc-500">Work history (JSON)</span> {LINK_FIELDS.map(([k, label, placeholder]) => (
<textarea value={p.workHistory ?? ""} onChange={(e) => set("workHistory", e.target.value)} rows={12} <div key={k} className="space-y-1.5">
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 className="block text-xs font-semibold text-zinc-700 dark:text-zinc-300">
{label}
</label> </label>
<input
type="text"
value={p[k] ?? ""}
onChange={(e) => set(k, e.target.value)}
placeholder={placeholder}
className="w-full rounded-xl border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950/60 px-3.5 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:border-indigo-500 focus:outline-hidden focus:ring-2 focus:ring-indigo-500/20 transition-all"
/>
</div>
))}
</div>
</div>
{/* Section 3: Summary */}
<div className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-6 shadow-xs space-y-3">
<h2 className="text-base font-bold text-zinc-900 dark:text-white flex items-center gap-2">
<svg className="w-4 h-4 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
<span>Professional Summary</span>
</h2>
<p className="text-xs text-zinc-500 dark:text-zinc-400">
A high-level overview of your background, core strengths, and domain specializations.
</p>
<textarea
value={p.summary ?? ""}
onChange={(e) => set("summary", e.target.value)}
rows={4}
placeholder="Experienced software engineer with 7+ years building high-scale web platforms, distributed systems, and AI-driven products..."
className="w-full rounded-xl border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950/60 p-4 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:border-indigo-500 focus:outline-hidden focus:ring-2 focus:ring-indigo-500/20 transition-all leading-relaxed"
/>
</div>
{/* Section 4: Skills (JSON) */}
<div className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-6 shadow-xs space-y-3">
<h2 className="text-base font-bold text-zinc-900 dark:text-white flex items-center gap-2">
<svg className="w-4 h-4 text-teal-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />
</svg>
<span>Skills (JSON Array)</span>
</h2>
<p className="text-xs text-zinc-500 dark:text-zinc-400">
Formatted as a JSON array of strings, e.g. <code className="bg-zinc-100 dark:bg-zinc-800 px-1 py-0.5 rounded text-indigo-600 dark:text-indigo-400">[&quot;TypeScript&quot;, &quot;React&quot;, &quot;Next.js&quot;, &quot;Node.js&quot;, &quot;PostgreSQL&quot;]</code>
</p>
<textarea
value={p.skills ?? ""}
onChange={(e) => set("skills", e.target.value)}
rows={3}
placeholder='["TypeScript", "React", "Node.js", "Python", "TailwindCSS", "PostgreSQL", "Docker"]'
className="w-full rounded-xl border border-zinc-300 dark:border-zinc-700 bg-zinc-50/50 dark:bg-zinc-950/60 p-4 font-mono text-xs text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:border-indigo-500 focus:outline-hidden focus:ring-2 focus:ring-indigo-500/20 transition-all leading-relaxed"
/>
</div>
{/* Section 5: Work History (JSON) */}
<div className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-6 shadow-xs space-y-3">
<h2 className="text-base font-bold text-zinc-900 dark:text-white flex items-center gap-2">
<svg className="w-4 h-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 14.15v4.25c0 1.094-.787 2.036-1.872 2.18-2.087.277-4.216.42-6.378.42s-4.291-.143-6.378-.42c-1.085-.144-1.872-1.086-1.872-2.18v-4.25m16.5 0a2.18 2.18 0 00.75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 00-3.413-.387m4.5 8.006c-.194.165-.42.295-.673.38A23.978 23.978 0 0112 15.75c-2.648 0-5.195-.429-7.577-1.22a2.016 2.016 0 01-.673-.38m0 0A2.18 2.18 0 013 12.489V8.706c0-1.081.768-2.015 1.837-2.175a48.111 48.111 0 013.413-.387m7.5 0V5.25A2.25 2.25 0 0013.5 3h-3a2.25 2.25 0 00-2.25 2.25v.894m7.5 0a48.667 48.667 0 00-7.5 0M12 12.75h.008v.008H12v-.008z" />
</svg>
<span>Work History (Structured JSON)</span>
</h2>
<p className="text-xs text-zinc-500 dark:text-zinc-400">
Array of job objects containing: <code className="bg-zinc-100 dark:bg-zinc-800 px-1 py-0.5 rounded text-indigo-600 dark:text-indigo-400">{`[{ "position": "...", "company": "...", "start": "2021", "end": "Present", "bullets": ["..."] }]`}</code>
</p>
<textarea
value={p.workHistory ?? ""}
onChange={(e) => set("workHistory", e.target.value)}
rows={14}
placeholder='[&#10; {&#10; "position": "Senior Software Engineer",&#10; "company": "Tech Corp",&#10; "unit": "Core Infrastructure",&#10; "start": "2022",&#10; "end": "Present",&#10; "bullets": [&#10; "Architected real-time streaming pipeline reducing latency by 45%",&#10; "Led a team of 5 engineers delivering high-availability microservices"&#10; ]&#10; }&#10;]'
className="w-full rounded-xl border border-zinc-300 dark:border-zinc-700 bg-zinc-50/50 dark:bg-zinc-950/60 p-4 font-mono text-xs text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:border-indigo-500 focus:outline-hidden focus:ring-2 focus:ring-indigo-500/20 transition-all leading-relaxed"
/>
</div>
</div> </div>
); );
} }

View File

@@ -6,52 +6,179 @@ import { marked } from "marked";
export default function ResumeEditor({ resume }: { resume: { id: string; name: string; markdown: string } }) { export default function ResumeEditor({ resume }: { resume: { id: string; name: string; markdown: string } }) {
const [md, setMd] = useState(resume.markdown); const [md, setMd] = useState(resume.markdown);
const [saved, setSaved] = useState(true); const [saved, setSaved] = useState(true);
const [saving, setSaving] = useState(false);
const [preview, setPreview] = useState(true); const [preview, setPreview] = useState(true);
async function save() { async function save() {
setSaving(true);
try {
await fetch(`/api/resumes/${resume.id}`, { await fetch(`/api/resumes/${resume.id}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ markdown: md }), body: JSON.stringify({ markdown: md }),
}); });
setSaved(true); setSaved(true);
} finally {
setSaving(false);
}
} }
function printResume() { function printResume() {
const w = window.open("", "_blank"); const w = window.open("", "_blank");
if (!w) return; if (!w) return;
w.document.write(`<!DOCTYPE html><html><head><title>${resume.name}</title><style> 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} @page { margin: 1.5cm; }
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} body {
h3{font-size:1rem;margin-bottom:0} ul{margin-top:.3rem} li{margin:.15rem 0} @media print{body{margin:0}} font-family: 'Times New Roman', Times, Georgia, serif;
max-width: 800px;
margin: 2rem auto;
padding: 0 1.5rem;
line-height: 1.5;
color: #111827;
}
h1 { font-size: 1.8rem; margin-bottom: 0.2rem; font-weight: bold; border-bottom: 1.5px solid #374151; padding-bottom: 4px; }
h2 { font-size: 1.15rem; border-bottom: 1px solid #9ca3af; padding-bottom: 2px; margin-top: 1.2rem; margin-bottom: 0.4rem; text-transform: uppercase; letter-spacing: 0.05em; font-family: Arial, sans-serif; }
h3 { font-size: 1rem; margin-bottom: 0.1rem; margin-top: 0.6rem; }
p { margin-top: 0.2rem; margin-bottom: 0.4rem; font-size: 0.95rem; }
ul { margin-top: 0.2rem; padding-left: 1.2rem; margin-bottom: 0.6rem; }
li { margin: 0.15rem 0; font-size: 0.95rem; }
a { color: #111827; text-decoration: none; }
@media print { body { margin: 0; padding: 0; } }
</style></head><body>${marked.parse(md)}</body></html>`); </style></head><body>${marked.parse(md)}</body></html>`);
w.document.close(); w.document.close();
setTimeout(() => w.print(), 300); setTimeout(() => w.print(), 300);
} }
return ( return (
<div className="space-y-4"> <div className="space-y-6">
<div className="flex items-center justify-between flex-wrap gap-2"> {/* Top Navigation & Toolbar */}
<div className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-4 shadow-xs">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<Link href="/resumes" className="text-sm text-zinc-500 hover:text-blue-600">← Resumes</Link> <Link
<h1 className="text-xl font-bold">{resume.name}</h1> href="/resumes"
className="inline-flex items-center gap-1 text-xs font-medium text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors mb-1"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
</svg>
<span>Back to Resumes</span>
</Link>
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold tracking-tight text-zinc-900 dark:text-white">
{resume.name}
</h1>
{saved ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-50 text-emerald-700 border border-emerald-200 dark:bg-emerald-950/50 dark:text-emerald-300 dark:border-emerald-800/60">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
Saved
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-700 border border-amber-200 dark:bg-amber-950/50 dark:text-amber-300 dark:border-amber-800/60 animate-pulse">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500" />
Unsaved changes
</span>
)}
</div> </div>
<div className="flex gap-2"> </div>
<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 {/* Action Toolbar Buttons */}
<div className="flex items-center gap-2">
<button
onClick={() => setPreview(!preview)}
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl text-xs font-medium text-zinc-700 dark:text-zinc-300 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors"
>
<svg className="w-4 h-4 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>{preview ? "Hide Preview" : "Show Preview"}</span>
</button> </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"> <button
{saved ? "Saved" : "Save"} onClick={printResume}
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl text-xs font-medium text-zinc-700 dark:text-zinc-300 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors"
>
<svg className="w-4 h-4 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6.72 13.829c-.24.03-.48.062-.72.096m.72-.096a42.415 42.415 0 0110.56 0m-10.56 0L6.34 18m10.94-4.171c.24.03.48.062.72.096m-.72-.096L17.66 18m0 0l.229 2.523a1.125 1.125 0 01-1.12 1.227H7.231c-.662 0-1.18-.568-1.12-1.227L6.34 18m11.318 0h1.091A2.25 2.25 0 0021 15.75V9.456c0-1.081-.768-2.015-1.837-2.175a48.055 48.055 0 00-1.913-.247M6.34 18H5.25A2.25 2.25 0 013 15.75V9.456c0-1.081.768-2.015 1.837-2.175a48.041 48.041 0 011.913-.247m10.5 0a48.536 48.536 0 00-10.5 0m10.5 0V3.375c0-.621-.504-1.125-1.125-1.125h-8.25c-.621 0-1.125.504-1.125 1.125v3.659M18 10.5h.008v.008H18V10.5zm-3 0h.008v.008H15V10.5z" />
</svg>
<span>Print / PDF</span>
</button>
<button
onClick={save}
disabled={saved || saving}
className={`inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-semibold text-white transition-all shadow-sm ${
saved
? "bg-zinc-400 dark:bg-zinc-700 opacity-60 cursor-default"
: "bg-gradient-to-r from-indigo-600 to-violet-600 hover:from-indigo-500 hover:to-violet-500 shadow-indigo-500/20 active:scale-95 cursor-pointer"
}`}
>
{saving ? (
<>
<svg className="animate-spin w-3.5 h-3.5 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<span>Saving…</span>
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
<span>{saved ? "Saved" : "Save Changes"}</span>
</>
)}
</button> </button>
</div> </div>
</div> </div>
<div className={`grid gap-4 ${preview ? "md:grid-cols-2" : ""}`}> </div>
<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" /> {/* Editor & Preview Split Grid */}
<div className={`grid gap-6 ${preview ? "lg:grid-cols-2" : "grid-cols-1"}`}>
{/* Monospace Markdown Editor */}
<div className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 shadow-xs overflow-hidden flex flex-col">
<div className="px-4 py-2.5 bg-zinc-50 dark:bg-zinc-900/60 border-b border-zinc-200 dark:border-zinc-800 flex items-center justify-between text-xs text-zinc-500 dark:text-zinc-400 font-medium">
<span className="flex items-center gap-1.5">
<svg className="w-4 h-4 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />
</svg>
Markdown Source
</span>
<span>{md.length} characters</span>
</div>
<textarea
value={md}
onChange={(e) => {
setMd(e.target.value);
setSaved(false);
}}
rows={30}
className="w-full flex-1 p-4 font-mono text-sm leading-relaxed bg-white dark:bg-zinc-950/60 text-zinc-900 dark:text-zinc-100 focus:outline-hidden resize-y min-h-[500px]"
placeholder="# Your Name&#10;Email | Phone | Location&#10;&#10;## Summary&#10;..."
/>
</div>
{/* Live Resume Document Preview */}
{preview && ( {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" <div className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 shadow-xs overflow-hidden flex flex-col">
dangerouslySetInnerHTML={{ __html: marked.parse(md) as string }} /> <div className="px-4 py-2.5 bg-zinc-50 dark:bg-zinc-900/60 border-b border-zinc-200 dark:border-zinc-800 flex items-center justify-between text-xs text-zinc-500 dark:text-zinc-400 font-medium">
<span className="flex items-center gap-1.5">
<svg className="w-4 h-4 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
Document Live Preview
</span>
<span>Formatted View</span>
</div>
<div className="p-6 md:p-8 bg-zinc-50/40 dark:bg-zinc-950/40 flex-1 overflow-auto max-h-[850px]">
<div
className="resume-preview max-w-2xl mx-auto bg-white dark:bg-zinc-900 p-8 rounded-xl shadow-xs border border-zinc-200/60 dark:border-zinc-800/80"
dangerouslySetInnerHTML={{ __html: marked.parse(md) as string }}
/>
</div>
</div>
)} )}
</div> </div>
</div> </div>

View File

@@ -6,21 +6,112 @@ export const dynamic = "force-dynamic";
export default async function ResumesPage() { export default async function ResumesPage() {
const resumes = await prisma.resume.findMany({ const resumes = await prisma.resume.findMany({
orderBy: { updatedAt: "desc" }, orderBy: { updatedAt: "desc" },
include: { job: { select: { title: true, company: true } } }, include: { job: { select: { id: true, title: true, company: true } } },
}); });
return ( return (
<div className="space-y-4"> <div className="space-y-6">
<h1 className="text-2xl font-bold">Resumes ({resumes.length})</h1> {/* Header */}
<ul className="space-y-2"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
{resumes.map((r) => ( <div>
<li key={r.id} className="rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-4"> <div className="flex items-center gap-3">
<Link href={`/resumes/${r.id}`} className="font-medium hover:text-blue-600">{r.name}</Link> <h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">Resumes</h1>
<div className="text-sm text-zinc-500"> <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-purple-50 text-purple-700 dark:bg-purple-950/70 dark:text-purple-300 border border-purple-200/60 dark:border-purple-800/60">
{r.kind}{r.job ? ` · for ${r.job.company} — ${r.job.title}` : ""} · updated {r.updatedAt.toISOString().slice(0, 10)} {resumes.length} {resumes.length === 1 ? "version" : "versions"}
</span>
</div>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
Tailored resumes customized by AI for specific positions and target roles.
</p>
</div>
</div>
{/* Resumes List */}
{resumes.length === 0 ? (
<div className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-12 text-center shadow-xs">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-purple-50 dark:bg-purple-950/60 text-purple-600 dark:text-purple-400 mb-3">
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
</div>
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">No resumes yet</h3>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-1 max-w-sm mx-auto">
Open any job in your tracker and click <strong>“AI Tailor Resume”</strong> to generate a custom CV.
</p>
<div className="mt-4">
<Link
href="/jobs"
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-violet-600 hover:from-indigo-500 hover:to-violet-500 shadow-sm shadow-indigo-500/20"
>
Go to Jobs Pipeline
</Link>
</div>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{resumes.map((r) => (
<div
key={r.id}
className="relative flex flex-col justify-between rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-5 shadow-xs hover:shadow-md hover:border-zinc-300 dark:hover:border-zinc-700 transition-all group"
>
<div>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-purple-50 dark:bg-purple-950/60 text-purple-600 dark:text-purple-400 border border-purple-100 dark:border-purple-900/50">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
</div>
<div className="min-w-0 flex-1">
<Link
href={`/resumes/${r.id}`}
className="font-semibold text-sm text-zinc-900 dark:text-zinc-100 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors truncate block"
>
{r.name}
</Link>
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-[10px] font-medium bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 capitalize mt-0.5">
{r.kind} version
</span>
</div>
</div>
</div>
{r.job && (
<div className="mt-3.5 pt-3 border-t border-zinc-100 dark:border-zinc-800/80 text-xs text-zinc-600 dark:text-zinc-400">
<span className="text-zinc-400 dark:text-zinc-500">Target Role: </span>
<Link
href={`/jobs/${r.job.id}`}
className="font-medium text-zinc-800 dark:text-zinc-200 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
{r.job.company} — {r.job.title}
</Link>
</div>
)}
</div>
<div className="mt-4 flex items-center justify-between pt-3 border-t border-zinc-100 dark:border-zinc-800/80 text-xs text-zinc-400">
<span>
Updated{" "}
{new Date(r.updatedAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</span>
<Link
href={`/resumes/${r.id}`}
className="inline-flex items-center gap-1 font-semibold text-indigo-600 dark:text-indigo-400 group-hover:translate-x-0.5 transition-transform"
>
<span>Open Editor</span>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</Link>
</div>
</div> </div>
</li>
))} ))}
</ul> </div>
)}
</div> </div>
); );
} }