diff --git a/messages/en.json b/messages/en.json index 17e7921..efee0bc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7,7 +7,7 @@ }, "Hero": { "greeting": "Hi, I'm", - "name": "Yolando.", + "name": "Yolando Manullang.", "badge": "Available for opportunities", "titlePart1": "Building", "titleHighlight": "Secure, Scalable", @@ -142,4 +142,4 @@ "backToTop": "Back to top", "copyright": "Yolando. Built with Next.js & crafted with purpose." } -} +} \ No newline at end of file diff --git a/messages/id.json b/messages/id.json index 6123e5e..f58c982 100644 --- a/messages/id.json +++ b/messages/id.json @@ -7,7 +7,7 @@ }, "Hero": { "greeting": "Hai, saya", - "name": "Yolando.", + "name": "Yolando Manullang.", "badge": "Tersedia untuk peluang baru", "titlePart1": "Membangun Sistem", "titleHighlight": "Aman & Skalabel", @@ -142,4 +142,4 @@ "backToTop": "Kembali ke atas", "copyright": "Yolando. Dibangun dengan Next.js & dibuat dengan penuh tujuan." } -} +} \ No newline at end of file diff --git a/prisma/migrations/20260331193217_add_experience_model/migration.sql b/prisma/migrations/20260331193217_add_experience_model/migration.sql new file mode 100644 index 0000000..f0113f2 --- /dev/null +++ b/prisma/migrations/20260331193217_add_experience_model/migration.sql @@ -0,0 +1,81 @@ +-- CreateTable +CREATE TABLE "users" ( + "id" UUID NOT NULL, + "email" TEXT NOT NULL, + "password_hash" TEXT NOT NULL, + "name" TEXT NOT NULL, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "projects" ( + "id" UUID NOT NULL, + "title" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "description" TEXT NOT NULL, + "image_url" TEXT, + "repo_url" TEXT, + "live_url" TEXT, + "category" TEXT NOT NULL, + "is_published" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "projects_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "skills" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "icon_name" TEXT, + "category" TEXT NOT NULL, + + CONSTRAINT "skills_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "project_skills" ( + "project_id" UUID NOT NULL, + "skill_id" UUID NOT NULL, + + CONSTRAINT "project_skills_pkey" PRIMARY KEY ("project_id","skill_id") +); + +-- CreateTable +CREATE TABLE "messages" ( + "id" UUID NOT NULL, + "sender_name" TEXT NOT NULL, + "sender_email" TEXT NOT NULL, + "content" TEXT NOT NULL, + "is_read" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "messages_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "experiences" ( + "id" UUID NOT NULL, + "year" TEXT NOT NULL, + "title" TEXT NOT NULL, + "company" TEXT NOT NULL, + "description" TEXT NOT NULL, + "achievements" JSONB NOT NULL DEFAULT '[]', + "order" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "experiences_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "projects_slug_key" ON "projects"("slug"); + +-- AddForeignKey +ALTER TABLE "project_skills" ADD CONSTRAINT "project_skills_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "project_skills" ADD CONSTRAINT "project_skills_skill_id_fkey" FOREIGN KEY ("skill_id") REFERENCES "skills"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a9f1541..e9bc09a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -66,3 +66,16 @@ model Message { @@map("messages") } + +model Experience { + id String @id @default(uuid()) @db.Uuid + year String + title String + company String + description String @db.Text + achievements Json @default("[]") + order Int @default(0) + createdAt DateTime @default(now()) @map("created_at") + + @@map("experiences") +} diff --git a/public/uploads/1774986132810-Screenshot_2026-03-28_205042.png b/public/uploads/1774986132810-Screenshot_2026-03-28_205042.png new file mode 100644 index 0000000..6ed64fd Binary files /dev/null and b/public/uploads/1774986132810-Screenshot_2026-03-28_205042.png differ diff --git a/src/app/[locale]/admin/dashboard/experience/[id]/edit/page.tsx b/src/app/[locale]/admin/dashboard/experience/[id]/edit/page.tsx new file mode 100644 index 0000000..c2883fd --- /dev/null +++ b/src/app/[locale]/admin/dashboard/experience/[id]/edit/page.tsx @@ -0,0 +1,36 @@ +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 { ExperienceForm } from "@/features/experience/experience-form"; + +export default async function EditExperiencePage({ + 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 experience = await prisma.experience.findUnique({ where: { id } }); + if (!experience) notFound(); + + return ( +
+ +
+ ); +} diff --git a/src/app/[locale]/admin/dashboard/experience/create/page.tsx b/src/app/[locale]/admin/dashboard/experience/create/page.tsx new file mode 100644 index 0000000..238de56 --- /dev/null +++ b/src/app/[locale]/admin/dashboard/experience/create/page.tsx @@ -0,0 +1,16 @@ +import { verifySession } from "@/core/security/session"; +import { redirect } from "@/i18n/routing"; +import { getLocale } from "next-intl/server"; +import { ExperienceForm } from "@/features/experience/experience-form"; + +export default async function CreateExperiencePage() { + const session = await verifySession(); + const locale = await getLocale(); + if (!session) redirect({ href: "/admin/login", locale }); + + return ( +
+ +
+ ); +} diff --git a/src/app/[locale]/admin/dashboard/experience/page.tsx b/src/app/[locale]/admin/dashboard/experience/page.tsx new file mode 100644 index 0000000..a1db22a --- /dev/null +++ b/src/app/[locale]/admin/dashboard/experience/page.tsx @@ -0,0 +1,110 @@ +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 { Plus, Pencil, ArrowLeft, Briefcase } from "lucide-react"; +import { DeleteExperienceButton } from "@/features/experience/delete-experience-button"; + +export default async function AdminExperiencePage() { + const session = await verifySession(); + const locale = await getLocale(); + + if (!session) { + redirect({ href: "/admin/login", locale }); + } + + const experiences = await prisma.experience.findMany({ + orderBy: { order: "asc" }, + }); + + return ( +
+
+
+
+ + + +
+

Work Experience

+

+ Manage your career timeline. +

+
+
+ + + Add Experience + +
+ + {experiences.length === 0 ? ( +
+ +

No experience entries yet

+

+ Build your career timeline in the CMS. +

+ + Add First Entry + +
+ ) : ( +
+ {experiences.map((exp) => ( +
+
+
+ + {exp.year} + + + order: {exp.order} + +
+

{exp.title}

+

+ + {exp.company} +

+

+ {exp.description} +

+ {Array.isArray(exp.achievements) && exp.achievements.length > 0 && ( +
+ + {(exp.achievements as string[]).length} achievements + +
+ )} +
+ +
+ + + + +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/app/[locale]/admin/dashboard/inbox/page.tsx b/src/app/[locale]/admin/dashboard/inbox/page.tsx new file mode 100644 index 0000000..32f851a --- /dev/null +++ b/src/app/[locale]/admin/dashboard/inbox/page.tsx @@ -0,0 +1,108 @@ +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, Mail, MailOpen, Inbox } from "lucide-react"; +import { MarkReadButton, DeleteMessageButton } from "@/features/messages/message-actions"; + +export default async function AdminInboxPage() { + const session = await verifySession(); + const locale = await getLocale(); + + if (!session) { + redirect({ href: "/admin/login", locale }); + } + + const messages = await prisma.message.findMany({ + orderBy: { createdAt: "desc" }, + }); + + const unreadCount = messages.filter((m) => !m.isRead).length; + + return ( +
+
+
+
+ + + +
+

+ Inbox + {unreadCount > 0 && ( + + {unreadCount} baru + + )} +

+

+ Pesan masuk dari pengunjung portfolio. +

+
+
+
+ + {messages.length === 0 ? ( +
+ +

Inbox kosong

+

Belum ada pesan masuk.

+
+ ) : ( +
+ {messages.map((message) => ( +
+ {/* Unread indicator */} + {!message.isRead && ( + + )} + +
+
+
+
+ {message.isRead ? : } +
+ {message.senderName} + + {message.senderEmail} + +
+

+ {message.content} +

+

+ {new Date(message.createdAt).toLocaleString("id-ID")} +

+
+ +
+ {!message.isRead && } + +
+
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/app/[locale]/admin/dashboard/page.tsx b/src/app/[locale]/admin/dashboard/page.tsx index 40792a1..7401cfb 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 } from "lucide-react"; +import { LogOut, ArrowRight, Code2, Inbox, Briefcase, FolderKanban } from "lucide-react"; import { prisma } from "@/core/db/prisma"; export default async function DashboardPage() { @@ -12,6 +12,55 @@ export default async function DashboardPage() { redirect({ href: "/admin/login", locale }); } + // Fetch counts in parallel + const [projectCount, skillCount, unreadMessageCount, experienceCount] = + await Promise.all([ + prisma.project.count(), + prisma.skill.count(), + prisma.message.count({ where: { isRead: false } }), + prisma.experience.count(), + ]); + + const cards = [ + { + href: "/admin/dashboard/projects", + icon: , + title: "Projects", + description: "Manage portfolio projects and case studies.", + count: projectCount, + color: "text-accent", + accentClass: "from-accent to-purple-500", + }, + { + href: "/admin/dashboard/skills", + icon: , + title: "Tech Stack", + description: "Update your skills and technical arsenal.", + count: skillCount, + color: "text-emerald-500", + accentClass: "from-emerald-500 to-teal-500", + }, + { + href: "/admin/dashboard/inbox", + icon: , + title: "Inbox", + description: "Read messages from visitors.", + count: unreadMessageCount, + color: "text-blue-500", + accentClass: "from-blue-500 to-cyan-500", + badge: unreadMessageCount > 0 ? `${unreadMessageCount} baru` : null, + }, + { + href: "/admin/dashboard/experience", + icon: , + title: "Experience", + description: "Manage your career timeline.", + count: experienceCount, + color: "text-violet-500", + accentClass: "from-violet-500 to-purple-500", + }, + ]; + return (
{/* Navbar Minimal Dashboard */} @@ -21,15 +70,17 @@ export default async function DashboardPage() {

Admin Dashboard

{session.email}
- -
{ - "use server"; - await clearSession(); - redirect({ href: "/admin/login", locale }); - }}> + + { + "use server"; + await clearSession(); + redirect({ href: "/admin/login", locale }); + }} + > + ); +} diff --git a/src/features/experience/experience-form.tsx b/src/features/experience/experience-form.tsx new file mode 100644 index 0000000..d496894 --- /dev/null +++ b/src/features/experience/experience-form.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { useState } from "react"; +import { createExperienceAction, updateExperienceAction } from "./actions"; +import { useRouter } from "@/i18n/routing"; +import { Loader2, ArrowLeft, Save, PlusCircle } from "lucide-react"; + +type InitialData = { + year?: string; + title?: string; + company?: string; + description?: string; + achievements?: string[]; + order?: number; +}; + +export function ExperienceForm({ + initialData, + experienceId, +}: { + initialData?: InitialData; + experienceId?: string; +}) { + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const defaultAchievements = Array.isArray(initialData?.achievements) + ? initialData!.achievements.join("\n") + : ""; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + + const formData = new FormData(e.currentTarget); + let result; + if (experienceId) { + result = await updateExperienceAction(experienceId, null, formData); + } else { + result = await createExperienceAction(null, formData); + } + + if (!result.success) { + setError(result.message || "An error occurred"); + setLoading(false); + } else { + router.push("/admin/dashboard/experience"); + router.refresh(); + } + } + + return ( +
+
+ +
+ +
+

+ {experienceId ? "Edit Experience" : "Add Work Experience"} +

+

+ {experienceId + ? "Update your career timeline entry" + : "Add a new entry to your career timeline"} +

+
+
+ + + {error && ( +
+ {error} +
+ )} + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ +