feat: implement education module with CRUD functionality, database schema, and localization support
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
17
src/app/[locale]/admin/dashboard/education/create/page.tsx
Normal file
17
src/app/[locale]/admin/dashboard/education/create/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
src/app/[locale]/admin/dashboard/education/page.tsx
Normal file
152
src/app/[locale]/admin/dashboard/education/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user