feat: implement education module with CRUD functionality, database schema, and localization support

This commit is contained in:
Yolando
2026-04-03 20:09:17 +07:00
parent 0f50a60084
commit 6387e2e5a5
16 changed files with 907 additions and 11 deletions

View File

@@ -0,0 +1,44 @@
import { prisma } from "@/core/db/prisma";
import { verifySession } from "@/core/security/session";
import { redirect } from "@/i18n/routing";
import { getLocale } from "next-intl/server";
import { notFound } from "next/navigation";
import { EducationForm } from "@/features/education/education-form";
export default async function EditEducationPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const session = await verifySession();
const locale = await getLocale();
if (!session) redirect({ href: "/admin/login", locale });
const { id } = await params;
const education = await prisma.education.findUnique({ where: { id } });
if (!education) notFound();
return (
<div className="min-h-screen bg-muted/10 p-6 lg:p-12">
<EducationForm
initialData={{
institution: education.institution,
degree: education.degree,
fieldOfStudy: education.fieldOfStudy,
location: education.location,
startYear: education.startYear,
endYear: education.endYear,
isOngoing: education.isOngoing,
description: education.description,
gpa: education.gpa,
finalProjectTitle: education.finalProjectTitle,
finalProjectUrl: education.finalProjectUrl,
order: education.order,
}}
educationId={education.id}
/>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { verifySession } from "@/core/security/session";
import { redirect } from "@/i18n/routing";
import { getLocale } from "next-intl/server";
import { EducationForm } from "@/features/education/education-form";
export default async function CreateEducationPage() {
const session = await verifySession();
const locale = await getLocale();
if (!session) redirect({ href: "/admin/login", locale });
return (
<div className="min-h-screen bg-muted/10 p-6 lg:p-12">
<EducationForm />
</div>
);
}

View File

@@ -0,0 +1,152 @@
import { prisma } from "@/core/db/prisma";
import { verifySession } from "@/core/security/session";
import { Link, redirect } from "@/i18n/routing";
import { getLocale } from "next-intl/server";
import { ArrowLeft, ExternalLink, GraduationCap, MapPin, Pencil, Plus, School } from "lucide-react";
import { DeleteEducationButton } from "@/features/education/delete-education-button";
import { formatEducationPeriod } from "@/features/education/education-utils";
export default async function AdminEducationPage() {
const session = await verifySession();
const locale = await getLocale();
if (!session) {
redirect({ href: "/admin/login", locale });
}
const educationEntries = await prisma.education.findMany({
orderBy: { order: "asc" },
});
return (
<div className="min-h-screen bg-muted/10 p-6 lg:p-12">
<div className="max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-4">
<Link
href="/admin/dashboard"
className="p-2 rounded-full hover:bg-muted text-muted-foreground transition-colors"
>
<ArrowLeft size={20} />
</Link>
<div>
<h1 className="text-3xl font-bold tracking-tight">Education History</h1>
<p className="text-sm text-muted-foreground mt-1">
Manage your academic timeline and final project links.
</p>
</div>
</div>
<Link
href="/admin/dashboard/education/create"
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-foreground text-background font-semibold text-sm shadow-md hover:scale-105 transition-all"
>
<Plus size={16} />
Add Education
</Link>
</div>
{educationEntries.length === 0 ? (
<div className="p-12 text-center flex flex-col items-center bg-card rounded-2xl border border-border/50">
<GraduationCap size={48} className="text-muted-foreground/40 mb-4" />
<h3 className="text-lg font-bold">No education entries yet</h3>
<p className="text-sm text-muted-foreground mb-6">
Build your education timeline in the CMS.
</p>
<Link
href="/admin/dashboard/education/create"
className="px-6 py-2 rounded-xl border border-border hover:bg-muted/50 transition-colors font-medium text-sm"
>
Add First Entry
</Link>
</div>
) : (
<div className="space-y-4">
{educationEntries.map((entry) => (
<div
key={entry.id}
className="group flex items-start justify-between gap-4 p-5 rounded-2xl bg-card border border-border/50 hover:border-sky-500/30 hover:shadow-md transition-all"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1 flex-wrap">
<span className="text-xs font-mono font-bold text-sky-600 dark:text-sky-400 bg-sky-500/10 px-2.5 py-1 rounded-full">
{formatEducationPeriod({
startYear: entry.startYear,
endYear: entry.endYear,
isOngoing: entry.isOngoing,
})}
</span>
<span className="text-xs text-muted-foreground font-mono">
order: {entry.order}
</span>
{entry.finalProjectUrl && (
<span className="text-xs font-mono text-muted-foreground/70">
final project link
</span>
)}
</div>
<h3 className="font-bold text-base">
{entry.degree} - {entry.fieldOfStudy}
</h3>
<p className="text-sm text-muted-foreground font-medium flex items-center gap-1.5 mt-0.5">
<School size={12} className="text-sky-500/70" />
{entry.institution}
</p>
{(entry.location || entry.gpa) && (
<div className="mt-2 flex flex-wrap gap-2">
{entry.location && (
<span className="inline-flex items-center gap-1.5 text-xs rounded-full bg-muted/60 px-2.5 py-1 text-muted-foreground">
<MapPin size={11} />
{entry.location}
</span>
)}
{entry.gpa && (
<span className="inline-flex items-center gap-1.5 text-xs rounded-full bg-sky-500/10 px-2.5 py-1 text-sky-600 dark:text-sky-400">
GPA: {entry.gpa}
</span>
)}
</div>
)}
{entry.description && (
<p className="text-sm text-muted-foreground mt-2 line-clamp-2 leading-relaxed">
{entry.description}
</p>
)}
{entry.finalProjectTitle && (
<p className="text-sm text-foreground/80 mt-2">
Final project: {entry.finalProjectTitle}
</p>
)}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
{entry.finalProjectUrl && (
<a
href={entry.finalProjectUrl}
target="_blank"
rel="noopener noreferrer"
className="p-2 text-muted-foreground hover:text-sky-500 hover:bg-sky-500/10 rounded-lg transition-colors"
title="Open final project"
>
<ExternalLink size={16} />
</a>
)}
<Link
href={`/admin/dashboard/education/${entry.id}/edit`}
className="p-2 text-muted-foreground hover:text-sky-500 hover:bg-sky-500/10 rounded-lg transition-colors"
>
<Pencil size={16} />
</Link>
<DeleteEducationButton id={entry.id} />
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { verifySession, clearSession } from "@/core/security/session";
import { Link, redirect } from "@/i18n/routing";
import { getLocale } from "next-intl/server";
import { LogOut, ArrowRight, Code2, Inbox, Briefcase, FolderKanban } from "lucide-react";
import { LogOut, ArrowRight, Code2, Inbox, Briefcase, FolderKanban, GraduationCap } from "lucide-react";
import { prisma } from "@/core/db/prisma";
export default async function DashboardPage() {
@@ -13,12 +13,13 @@ export default async function DashboardPage() {
}
// Fetch counts in parallel
const [projectCount, skillCount, unreadMessageCount, experienceCount] =
const [projectCount, skillCount, unreadMessageCount, experienceCount, educationCount] =
await Promise.all([
prisma.project.count(),
prisma.skill.count(),
prisma.message.count({ where: { isRead: false } }),
prisma.experience.count(),
prisma.education.count(),
]);
const cards = [
@@ -59,6 +60,15 @@ export default async function DashboardPage() {
color: "text-violet-500",
accentClass: "from-violet-500 to-purple-500",
},
{
href: "/admin/dashboard/education",
icon: <GraduationCap size={22} />,
title: "Education",
description: "Manage your academic timeline.",
count: educationCount,
color: "text-sky-500",
accentClass: "from-sky-500 to-indigo-500",
},
];
return (
@@ -91,7 +101,7 @@ export default async function DashboardPage() {
{/* Main Content */}
<main className="max-w-7xl mx-auto px-6 py-12">
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
{cards.map((card) => (
<Link
key={card.href}