Redesign UI: modern SaaS dashboard styling with indigo/violet accents
This commit is contained in:
@@ -3,25 +3,212 @@ import Link from "next/link";
|
||||
|
||||
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() {
|
||||
const apps = await prisma.application.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
include: { job: { select: { id: true, title: true, company: true, status: true } } },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">Applications ({apps.length})</h1>
|
||||
{apps.length === 0 && <p className="text-zinc-500 text-sm">No applications yet. Open a job and click “Mark Applied”.</p>}
|
||||
<ul className="space-y-2">
|
||||
{apps.map((a) => (
|
||||
<li key={a.id} className="rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-4 text-sm">
|
||||
<Link href={`/jobs/${a.job.id}`} className="font-medium hover:text-blue-600">{a.job.title} — {a.job.company}</Link>
|
||||
<div className="text-zinc-500">
|
||||
{a.method} · {a.submittedAt ? `submitted ${a.submittedAt.toISOString().slice(0, 10)}` : "not submitted"} · job status: {a.job.status}
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">Applications</h1>
|
||||
<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">
|
||||
{apps.length} {apps.length === 1 ? "application" : "applications"}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
|
||||
Track submitted job applications and their current recruitment pipeline status.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,147 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--background: #f8fafc;
|
||||
--foreground: #0f172a;
|
||||
--card-bg: #ffffff;
|
||||
--card-border: #e2e8f0;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
--background: #09090b;
|
||||
--foreground: #f4f4f5;
|
||||
--card-bg: #121215;
|
||||
--card-border: #27272a;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
background-color: var(--background);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -4,22 +4,39 @@ import { useRouter } from "next/navigation";
|
||||
|
||||
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 } }) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState("");
|
||||
const [msg, setMsg] = useState("");
|
||||
const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
|
||||
async function setStatus(status: string) {
|
||||
try {
|
||||
await fetch(`/api/jobs/${job.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
router.refresh();
|
||||
} catch {
|
||||
setMsg({ type: "error", text: "Failed to update status." });
|
||||
}
|
||||
}
|
||||
|
||||
async function run(kind: "tailor" | "cover") {
|
||||
setBusy(kind); setMsg("");
|
||||
setBusy(kind);
|
||||
setMsg(null);
|
||||
try {
|
||||
const res = await fetch(`/api/ai/${kind}`, {
|
||||
method: "POST",
|
||||
@@ -27,53 +44,163 @@ export default function JobActions({ job }: { job: { id: string; status: string
|
||||
body: JSON.stringify({ jobId: job.id }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? "failed");
|
||||
setMsg(kind === "tailor" ? "Tailored resume created ✅" : "Cover letter created ✅");
|
||||
if (!res.ok) throw new Error(data.error ?? "Generation failed");
|
||||
setMsg({
|
||||
type: "success",
|
||||
text: kind === "tailor" ? "Tailored resume created successfully! ✨" : "Cover letter created successfully! 📄",
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e) { setMsg(String(e)); } finally { setBusy(""); }
|
||||
} catch (e) {
|
||||
setMsg({ type: "error", text: String(e) });
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function markApplied() {
|
||||
setBusy("applied");
|
||||
setMsg(null);
|
||||
try {
|
||||
await fetch("/api/applications", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ jobId: job.id, method: "manual", submittedAt: new Date().toISOString() }),
|
||||
});
|
||||
setMsg({ type: "success", text: "Marked as applied! Application logged. ✅" });
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setMsg({ type: "error", text: String(e) });
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
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" });
|
||||
router.push("/jobs");
|
||||
} catch (e) {
|
||||
setMsg({ type: "error", text: String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-zinc-500">Status:</span>
|
||||
<select value={job.status} onChange={(e) => setStatus(e.target.value)}
|
||||
className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-1">
|
||||
{STATUSES.map((s) => <option key={s}>{s}</option>)}
|
||||
<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 flex-wrap items-center justify-between gap-4">
|
||||
{/* Status Dropdown */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
|
||||
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>
|
||||
<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 className="flex flex-wrap gap-2">
|
||||
<button onClick={() => run("tailor")} disabled={!!busy}
|
||||
className="rounded bg-purple-600 text-white px-3 py-1.5 text-sm disabled:opacity-50 hover:bg-purple-700">
|
||||
{busy === "tailor" ? "Generating…" : "🪄 AI Tailor Resume"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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 onClick={() => run("cover")} disabled={!!busy}
|
||||
className="rounded bg-indigo-600 text-white px-3 py-1.5 text-sm disabled:opacity-50 hover:bg-indigo-700">
|
||||
{busy === "cover" ? "Generating…" : "✉️ AI Cover Letter"}
|
||||
|
||||
{/* 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 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 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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,65 +5,308 @@ import JobActions from "./job-actions";
|
||||
|
||||
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 }> }) {
|
||||
const { id } = await params;
|
||||
const job = await prisma.job.findUnique({
|
||||
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();
|
||||
|
||||
const comp = formatCompensation(job);
|
||||
const companyColor = getCompanyColor(job.company);
|
||||
const initials = getInitials(job.company);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-6">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Breadcrumb & Top Bar */}
|
||||
<div>
|
||||
<Link href="/jobs" className="text-sm text-zinc-500 hover:text-blue-600">← Jobs</Link>
|
||||
<h1 className="text-2xl font-bold mt-1">{job.title}</h1>
|
||||
<div className="text-zinc-500">{job.company} · {job.location ?? "Remote"} · PH: {job.phEligibility}</div>
|
||||
<div className="mt-2 flex gap-3 text-sm">
|
||||
{job.url && <a href={job.url} target="_blank" className="text-blue-600 hover:underline">Listing ↗</a>}
|
||||
{job.applyUrl && <a href={job.applyUrl} target="_blank" className="text-blue-600 hover:underline">Apply page ↗</a>}
|
||||
<Link
|
||||
href="/jobs"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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))} />
|
||||
|
||||
{job.description && (
|
||||
<section>
|
||||
<h2 className="font-semibold mb-1">Description</h2>
|
||||
<p className="text-sm whitespace-pre-wrap">{job.description}</p>
|
||||
</section>
|
||||
)}
|
||||
{job.requirements && (
|
||||
<section>
|
||||
<h2 className="font-semibold mb-1">Requirements</h2>
|
||||
<p className="text-sm whitespace-pre-wrap">{job.requirements}</p>
|
||||
</section>
|
||||
)}
|
||||
{job.notes && (
|
||||
<section>
|
||||
<h2 className="font-semibold mb-1">Notes</h2>
|
||||
<p className="text-sm whitespace-pre-wrap">{job.notes}</p>
|
||||
{/* Tailored Resumes Section */}
|
||||
{job.resumes.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">
|
||||
<div className="flex items-center justify-between">
|
||||
<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}>
|
||||
<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>Tailored Resumes ({job.resumes.length})</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{job.resumes.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
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>
|
||||
)}
|
||||
|
||||
{job.resumes.length > 0 && (
|
||||
<section>
|
||||
<h2 className="font-semibold mb-1">Tailored resumes</h2>
|
||||
<ul className="text-sm space-y-1">
|
||||
{job.resumes.map((r) => (
|
||||
<li key={r.id}><Link href={`/resumes/${r.id}`} className="text-blue-600 hover:underline">{r.name}</Link></li>
|
||||
{/* Cover Letters Section */}
|
||||
{job.coverLetters.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-indigo-600 dark:text-indigo-400" 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>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>
|
||||
)}
|
||||
{job.coverLetters.length > 0 && (
|
||||
<section>
|
||||
<h2 className="font-semibold mb-1">Cover letters</h2>
|
||||
<ul className="text-sm space-y-2">
|
||||
{job.coverLetters.map((c) => (
|
||||
<li key={c.id} className="rounded border border-zinc-200 dark:border-zinc-800 p-3 whitespace-pre-wrap bg-white dark:bg-zinc-900">{c.markdown}</li>
|
||||
|
||||
{/* Description */}
|
||||
{job.description && (
|
||||
<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="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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
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() {
|
||||
const router = useRouter();
|
||||
const [url, setUrl] = useState("");
|
||||
@@ -13,7 +29,8 @@ export default function NewJobPage() {
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
async function parse() {
|
||||
setBusy(true); setErr("");
|
||||
setBusy(true);
|
||||
setErr("");
|
||||
try {
|
||||
const res = await fetch("/api/ai/parse", {
|
||||
method: "POST",
|
||||
@@ -21,14 +38,19 @@ export default function NewJobPage() {
|
||||
body: JSON.stringify({ url: url || undefined, text: text || undefined }),
|
||||
});
|
||||
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);
|
||||
} catch (e) { setErr(String(e)); } finally { setBusy(false); }
|
||||
} catch (e) {
|
||||
setErr(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!parsed) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch("/api/jobs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -36,47 +58,202 @@ export default function NewJobPage() {
|
||||
});
|
||||
const job = await res.json();
|
||||
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 (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<h1 className="text-2xl font-bold">Add Job</h1>
|
||||
{!parsed && (
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<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)"
|
||||
className="w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2 text-sm" />
|
||||
<div className="text-center text-zinc-400 text-sm">— or paste job text (more reliable) —</div>
|
||||
<textarea value={text} onChange={(e) => setText(e.target.value)} rows={10} placeholder="Paste the full job posting text here"
|
||||
className="w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2 text-sm" />
|
||||
<button onClick={parse} disabled={busy || (!url && !text)}
|
||||
className="rounded bg-blue-600 text-white px-4 py-2 text-sm disabled:opacity-50 hover:bg-blue-700">
|
||||
{busy ? "Parsing with AI…" : "Parse with AI"}
|
||||
</button>
|
||||
<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>Parsing with AI…</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>}
|
||||
{parsed && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="font-semibold">Review parsed fields</h2>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<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) => (
|
||||
<label key={k} className="block text-sm">
|
||||
<span className="text-zinc-500">{k}</span>
|
||||
<input value={String(parsed[k] ?? "")} onChange={(e) => set(k, e.target.value)}
|
||||
className="mt-1 w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2" />
|
||||
<div key={k} className="space-y-1">
|
||||
<label className="block text-xs font-semibold text-zinc-600 dark:text-zinc-300">
|
||||
{FIELD_LABELS[k] || k}
|
||||
</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) => (
|
||||
<label key={k} className="block text-sm">
|
||||
<span className="text-zinc-500">{k}</span>
|
||||
<textarea value={String(parsed[k] ?? "")} onChange={(e) => set(k, e.target.value)} rows={4}
|
||||
className="mt-1 w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2" />
|
||||
<div key={k} className="space-y-1">
|
||||
<label className="block text-xs font-semibold text-zinc-600 dark:text-zinc-300 capitalize">
|
||||
{k}
|
||||
</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>
|
||||
<button onClick={() => setParsed(null)} className="rounded border border-zinc-300 dark:border-zinc-700 px-4 py-2 text-sm">Back</button>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-zinc-200/80 dark:border-zinc-800/80">
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -3,18 +3,104 @@ import Link from "next/link";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const badge: Record<string, string> = {
|
||||
prospect: "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
|
||||
reviewing: "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200",
|
||||
tailoring: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200",
|
||||
ready: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
|
||||
applied: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
||||
interviewing: "bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200",
|
||||
offer: "bg-emerald-200 text-emerald-900 dark:bg-emerald-800 dark:text-emerald-100",
|
||||
rejected: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
|
||||
dead: "bg-zinc-300 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-400",
|
||||
const STATUSES = ["prospect", "reviewing", "tailoring", "ready", "applied", "interviewing", "offer", "rejected", "dead"];
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function pay(j: { payMin: number | null; payMax: number | null; payCurrency: string | null; payPeriod: string | null }) {
|
||||
if (!j.payMin && !j.payMax) return "—";
|
||||
const cur = j.payCurrency === "PHP" ? "₱" : "$";
|
||||
@@ -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 }> }) {
|
||||
const { status } = await searchParams;
|
||||
const jobs = await prisma.job.findMany({
|
||||
const [jobs, allJobs] = await Promise.all([
|
||||
prisma.job.findMany({
|
||||
where: status ? { status } : undefined,
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Jobs {status ? `— ${status}` : ""} ({jobs.length})</h1>
|
||||
<Link href="/jobs/new" className="rounded bg-blue-600 text-white px-4 py-2 text-sm hover:bg-blue-700">+ Add Job</Link>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<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 className="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-800">
|
||||
<table className="w-full text-sm bg-white dark:bg-zinc-900">
|
||||
<thead className="text-left text-zinc-500 border-b border-zinc-200 dark:border-zinc-800">
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
|
||||
{status ? (
|
||||
<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>
|
||||
<th className="p-3">Job</th>
|
||||
<th className="p-3">Pay</th>
|
||||
<th className="p-3">PH?</th>
|
||||
<th className="p-3">Status</th>
|
||||
<th scope="col" className="py-3.5 pl-5 pr-3">Job & Company</th>
|
||||
<th scope="col" className="py-3.5 px-3">Compensation</th>
|
||||
<th scope="col" className="py-3.5 px-3">PH Eligibility</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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map((j) => (
|
||||
<tr key={j.id} className="border-b border-zinc-100 dark:border-zinc-800 hover:bg-zinc-50 dark:hover:bg-zinc-800/50">
|
||||
<td className="p-3">
|
||||
<Link href={`/jobs/${j.id}`} className="font-medium hover:text-blue-600">{j.title}</Link>
|
||||
<div className="text-zinc-500">{j.company}</div>
|
||||
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800/60 font-normal">
|
||||
{jobs.map((j) => {
|
||||
const statusInfo = STATUS_CONFIG[j.status] || STATUS_CONFIG.prospect;
|
||||
const companyColor = getCompanyColor(j.company);
|
||||
const initials = getInitials(j.company);
|
||||
|
||||
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 className="p-3 whitespace-nowrap">{pay(j)}</td>
|
||||
<td className="p-3">{j.phEligibility === "yes" ? "✅" : j.phEligibility === "no" ? "❌" : "❓"}</td>
|
||||
<td className="p-3">
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs ${badge[j.status] ?? ""}`}>{j.status}</span>
|
||||
|
||||
{/* Pay */}
|
||||
<td className="py-4 px-3 whitespace-nowrap">
|
||||
<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>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import Link from "next/link";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "AI Job Search",
|
||||
description: "Track jobs, tailor resumes, apply with AI",
|
||||
title: "AI Job Search — Smart Career Tracker & Resume Tailor",
|
||||
description: "Track job applications, tailor resumes, and optimize your job hunt with AI.",
|
||||
};
|
||||
|
||||
const nav = [
|
||||
@@ -17,21 +17,63 @@ const nav = [
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="min-h-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100 antialiased">
|
||||
<header className="border-b border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900">
|
||||
<div className="mx-auto max-w-6xl flex items-center gap-6 px-4 h-14">
|
||||
<span className="font-bold">🎯 AI Job Search</span>
|
||||
<nav className="flex gap-4 text-sm">
|
||||
<html lang="en" className="h-full">
|
||||
<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="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 px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex h-16 items-center justify-between gap-4">
|
||||
{/* 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) => (
|
||||
<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}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</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>
|
||||
<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>
|
||||
</html>
|
||||
);
|
||||
|
||||
314
src/app/page.tsx
314
src/app/page.tsx
@@ -5,43 +5,309 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
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() {
|
||||
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 resumes = await prisma.resume.count();
|
||||
const byStatus = Object.fromEntries(STATUSES.map((s) => [s, jobs.filter((j) => j.status === s).length]));
|
||||
const phYes = jobs.filter((j) => j.phEligibility === "yes").length;
|
||||
const byStatus = Object.fromEntries(STATUSES.map((s) => [s, allJobs.filter((j) => j.status === s).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 (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[
|
||||
["Total jobs", jobs.length],
|
||||
["PH-eligible", phYes],
|
||||
["Resumes", resumes],
|
||||
["Applications", apps],
|
||||
].map(([label, n]) => (
|
||||
<div key={label as string} className="rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-4">
|
||||
<div className="text-3xl font-bold">{n as number}</div>
|
||||
<div className="text-sm text-zinc-500">{label as string}</div>
|
||||
<div className="space-y-8">
|
||||
{/* Header & Quick Action */}
|
||||
<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>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">Dashboard</h1>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
|
||||
Track applications, monitor your hiring pipeline, and tailor resumes with AI.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
<h2 className="font-semibold mb-2">Pipeline</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{STATUSES.map((s) => (
|
||||
<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">
|
||||
{s} <span className="font-bold">{byStatus[s]}</span>
|
||||
<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">
|
||||
{config.label}
|
||||
</div>
|
||||
<div className="text-[10px] text-zinc-400 dark:text-zinc-500 truncate mt-0.5">
|
||||
{config.description}
|
||||
</div>
|
||||
</div>
|
||||
</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 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,16 +2,29 @@
|
||||
import { useState } from "react";
|
||||
|
||||
type P = Record<string, string | null>;
|
||||
const FIELDS: [string, string][] = [
|
||||
["fullName", "Full name"], ["email", "Email"], ["phone", "Phone"], ["location", "Location"],
|
||||
["linkedin", "LinkedIn"], ["github", "GitHub"], ["portfolio", "Portfolio URL"], ["headline", "Headline"],
|
||||
|
||||
const PERSONAL_FIELDS: [string, string, string][] = [
|
||||
["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 }) {
|
||||
const [p, setP] = useState<P>(profile);
|
||||
const [saved, setSaved] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const { ...data } = p;
|
||||
delete (data as Record<string, unknown>).id;
|
||||
delete (data as Record<string, unknown>).updatedAt;
|
||||
@@ -21,42 +34,179 @@ export default function ProfileEditor({ profile }: { profile: P }) {
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
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 (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Profile</h1>
|
||||
<button onClick={save} disabled={saved} className="rounded bg-blue-600 text-white px-4 py-2 text-sm disabled:opacity-50 hover:bg-blue-700">
|
||||
{saved ? "Saved" : "Save"}
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Header & Save Bar */}
|
||||
<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">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
{FIELDS.map(([k, label]) => (
|
||||
<label key={k} className="block text-sm">
|
||||
<span className="text-zinc-500">{label}</span>
|
||||
<input value={p[k] ?? ""} onChange={(e) => set(k, e.target.value)}
|
||||
className="mt-1 w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2" />
|
||||
|
||||
{/* Section 1: Contact Information */}
|
||||
<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">
|
||||
<h2 className="text-base font-bold text-zinc-900 dark:text-white flex items-center gap-2">
|
||||
<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="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>
|
||||
<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>
|
||||
<label className="block text-sm">
|
||||
<span className="text-zinc-500">Summary</span>
|
||||
<textarea value={p.summary ?? ""} onChange={(e) => set("summary", e.target.value)} rows={4}
|
||||
className="mt-1 w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2" />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-zinc-500">Skills (JSON array)</span>
|
||||
<textarea value={p.skills ?? ""} onChange={(e) => set("skills", e.target.value)} rows={3}
|
||||
className="mt-1 w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2 font-mono" />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-zinc-500">Work history (JSON)</span>
|
||||
<textarea value={p.workHistory ?? ""} onChange={(e) => set("workHistory", e.target.value)} rows={12}
|
||||
className="mt-1 w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2 font-mono" />
|
||||
</div>
|
||||
|
||||
{/* Section 2: Online Presence & Links */}
|
||||
<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">
|
||||
<h2 className="text-base font-bold text-zinc-900 dark:text-white flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-violet-500" 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>
|
||||
<span>Online Presence & Headline</span>
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{LINK_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>
|
||||
<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">["TypeScript", "React", "Next.js", "Node.js", "PostgreSQL"]</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='[ { "position": "Senior Software Engineer", "company": "Tech Corp", "unit": "Core Infrastructure", "start": "2022", "end": "Present", "bullets": [ "Architected real-time streaming pipeline reducing latency by 45%", "Led a team of 5 engineers delivering high-availability microservices" ] } ]'
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,52 +6,179 @@ import { marked } from "marked";
|
||||
export default function ResumeEditor({ resume }: { resume: { id: string; name: string; markdown: string } }) {
|
||||
const [md, setMd] = useState(resume.markdown);
|
||||
const [saved, setSaved] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [preview, setPreview] = useState(true);
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await fetch(`/api/resumes/${resume.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ markdown: md }),
|
||||
});
|
||||
setSaved(true);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function printResume() {
|
||||
const w = window.open("", "_blank");
|
||||
if (!w) return;
|
||||
w.document.write(`<!DOCTYPE html><html><head><title>${resume.name}</title><style>
|
||||
body{font-family:Georgia,serif;max-width:800px;margin:2rem auto;padding:0 1rem;line-height:1.5;color:#222}
|
||||
h1{font-size:1.6rem;margin-bottom:0} h2{font-size:1.1rem;border-bottom:1px solid #ccc;padding-bottom:2px;margin-top:1.2rem}
|
||||
h3{font-size:1rem;margin-bottom:0} ul{margin-top:.3rem} li{margin:.15rem 0} @media print{body{margin:0}}
|
||||
@page { margin: 1.5cm; }
|
||||
body {
|
||||
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>`);
|
||||
w.document.close();
|
||||
setTimeout(() => w.print(), 300);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="space-y-6">
|
||||
{/* 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>
|
||||
<Link href="/resumes" className="text-sm text-zinc-500 hover:text-blue-600">← Resumes</Link>
|
||||
<h1 className="text-xl font-bold">{resume.name}</h1>
|
||||
<Link
|
||||
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 className="flex gap-2">
|
||||
<button onClick={() => setPreview(!preview)} className="rounded border border-zinc-300 dark:border-zinc-700 px-3 py-1.5 text-sm">
|
||||
{preview ? "Hide" : "Show"} preview
|
||||
</div>
|
||||
|
||||
{/* 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 onClick={printResume} className="rounded border border-zinc-300 dark:border-zinc-700 px-3 py-1.5 text-sm">🖨 Print / PDF</button>
|
||||
<button onClick={save} disabled={saved} className="rounded bg-blue-600 text-white px-4 py-1.5 text-sm disabled:opacity-50 hover:bg-blue-700">
|
||||
{saved ? "Saved" : "Save"}
|
||||
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`grid gap-4 ${preview ? "md:grid-cols-2" : ""}`}>
|
||||
<textarea value={md} onChange={(e) => { setMd(e.target.value); setSaved(false); }} rows={30}
|
||||
className="w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-3 font-mono text-sm" />
|
||||
</div>
|
||||
|
||||
{/* 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 Email | Phone | Location ## Summary ..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Live Resume Document 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"
|
||||
dangerouslySetInnerHTML={{ __html: marked.parse(md) as string }} />
|
||||
<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-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>
|
||||
|
||||
@@ -6,21 +6,112 @@ export const dynamic = "force-dynamic";
|
||||
export default async function ResumesPage() {
|
||||
const resumes = await prisma.resume.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
include: { job: { select: { title: true, company: true } } },
|
||||
include: { job: { select: { id: true, title: true, company: true } } },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">Resumes ({resumes.length})</h1>
|
||||
<ul className="space-y-2">
|
||||
{resumes.map((r) => (
|
||||
<li key={r.id} className="rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-4">
|
||||
<Link href={`/resumes/${r.id}`} className="font-medium hover:text-blue-600">{r.name}</Link>
|
||||
<div className="text-sm text-zinc-500">
|
||||
{r.kind}{r.job ? ` · for ${r.job.company} — ${r.job.title}` : ""} · updated {r.updatedAt.toISOString().slice(0, 10)}
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">Resumes</h1>
|
||||
<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">
|
||||
{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>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user