diff --git a/src/features/education/actions.ts b/src/features/education/actions.ts
new file mode 100644
index 0000000..29ef4c6
--- /dev/null
+++ b/src/features/education/actions.ts
@@ -0,0 +1,156 @@
+"use server";
+
+import { prisma } from "@/core/db/prisma";
+import { verifySession } from "@/core/security/session";
+import { revalidatePath } from "next/cache";
+import { z } from "zod";
+
+const optionalTextSchema = z.preprocess((value) => {
+ if (typeof value !== "string") return undefined;
+ const trimmed = value.trim();
+ return trimmed === "" ? undefined : trimmed;
+}, z.string().optional());
+
+const optionalUrlSchema = z.preprocess((value) => {
+ if (typeof value !== "string") return undefined;
+ const trimmed = value.trim();
+ return trimmed === "" ? undefined : trimmed;
+}, z.string().url("Final project URL must be a valid URL").optional());
+
+const yearSchema = z
+ .coerce
+ .number()
+ .int("Year must be a whole number")
+ .min(1900, "Year must be 1900 or later")
+ .max(2100, "Year must be 2100 or earlier");
+
+const optionalYearSchema = z.preprocess((value) => {
+ if (value === "" || value === null || value === undefined) return undefined;
+ return value;
+}, yearSchema.optional());
+
+const educationSchema = z
+ .object({
+ institution: z.string().trim().min(1, "Institution is required"),
+ degree: z.string().trim().min(1, "Degree is required"),
+ fieldOfStudy: z.string().trim().min(1, "Field of study is required"),
+ location: optionalTextSchema,
+ startYear: yearSchema,
+ endYear: optionalYearSchema,
+ isOngoing: z.boolean().default(false),
+ description: optionalTextSchema,
+ gpa: optionalTextSchema,
+ finalProjectTitle: optionalTextSchema,
+ finalProjectUrl: optionalUrlSchema,
+ order: z.coerce.number().int().default(0),
+ })
+ .superRefine((data, ctx) => {
+ if (!data.isOngoing && typeof data.endYear !== "number") {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["endYear"],
+ message: "End year is required unless this education is ongoing",
+ });
+ }
+
+ if (
+ typeof data.endYear === "number" &&
+ data.endYear < data.startYear
+ ) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["endYear"],
+ message: "End year must be greater than or equal to start year",
+ });
+ }
+ });
+
+function extractEducationFormData(formData: FormData) {
+ return {
+ institution: formData.get("institution") as string,
+ degree: formData.get("degree") as string,
+ fieldOfStudy: formData.get("fieldOfStudy") as string,
+ location: formData.get("location") as string,
+ startYear: formData.get("startYear") as string,
+ endYear: formData.get("endYear") as string,
+ isOngoing: formData.get("isOngoing") === "on",
+ description: formData.get("description") as string,
+ gpa: formData.get("gpa") as string,
+ finalProjectTitle: formData.get("finalProjectTitle") as string,
+ finalProjectUrl: formData.get("finalProjectUrl") as string,
+ order: formData.get("order") as string,
+ };
+}
+
+export async function createEducationAction(prevState: unknown, formData: FormData) {
+ const session = await verifySession();
+ if (!session) return { success: false, message: "Unauthorized" };
+
+ const validation = educationSchema.safeParse(extractEducationFormData(formData));
+ if (!validation.success) {
+ return { success: false, message: validation.error.issues[0].message };
+ }
+
+ try {
+ await prisma.education.create({
+ data: validation.data,
+ });
+
+ revalidatePath("/admin/dashboard/education");
+ revalidatePath("/admin/dashboard");
+ revalidatePath("/");
+
+ return { success: true };
+ } catch (error) {
+ console.error(error);
+ return { success: false, message: "Failed to create education entry" };
+ }
+}
+
+export async function updateEducationAction(
+ id: string,
+ prevState: unknown,
+ formData: FormData
+) {
+ const session = await verifySession();
+ if (!session) return { success: false, message: "Unauthorized" };
+
+ const validation = educationSchema.safeParse(extractEducationFormData(formData));
+ if (!validation.success) {
+ return { success: false, message: validation.error.issues[0].message };
+ }
+
+ try {
+ await prisma.education.update({
+ where: { id },
+ data: validation.data,
+ });
+
+ revalidatePath("/admin/dashboard/education");
+ revalidatePath("/admin/dashboard");
+ revalidatePath("/");
+
+ return { success: true };
+ } catch (error) {
+ console.error(error);
+ return { success: false, message: "Failed to update education entry" };
+ }
+}
+
+export async function deleteEducationAction(id: string) {
+ const session = await verifySession();
+ if (!session) return { success: false, message: "Unauthorized" };
+
+ try {
+ await prisma.education.delete({ where: { id } });
+
+ revalidatePath("/admin/dashboard/education");
+ revalidatePath("/admin/dashboard");
+ revalidatePath("/");
+
+ return { success: true };
+ } catch (error) {
+ console.error(error);
+ return { success: false, message: "Failed to delete education entry" };
+ }
+}
diff --git a/src/features/education/delete-education-button.tsx b/src/features/education/delete-education-button.tsx
new file mode 100644
index 0000000..4de275c
--- /dev/null
+++ b/src/features/education/delete-education-button.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import { useState } from "react";
+import { Loader2, Trash2 } from "lucide-react";
+import { deleteEducationAction } from "./actions";
+
+export function DeleteEducationButton({ id }: { id: string }) {
+ const [loading, setLoading] = useState(false);
+
+ async function handleDelete() {
+ if (confirm("Delete this education entry? This cannot be undone.")) {
+ setLoading(true);
+ await deleteEducationAction(id);
+ setLoading(false);
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/src/features/education/education-form.tsx b/src/features/education/education-form.tsx
new file mode 100644
index 0000000..5fe86b5
--- /dev/null
+++ b/src/features/education/education-form.tsx
@@ -0,0 +1,259 @@
+"use client";
+
+import { useState } from "react";
+import { ArrowLeft, GraduationCap, Loader2, PlusCircle, Save } from "lucide-react";
+import { createEducationAction, updateEducationAction } from "./actions";
+import { useRouter } from "@/i18n/routing";
+
+type InitialData = {
+ institution?: string;
+ degree?: string;
+ fieldOfStudy?: string;
+ location?: string | null;
+ startYear?: number;
+ endYear?: number | null;
+ isOngoing?: boolean;
+ description?: string | null;
+ gpa?: string | null;
+ finalProjectTitle?: string | null;
+ finalProjectUrl?: string | null;
+ order?: number;
+};
+
+export function EducationForm({
+ initialData,
+ educationId,
+}: {
+ initialData?: InitialData;
+ educationId?: string;
+}) {
+ const router = useRouter();
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState
(null);
+ const [isOngoing, setIsOngoing] = useState(initialData?.isOngoing ?? false);
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setError(null);
+ setLoading(true);
+
+ const formData = new FormData(e.currentTarget);
+ let result;
+
+ if (educationId) {
+ result = await updateEducationAction(educationId, null, formData);
+ } else {
+ result = await createEducationAction(null, formData);
+ }
+
+ if (!result.success) {
+ setError(result.message || "An error occurred");
+ setLoading(false);
+ return;
+ }
+
+ router.push("/admin/dashboard/education");
+ router.refresh();
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {educationId ? "Edit Education" : "Add Education"}
+
+
+
+ {educationId
+ ? "Update your academic timeline entry"
+ : "Add a new entry to your education history"}
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/education/education-section.tsx b/src/features/education/education-section.tsx
new file mode 100644
index 0000000..f72dfd3
--- /dev/null
+++ b/src/features/education/education-section.tsx
@@ -0,0 +1,141 @@
+import { prisma } from "@/core/db/prisma";
+import { AnimatedSection } from "@/shared/components/animated-section";
+import { SectionHeading } from "@/shared/components/section-heading";
+import { ExternalLink, GraduationCap, BookOpen, Landmark, MapPin, School } from "lucide-react";
+import { getTranslations } from "next-intl/server";
+import { formatEducationPeriod } from "./education-utils";
+
+const ICONS = [
+ ,
+ ,
+ ,
+ ,
+];
+
+export async function EducationSection() {
+ const t = await getTranslations("Education");
+
+ const educationEntries = await prisma.education.findMany({
+ orderBy: { order: "asc" },
+ });
+
+ const timelineData = educationEntries.map((entry, index) => ({
+ ...entry,
+ period: formatEducationPeriod({
+ startYear: entry.startYear,
+ endYear: entry.endYear,
+ isOngoing: entry.isOngoing,
+ presentLabel: t("present"),
+ }),
+ icon: ICONS[index % ICONS.length],
+ }));
+
+ return (
+
+
+
+
+
+
+ {timelineData.length === 0 ? (
+
+
+
{t("emptyTitle")}
+
{t("emptyDescription")}
+
+ ) : (
+
+
+
+
+ {timelineData.map((item, index) => (
+
+
+
+
+ {item.period}
+
+
+ {item.degree} - {item.fieldOfStudy}
+
+
+
+ {item.institution}
+
+
+ {(item.location || item.gpa) && (
+
+ {item.location && (
+
+
+ {item.location}
+
+ )}
+ {item.gpa && (
+
+ {t("gpaLabel")}: {item.gpa}
+
+ )}
+
+ )}
+
+ {item.description && (
+
+ {item.description}
+
+ )}
+
+ {item.finalProjectUrl && (
+
+ )}
+
+
+
+ {item.icon}
+
+
+
+
+
+ ))}
+
+
+ )}
+
+
+ );
+}
diff --git a/src/features/education/education-utils.ts b/src/features/education/education-utils.ts
new file mode 100644
index 0000000..a032b61
--- /dev/null
+++ b/src/features/education/education-utils.ts
@@ -0,0 +1,21 @@
+export function formatEducationPeriod({
+ startYear,
+ endYear,
+ isOngoing,
+ presentLabel = "Present",
+}: {
+ startYear: number;
+ endYear?: number | null;
+ isOngoing: boolean;
+ presentLabel?: string;
+}) {
+ if (isOngoing) {
+ return `${startYear} - ${presentLabel}`;
+ }
+
+ if (typeof endYear === "number" && endYear > startYear) {
+ return `${startYear} - ${endYear}`;
+ }
+
+ return `${startYear}`;
+}
diff --git a/src/shared/components/footer.tsx b/src/shared/components/footer.tsx
index 0ca4982..eee5042 100644
--- a/src/shared/components/footer.tsx
+++ b/src/shared/components/footer.tsx
@@ -1,13 +1,14 @@
-import { GitFork, Link2, Mail, ArrowUp, Heart, MapPin } from "lucide-react";
+import { Link2, Mail, ArrowUp, Heart, MapPin } from "lucide-react";
import { useTranslations } from "next-intl";
import { BrandLogo } from "@/shared/components/brand-logo";
const quickLinks = [
- { href: "#experience", label: "Experience" },
- { href: "#tech-stack", label: "Tech Stack" },
- { href: "#projects", label: "Projects" },
- { href: "#contact", label: "Contact" },
-];
+ { href: "#experience", labelKey: "experience" },
+ { href: "#education", labelKey: "education" },
+ { href: "#tech-stack", labelKey: "techStack" },
+ { href: "#projects", labelKey: "projects" },
+ { href: "#contact", labelKey: "contact" },
+] as const;
export function Footer() {
const t = useTranslations("Footer");
@@ -46,7 +47,7 @@ export function Footer() {
href={link.href}
className="block text-sm text-muted-foreground hover:text-accent transition-colors"
>
- {tNav(link.label?.toLowerCase() === "tech stack" ? "techStack" : link.label.toLowerCase() as any)}
+ {tNav(link.labelKey)}
))}
diff --git a/src/shared/components/navbar.tsx b/src/shared/components/navbar.tsx
index ae92a86..e693fd6 100644
--- a/src/shared/components/navbar.tsx
+++ b/src/shared/components/navbar.tsx
@@ -8,7 +8,7 @@ import { BrandLogo } from "@/shared/components/brand-logo";
import { useTranslations, useLocale } from "next-intl";
import { usePathname, useRouter } from "@/i18n/routing";
-const SECTIONS = ["experience", "tech-stack", "projects", "contact"];
+const SECTIONS = ["experience", "education", "tech-stack", "projects", "contact"];
export function Navbar() {
const [isScrolled, setIsScrolled] = useState(false);
@@ -49,6 +49,7 @@ export function Navbar() {
const navLinks = [
{ href: "#experience", id: "experience", label: t("experience") },
+ { href: "#education", id: "education", label: t("education") },
{ href: "#tech-stack", id: "tech-stack", label: t("techStack") },
{ href: "#projects", id: "projects", label: t("projects") },
{ href: "#contact", id: "contact", label: t("contact") },