diff --git a/messages/en.json b/messages/en.json index d42be37..5774c06 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1,6 +1,7 @@ { "Navigation": { "experience": "Experience", + "education": "Education", "techStack": "Tech Stack", "projects": "Projects", "contact": "Contact" @@ -59,6 +60,18 @@ } } }, + "Education": { + "badge": "Education", + "title": "Education History", + "subtitle": "The formal academic path that built the technical and professional foundation of my work.", + "present": "Present", + "gpaLabel": "GPA", + "locationLabel": "Location", + "finalProjectLabel": "Final Project", + "viewFinalProject": "View Final Project", + "emptyTitle": "No education history yet", + "emptyDescription": "Add your education history through the Admin Dashboard." + }, "TechStack": { "badge": "Technologies", "title": "Skills & Technologies", diff --git a/messages/id.json b/messages/id.json index a84a377..ad82f25 100644 --- a/messages/id.json +++ b/messages/id.json @@ -1,6 +1,7 @@ { "Navigation": { "experience": "Pengalaman", + "education": "Pendidikan", "techStack": "Tech Stack", "projects": "Proyek", "contact": "Kontak" @@ -59,6 +60,18 @@ } } }, + "Education": { + "badge": "Pendidikan", + "title": "Riwayat Pendidikan", + "subtitle": "Perjalanan akademik formal yang membentuk fondasi teknis dan profesional saya.", + "present": "Sekarang", + "gpaLabel": "GPA", + "locationLabel": "Lokasi", + "finalProjectLabel": "Tugas Akhir", + "viewFinalProject": "Lihat Tugas Akhir", + "emptyTitle": "Belum ada riwayat pendidikan", + "emptyDescription": "Tambahkan melalui Admin Dashboard." + }, "TechStack": { "badge": "Teknologi", "title": "Keahlian & Teknologi", diff --git a/prisma/migrations/20260403193000_add_education_model/migration.sql b/prisma/migrations/20260403193000_add_education_model/migration.sql new file mode 100644 index 0000000..4fad172 --- /dev/null +++ b/prisma/migrations/20260403193000_add_education_model/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "educations" ( + "id" UUID NOT NULL, + "institution" TEXT NOT NULL, + "degree" TEXT NOT NULL, + "field_of_study" TEXT NOT NULL, + "location" TEXT, + "start_year" INTEGER NOT NULL, + "end_year" INTEGER, + "is_ongoing" BOOLEAN NOT NULL DEFAULT false, + "description" TEXT, + "gpa" TEXT, + "final_project_title" TEXT, + "final_project_url" TEXT, + "order" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "educations_pkey" PRIMARY KEY ("id") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 10dd523..dcd81ce 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -85,3 +85,22 @@ model Experience { @@map("experiences") } + +model Education { + id String @id @default(uuid()) @db.Uuid + institution String + degree String + fieldOfStudy String @map("field_of_study") + location String? + startYear Int @map("start_year") + endYear Int? @map("end_year") + isOngoing Boolean @default(false) @map("is_ongoing") + description String? @db.Text + gpa String? + finalProjectTitle String? @map("final_project_title") + finalProjectUrl String? @map("final_project_url") + order Int @default(0) + createdAt DateTime @default(now()) @map("created_at") + + @@map("educations") +} diff --git a/src/app/[locale]/admin/dashboard/education/[id]/edit/page.tsx b/src/app/[locale]/admin/dashboard/education/[id]/edit/page.tsx new file mode 100644 index 0000000..b94e14b --- /dev/null +++ b/src/app/[locale]/admin/dashboard/education/[id]/edit/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/src/app/[locale]/admin/dashboard/education/create/page.tsx b/src/app/[locale]/admin/dashboard/education/create/page.tsx new file mode 100644 index 0000000..449ded7 --- /dev/null +++ b/src/app/[locale]/admin/dashboard/education/create/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/src/app/[locale]/admin/dashboard/education/page.tsx b/src/app/[locale]/admin/dashboard/education/page.tsx new file mode 100644 index 0000000..63c977d --- /dev/null +++ b/src/app/[locale]/admin/dashboard/education/page.tsx @@ -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 ( +
+
+
+
+ + + +
+

Education History

+

+ Manage your academic timeline and final project links. +

+
+
+ + + Add Education + +
+ + {educationEntries.length === 0 ? ( +
+ +

No education entries yet

+

+ Build your education timeline in the CMS. +

+ + Add First Entry + +
+ ) : ( +
+ {educationEntries.map((entry) => ( +
+
+
+ + {formatEducationPeriod({ + startYear: entry.startYear, + endYear: entry.endYear, + isOngoing: entry.isOngoing, + })} + + + order: {entry.order} + + {entry.finalProjectUrl && ( + + final project link + + )} +
+ +

+ {entry.degree} - {entry.fieldOfStudy} +

+

+ + {entry.institution} +

+ + {(entry.location || entry.gpa) && ( +
+ {entry.location && ( + + + {entry.location} + + )} + {entry.gpa && ( + + GPA: {entry.gpa} + + )} +
+ )} + + {entry.description && ( +

+ {entry.description} +

+ )} + + {entry.finalProjectTitle && ( +

+ Final project: {entry.finalProjectTitle} +

+ )} +
+ +
+ {entry.finalProjectUrl && ( + + + + )} + + + + +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/app/[locale]/admin/dashboard/page.tsx b/src/app/[locale]/admin/dashboard/page.tsx index 7401cfb..c35e979 100644 --- a/src/app/[locale]/admin/dashboard/page.tsx +++ b/src/app/[locale]/admin/dashboard/page.tsx @@ -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: , + 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 */}
-
+
{cards.map((card) => ( + {/* Experience → TechStack (white/dark → muted tint) */}
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"} +

+
+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ setIsOngoing(e.target.checked)} + className="w-5 h-5 rounded border-border text-sky-500 focus:ring-sky-500/50" + /> + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ +