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

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

View File

@@ -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>