Compare commits
22 Commits
0549f12a97
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6922c5bea | ||
|
|
06474f69b4 | ||
|
|
b68869b9d3 | ||
|
|
ef2fef4d37 | ||
|
|
bb4e7227f6 | ||
|
|
819e55a652 | ||
|
|
927051b3b4 | ||
|
|
9fb6db09a4 | ||
|
|
a51c1ed1f3 | ||
|
|
87f6114807 | ||
|
|
6387e2e5a5 | ||
|
|
0f50a60084 | ||
|
|
43c3bf45d1 | ||
|
|
b5a6acc622 | ||
|
|
9554674c79 | ||
|
|
e511a50021 | ||
|
|
9f3510df8f | ||
|
|
43858ce798 | ||
|
|
9fffe4c598 | ||
|
|
e0f6e4bd8b | ||
|
|
ef6b44604a | ||
|
|
01ecca4b28 |
@@ -1,27 +1,35 @@
|
||||
{
|
||||
"Navigation": {
|
||||
"experience": "Experience",
|
||||
"education": "Education",
|
||||
"techStack": "Tech Stack",
|
||||
"projects": "Projects",
|
||||
"contact": "Contact"
|
||||
},
|
||||
"Hero": {
|
||||
"greeting": "Hi, I'm",
|
||||
"name": "Yolando.",
|
||||
"greeting": "Ohh Hiii !!",
|
||||
"iAm": "I'm ",
|
||||
"name": "Yolando Manullang",
|
||||
"badge": "Available for opportunities",
|
||||
"role": "Senior Backend Developer · 3+ Years in Enterprise Banking",
|
||||
"titlePart1": "Building",
|
||||
"titleHighlight": "Secure, Scalable",
|
||||
"titlePart2": "Enterprise-Grade Systems",
|
||||
"yearsExp": "3+ Years",
|
||||
"subtitle": "in Banking Technology. Backend Developer specializing in Java Spring Boot, Microservices Architecture, and Enterprise Security.",
|
||||
"taglineRest": "Senior Backend Developer · 3+ years building secure, scalable systems in Banking Technology — Java Spring Boot, Microservices Architecture, and Enterprise Security.",
|
||||
"description": "A Senior Backend Developer with 3+ years of hands-on experience, well-versed in enterprise banking technology. I enjoy building systems that are reliable, secure, and built to scale — using the kind of stack that banks actually trust.",
|
||||
"ctaContact": "Get in Touch",
|
||||
"ctaProjects": "View Projects",
|
||||
"ctaDownloadCV": "Download CV",
|
||||
"ctaMore": "More",
|
||||
"findMeOn": "Find Me On",
|
||||
"scroll": "Scroll"
|
||||
},
|
||||
"Experience": {
|
||||
"badge": "Career Journey",
|
||||
"title": "Experience & Evolution",
|
||||
"subtitle": "A timeline of building enterprise-grade systems in the banking technology industry.",
|
||||
"badge": "Track Record",
|
||||
"title": "Professional Experience",
|
||||
"subtitle": "My journey in designing and developing enterprise-grade systems",
|
||||
"jobs": {
|
||||
"enterprise": {
|
||||
"year": "2024 — Present",
|
||||
@@ -58,10 +66,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": "Tech Arsenal",
|
||||
"title": "Technology Stack",
|
||||
"subtitle": "A comprehensive toolkit forged through years of enterprise development, from backend infrastructure to mobile interfaces.",
|
||||
"badge": "Technologies",
|
||||
"title": "Skills & Technologies",
|
||||
"seeMore": "See more",
|
||||
"subtitle": "The core technologies and tools I use to build robust and scalable enterprise applications.",
|
||||
"categories": {
|
||||
"backend": {
|
||||
"title": "Enterprise Backend",
|
||||
@@ -83,8 +104,11 @@
|
||||
},
|
||||
"Projects": {
|
||||
"badge": "Portfolio",
|
||||
"title": "Projects & Case Studies",
|
||||
"subtitle": "Real-world enterprise solutions built for scale, security, and reliability.",
|
||||
"title": "Featured Projects",
|
||||
"subtitle": "My best work in software development",
|
||||
"techStack": "Tech Stack",
|
||||
"year": "Year",
|
||||
"highlights": "Highlights",
|
||||
"filters": {
|
||||
"all": "All Projects",
|
||||
"backend": "Backend",
|
||||
@@ -122,10 +146,25 @@
|
||||
}
|
||||
},
|
||||
"Contact": {
|
||||
"badge": "Let's Connect",
|
||||
"title": "Get in Touch",
|
||||
"subtitle": "Interested in working together? Whether you're a recruiter, hiring manager, or potential collaborator — I'd love to hear from you.",
|
||||
"badge": "Contact",
|
||||
"title": "Let's Collaborate",
|
||||
"subtitle": "Have a project idea, job opportunity, or just want to chat? Feel free to send me a message.",
|
||||
"info": {
|
||||
"title": "Contact Information",
|
||||
"subtitle": "You can also reach out to me directly through the channels below.",
|
||||
"email": "Email",
|
||||
"location": "Location",
|
||||
"locationValue": "Jakarta, Indonesia",
|
||||
"responseTime": "Response Time",
|
||||
"responseTimeValue": "Less than 1 hour",
|
||||
"fastResponse": "Fast Response",
|
||||
"whatsappTemplate": "Hi Yolando, I saw your portfolio and would like to discuss a potential opportunity regarding...",
|
||||
"openToWork": "Open to Work",
|
||||
"openToWorkDesc": "Currently open for new opportunities in Backend/Fullstack Development."
|
||||
},
|
||||
"form": {
|
||||
"heading": "Send Direct Message",
|
||||
"subheading": "I'll get back to you as soon as possible via the email you provide.",
|
||||
"nameLabel": "Full Name",
|
||||
"namePlaceholder": "John Doe",
|
||||
"emailLabel": "Email Address",
|
||||
@@ -139,7 +178,10 @@
|
||||
}
|
||||
},
|
||||
"Footer": {
|
||||
"description": "Building secure, scalable, enterprise-grade systems. Specializing in banking & fintech infrastructure.",
|
||||
"quickLinksTitle": "Quick Links",
|
||||
"connectTitle": "Connect",
|
||||
"backToTop": "Back to top",
|
||||
"copyright": "Yolando. Built with Next.js & crafted with purpose."
|
||||
"copyright": "Simanullang Dev. All Rights Reserved."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,35 @@
|
||||
{
|
||||
"Navigation": {
|
||||
"experience": "Pengalaman",
|
||||
"education": "Pendidikan",
|
||||
"techStack": "Tech Stack",
|
||||
"projects": "Proyek",
|
||||
"contact": "Kontak"
|
||||
},
|
||||
"Hero": {
|
||||
"greeting": "Hai, saya",
|
||||
"name": "Yolando.",
|
||||
"greeting": "Ohh Hiii !!",
|
||||
"iAm": "Saya ",
|
||||
"name": "Yolando Manullang",
|
||||
"badge": "Tersedia untuk peluang baru",
|
||||
"role": "Senior Backend Developer · 3+ Tahun di Perbankan Enterprise",
|
||||
"titlePart1": "Membangun Sistem",
|
||||
"titleHighlight": "Aman & Skalabel",
|
||||
"titlePart2": "Skala Enterprise",
|
||||
"yearsExp": "3+ Tahun",
|
||||
"subtitle": "di Teknologi Perbankan. Backend Developer dengan spesialisasi Java Spring Boot, Arsitektur Microservices, dan Keamanan Enterprise.",
|
||||
"taglineRest": "Senior Backend Developer · 3+ tahun membangun sistem aman & skalabel di industri teknologi perbankan — Java Spring Boot, Arsitektur Microservices, dan Keamanan Enterprise.",
|
||||
"description": "Senior Backend Developer dengan 3+ tahun pengalaman kerja nyata, terbiasa dengan teknologi enterprise perbankan. Saya senang membangun sistem yang handal, aman, dan scalable — menggunakan stack yang memang dipercaya industri keuangan.",
|
||||
"ctaContact": "Hubungi Saya",
|
||||
"ctaProjects": "Lihat Proyek",
|
||||
"ctaDownloadCV": "Unduh CV",
|
||||
"ctaMore": "Selengkapnya",
|
||||
"findMeOn": "Temukan Saya Di",
|
||||
"scroll": "Scroll"
|
||||
},
|
||||
"Experience": {
|
||||
"badge": "Perjalanan Karir",
|
||||
"title": "Pengalaman & Evolusi",
|
||||
"subtitle": "Garis waktu (timeline) membangun sistem skala enterprise di industri teknologi perbankan.",
|
||||
"badge": "Rekam Jejak",
|
||||
"title": "Pengalaman Profesional",
|
||||
"subtitle": "Perjalanan saya dalam merancang dan mengembangkan sistem berskala enterprise",
|
||||
"jobs": {
|
||||
"enterprise": {
|
||||
"year": "2024 — Sekarang",
|
||||
@@ -58,10 +66,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": "Arsenal Teknologi",
|
||||
"title": "Tech Stack",
|
||||
"subtitle": "Perangkat komprehensif yang ditempa dari pengalaman pengembangan enterprise, mulai dari infrastruktur backend hingga antarmuka mobile.",
|
||||
"badge": "Teknologi",
|
||||
"title": "Keahlian & Teknologi",
|
||||
"seeMore": "Lihat semua",
|
||||
"subtitle": "Kumpulan teknologi dan perangkat utama yang saya gunakan sehari-hari untuk mengembangkan aplikasi berskala enterprise.",
|
||||
"categories": {
|
||||
"backend": {
|
||||
"title": "Enterprise Backend",
|
||||
@@ -83,8 +104,11 @@
|
||||
},
|
||||
"Projects": {
|
||||
"badge": "Portofolio",
|
||||
"title": "Proyek & Studi Kasus",
|
||||
"subtitle": "Solusi enterprise dunia nyata yang dibangun untuk skalabilitas, keamanan, dan keandalan.",
|
||||
"title": "Project Pilihan",
|
||||
"subtitle": "Karya terbaik saya dalam pengembangan perangkat lunak",
|
||||
"techStack": "Tech Stack",
|
||||
"year": "Tahun",
|
||||
"highlights": "Pencapaian",
|
||||
"filters": {
|
||||
"all": "Semua Proyek",
|
||||
"backend": "Backend",
|
||||
@@ -122,10 +146,25 @@
|
||||
}
|
||||
},
|
||||
"Contact": {
|
||||
"badge": "Mari Terhubung",
|
||||
"title": "Hubungi Saya",
|
||||
"subtitle": "Tertarik bekerja sama? Baik Anda seorang recruiter, hiring manager, atau kolaborator potensial — saya sangat ingin mendengarnya.",
|
||||
"badge": "Kontak",
|
||||
"title": "Mari Berkolaborasi",
|
||||
"subtitle": "Punya ide proyek, tawaran pekerjaan, atau sekadar ingin berdiskusi? Jangan ragu untuk mengirimkan pesan.",
|
||||
"info": {
|
||||
"title": "Informasi Kontak",
|
||||
"subtitle": "Anda juga dapat menjangkau saya secara langsung melalui saluran berikut.",
|
||||
"email": "Email",
|
||||
"location": "Lokasi",
|
||||
"locationValue": "Jakarta, Indonesia",
|
||||
"responseTime": "Waktu Respons",
|
||||
"responseTimeValue": "Kurang dari 1 jam",
|
||||
"fastResponse": "Fast Reponse",
|
||||
"whatsappTemplate": "Halo Yolando, saya melihat portofolio Anda dan tertarik untuk berdiskusi lebih lanjut tentang...",
|
||||
"openToWork": "Open to Work",
|
||||
"openToWorkDesc": "Saat ini terbuka untuk peluang di bidang Backend/Fullstack Development."
|
||||
},
|
||||
"form": {
|
||||
"heading": "Kirim Pesan Langsung",
|
||||
"subheading": "Saya akan membalas secepatnya ke email yang Anda berikan.",
|
||||
"nameLabel": "Nama Lengkap",
|
||||
"namePlaceholder": "Budi Santoso",
|
||||
"emailLabel": "Alamat Email",
|
||||
@@ -139,7 +178,10 @@
|
||||
}
|
||||
},
|
||||
"Footer": {
|
||||
"description": "Membangun sistem tingkat enterprise yang aman dan dapat diskalakan. Terspesialisasi dalam infrastruktur perbankan & fintech.",
|
||||
"quickLinksTitle": "Tautan Cepat",
|
||||
"connectTitle": "Terhubung",
|
||||
"backToTop": "Kembali ke atas",
|
||||
"copyright": "Yolando. Dibangun dengan Next.js & dibuat dengan penuh tujuan."
|
||||
"copyright": "Simanullang Dev. Semua Hak Cipta Dilindungi."
|
||||
}
|
||||
}
|
||||
|
||||
1548
package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1019.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"framer-motion": "^12.38.0",
|
||||
"jose": "^6.2.2",
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "skills" ADD COLUMN "sort_order" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "projects" ADD COLUMN "highlights" TEXT[],
|
||||
ADD COLUMN "techStack" TEXT[],
|
||||
ADD COLUMN "year" INTEGER;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "projects" ADD COLUMN "image_urls" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
@@ -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")
|
||||
);
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
@@ -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"
|
||||
@@ -22,12 +22,17 @@ model Project {
|
||||
slug String @unique
|
||||
description String @db.Text
|
||||
imageUrl String? @map("image_url")
|
||||
imageUrls String[] @default([]) @map("image_urls")
|
||||
repoUrl String? @map("repo_url")
|
||||
liveUrl String? @map("live_url")
|
||||
category String
|
||||
isPublished Boolean @default(false) @map("is_published")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
techStack String[]
|
||||
year Int?
|
||||
highlights String[]
|
||||
|
||||
skills ProjectSkill[]
|
||||
|
||||
@@map("projects")
|
||||
@@ -38,6 +43,7 @@ model Skill {
|
||||
name String
|
||||
iconName String? @map("icon_name")
|
||||
category String
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
|
||||
projects ProjectSkill[]
|
||||
|
||||
@@ -66,3 +72,35 @@ 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")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
1
public/brand/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
BIN
public/brand/foto-1.jpg
Normal file
|
After Width: | Height: | Size: 164 KiB |
BIN
public/brand/foto-2.jpeg
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
public/brand/foto-3.jpeg
Normal file
|
After Width: | Height: | Size: 344 KiB |
BIN
public/brand/icon.png
Normal file
|
After Width: | Height: | Size: 444 KiB |
BIN
public/uploads/1774706832711-Screenshot_2026-03-28_205042.png
Normal file
|
After Width: | Height: | Size: 180 KiB |
BIN
public/uploads/1774707074665-Screenshot_2026-03-20_031020.png
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
public/uploads/1774986132810-Screenshot_2026-03-28_205042.png
Normal file
|
After Width: | Height: | Size: 180 KiB |
BIN
public/uploads/1775214382728-Screenshot_2026-03-28_205042.png
Normal file
|
After Width: | Height: | Size: 180 KiB |
BIN
public/uploads/1775214826581-Screenshot_2026-03-28_205042.png
Normal file
|
After Width: | Height: | Size: 180 KiB |
BIN
public/uploads/1775215774470-Screenshot_2026-03-20_031020.png
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
public/uploads/1775219874524-graphic_Fitures.jpg
Normal file
|
After Width: | Height: | Size: 232 KiB |
BIN
public/uploads/1775219874526-Group_13.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="min-h-screen bg-muted/10 p-6 lg:p-12">
|
||||
<ExperienceForm
|
||||
initialData={{
|
||||
year: experience.year,
|
||||
title: experience.title,
|
||||
company: experience.company,
|
||||
description: experience.description,
|
||||
achievements: experience.achievements as string[],
|
||||
order: experience.order,
|
||||
}}
|
||||
experienceId={experience.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
src/app/[locale]/admin/dashboard/experience/create/page.tsx
Normal file
@@ -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 (
|
||||
<div className="min-h-screen bg-muted/10 p-6 lg:p-12">
|
||||
<ExperienceForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
src/app/[locale]/admin/dashboard/experience/page.tsx
Normal file
@@ -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 (
|
||||
<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">Work Experience</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Manage your career timeline.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/dashboard/experience/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 Experience
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{experiences.length === 0 ? (
|
||||
<div className="p-12 text-center flex flex-col items-center bg-card rounded-2xl border border-border/50">
|
||||
<Briefcase size={48} className="text-muted-foreground/40 mb-4" />
|
||||
<h3 className="text-lg font-bold">No experience entries yet</h3>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
Build your career timeline in the CMS.
|
||||
</p>
|
||||
<Link
|
||||
href="/admin/dashboard/experience/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">
|
||||
{experiences.map((exp) => (
|
||||
<div
|
||||
key={exp.id}
|
||||
className="group flex items-start justify-between gap-4 p-5 rounded-2xl bg-card border border-border/50 hover:border-accent/30 hover:shadow-md transition-all"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="text-xs font-mono font-bold text-accent bg-accent/10 px-2.5 py-1 rounded-full">
|
||||
{exp.year}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
order: {exp.order}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-bold text-base">{exp.title}</h3>
|
||||
<p className="text-sm text-muted-foreground font-medium flex items-center gap-1.5 mt-0.5">
|
||||
<Briefcase size={12} className="text-accent/60" />
|
||||
{exp.company}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-2 line-clamp-2 leading-relaxed">
|
||||
{exp.description}
|
||||
</p>
|
||||
{Array.isArray(exp.achievements) && exp.achievements.length > 0 && (
|
||||
<div className="mt-2 flex gap-2 flex-wrap">
|
||||
<span className="text-xs font-mono text-muted-foreground/60">
|
||||
{(exp.achievements as string[]).length} achievements
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
||||
<Link
|
||||
href={`/admin/dashboard/experience/${exp.id}/edit`}
|
||||
className="p-2 text-muted-foreground hover:text-accent hover:bg-accent/10 rounded-lg transition-colors"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</Link>
|
||||
<DeleteExperienceButton id={exp.id} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
src/app/[locale]/admin/dashboard/inbox/page.tsx
Normal file
@@ -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 (
|
||||
<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 flex items-center gap-3">
|
||||
Inbox
|
||||
{unreadCount > 0 && (
|
||||
<span className="text-sm font-mono font-bold px-2.5 py-1 rounded-full bg-blue-500 text-white">
|
||||
{unreadCount} baru
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Pesan masuk dari pengunjung portfolio.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{messages.length === 0 ? (
|
||||
<div className="p-12 text-center flex flex-col items-center bg-card rounded-2xl border border-border/50">
|
||||
<Inbox size={48} className="text-muted-foreground/40 mb-4" />
|
||||
<h3 className="text-lg font-bold">Inbox kosong</h3>
|
||||
<p className="text-sm text-muted-foreground">Belum ada pesan masuk.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`relative p-5 rounded-2xl border transition-all ${
|
||||
message.isRead
|
||||
? "bg-card border-border/50 opacity-70"
|
||||
: "bg-card border-blue-500/30 shadow-sm shadow-blue-500/5"
|
||||
}`}
|
||||
>
|
||||
{/* Unread indicator */}
|
||||
{!message.isRead && (
|
||||
<span className="absolute top-4 left-4 w-2 h-2 rounded-full bg-blue-500" />
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-between gap-4 pl-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div
|
||||
className={`p-1.5 rounded-lg ${
|
||||
message.isRead
|
||||
? "text-muted-foreground bg-muted"
|
||||
: "text-blue-500 bg-blue-500/10"
|
||||
}`}
|
||||
>
|
||||
{message.isRead ? <MailOpen size={14} /> : <Mail size={14} />}
|
||||
</div>
|
||||
<span className="font-bold text-sm">{message.senderName}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{message.senderEmail}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-3 pl-9">
|
||||
{message.content}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground/60 font-mono mt-2 pl-9">
|
||||
{new Date(message.createdAt).toLocaleString("id-ID")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{!message.isRead && <MarkReadButton id={message.id} />}
|
||||
<DeleteMessageButton id={message.id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { verifySession, clearSession } from "@/core/security/session";
|
||||
import { redirect } from "@/i18n/routing";
|
||||
import { Link, redirect } from "@/i18n/routing";
|
||||
import { getLocale } from "next-intl/server";
|
||||
import { LogOut } 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() {
|
||||
const session = await verifySession();
|
||||
@@ -11,6 +12,65 @@ export default async function DashboardPage() {
|
||||
redirect({ href: "/admin/login", locale });
|
||||
}
|
||||
|
||||
// Fetch counts in parallel
|
||||
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 = [
|
||||
{
|
||||
href: "/admin/dashboard/projects",
|
||||
icon: <FolderKanban size={22} />,
|
||||
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: <Code2 size={22} />,
|
||||
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: <Inbox size={22} />,
|
||||
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: <Briefcase size={22} />,
|
||||
title: "Experience",
|
||||
description: "Manage your career timeline.",
|
||||
count: experienceCount,
|
||||
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 (
|
||||
<div className="min-h-screen bg-muted/30">
|
||||
{/* Navbar Minimal Dashboard */}
|
||||
@@ -21,14 +81,16 @@ export default async function DashboardPage() {
|
||||
<span className="text-xs text-muted-foreground">{session.email}</span>
|
||||
</div>
|
||||
|
||||
<form action={async () => {
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
await clearSession();
|
||||
redirect({ href: "/admin/login", locale });
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-error hover:bg-error/10 hover:border-error/30 border border-transparent rounded-lg transition-colors"
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-red-500 hover:bg-red-500/10 hover:border-red-500/30 border border-transparent rounded-lg transition-colors"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
Sign Out
|
||||
@@ -39,27 +101,42 @@ 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-3">
|
||||
{/* Card: Projects */}
|
||||
<div className="p-6 rounded-2xl bg-card border border-border/50 shadow-sm hover:shadow-md transition-shadow">
|
||||
<h2 className="text-lg font-bold mb-2">Projects</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">Manage portfolio projects and case studies.</p>
|
||||
<div className="text-3xl font-mono font-bold text-accent">--</div>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||
{cards.map((card) => (
|
||||
<Link
|
||||
key={card.href}
|
||||
href={card.href}
|
||||
className="relative p-6 rounded-2xl bg-card border border-border/50 shadow-sm hover:shadow-lg hover:border-accent/30 hover:-translate-y-1 transition-all group block overflow-hidden"
|
||||
>
|
||||
{/* Top accent bar */}
|
||||
<div className={`absolute top-0 inset-x-0 h-0.5 bg-gradient-to-r ${card.accentClass} opacity-0 group-hover:opacity-100 transition-opacity`} />
|
||||
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className={`p-2.5 rounded-xl bg-gradient-to-br ${card.accentClass} text-white shadow-md`}>
|
||||
{card.icon}
|
||||
</div>
|
||||
<ArrowRight
|
||||
size={18}
|
||||
className="text-muted-foreground group-hover:text-accent group-hover:translate-x-1 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Card: Skills */}
|
||||
<div className="p-6 rounded-2xl bg-card border border-border/50 shadow-sm hover:shadow-md transition-shadow">
|
||||
<h2 className="text-lg font-bold mb-2">Tech Stack</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">Update your skills and technical arsenal.</p>
|
||||
<div className="text-3xl font-mono font-bold text-emerald-500">--</div>
|
||||
</div>
|
||||
|
||||
{/* Card: Messages */}
|
||||
<div className="p-6 rounded-2xl bg-card border border-border/50 shadow-sm hover:shadow-md transition-shadow relative overflow-hidden">
|
||||
<h2 className="text-lg font-bold mb-2">Inbox</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">Read messages from visitors.</p>
|
||||
<div className="text-3xl font-mono font-bold text-blue-500">--</div>
|
||||
<h2 className="text-lg font-bold mb-1 flex items-center gap-2">
|
||||
{card.title}
|
||||
{card.badge && (
|
||||
<span className="text-[10px] font-mono font-bold px-2 py-0.5 rounded-full bg-blue-500 text-white">
|
||||
{card.badge}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{card.description}
|
||||
</p>
|
||||
<div className={`text-3xl font-mono font-bold ${card.color}`}>
|
||||
{card.count}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
33
src/app/[locale]/admin/dashboard/projects/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ProjectForm } from "@/features/projects/project-form";
|
||||
import { verifySession } from "@/core/security/session";
|
||||
import { redirect } from "@/i18n/routing";
|
||||
import { getLocale } from "next-intl/server";
|
||||
import { prisma } from "@/core/db/prisma";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
export default async function EditProjectPage({
|
||||
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 project = await prisma.project.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
if (!project) return notFound();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/10 px-6 py-12">
|
||||
<ProjectForm initialData={project} projectId={project.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
src/app/[locale]/admin/dashboard/projects/create/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ProjectForm } from "@/features/projects/project-form";
|
||||
import { verifySession } from "@/core/security/session";
|
||||
import { redirect } from "@/i18n/routing";
|
||||
import { getLocale } from "next-intl/server";
|
||||
|
||||
export default async function CreateProjectPage() {
|
||||
const session = await verifySession();
|
||||
const locale = await getLocale();
|
||||
|
||||
if (!session) {
|
||||
redirect({ href: "/admin/login", locale });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/10 px-6 py-12">
|
||||
<ProjectForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
src/app/[locale]/admin/dashboard/projects/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
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 } from "lucide-react";
|
||||
import { DeleteProjectButton } from "@/features/projects/delete-button";
|
||||
import { TogglePublishButton } from "@/features/projects/toggle-button";
|
||||
|
||||
export default async function AdminProjectsPage() {
|
||||
const session = await verifySession();
|
||||
const locale = await getLocale();
|
||||
|
||||
if (!session) {
|
||||
redirect({ href: "/admin/login", locale });
|
||||
}
|
||||
|
||||
const projects = await prisma.project.findMany({
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/10 p-6 lg:p-12">
|
||||
<div className="max-w-6xl 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">Manage Projects</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Add, update, or remove portfolio case studies.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/dashboard/projects/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} />
|
||||
New Project
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-card border border-border/50 rounded-2xl shadow-sm overflow-hidden">
|
||||
{projects.length === 0 ? (
|
||||
<div className="p-12 text-center flex flex-col items-center">
|
||||
<div className="text-muted-foreground mb-4 opacity-50">
|
||||
<Plus size={48} />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold">No projects yet</h3>
|
||||
<p className="text-sm text-muted-foreground mb-6">Get started by creating your first portfolio case study.</p>
|
||||
<Link
|
||||
href="/admin/dashboard/projects/create"
|
||||
className="px-6 py-2 rounded-xl border border-border hover:bg-muted/50 transition-colors font-medium text-sm"
|
||||
>
|
||||
Create Project
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border/50 bg-muted/20 text-muted-foreground text-sm uppercase tracking-wider">
|
||||
<th className="p-4 font-semibold">Title</th>
|
||||
<th className="p-4 font-semibold">Category</th>
|
||||
<th className="p-4 font-semibold">Status</th>
|
||||
<th className="p-4 font-semibold">Date Added</th>
|
||||
<th className="p-4 font-semibold text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/30">
|
||||
{projects.map((project: any) => (
|
||||
<tr key={project.id} className="hover:bg-muted/10 transition-colors">
|
||||
<td className="p-4 font-medium flex items-center gap-3">
|
||||
{project.imageUrl && (
|
||||
<div className="w-10 h-10 rounded overflow-hidden aspect-square flex-shrink-0 bg-muted">
|
||||
<img src={project.imageUrl} alt={project.title} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="font-semibold">{project.title}</div>
|
||||
<div className="font-mono text-xs text-muted-foreground truncate max-w-[200px]">{project.slug}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 text-sm text-muted-foreground">{project.category}</td>
|
||||
<td className="p-4">
|
||||
<TogglePublishButton id={project.id} isPublished={project.isPublished} />
|
||||
</td>
|
||||
<td className="p-4 text-sm text-muted-foreground font-mono">
|
||||
{new Date(project.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="p-4 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Link
|
||||
href={`/admin/dashboard/projects/${project.id}/edit`}
|
||||
className="p-2 text-muted-foreground hover:text-accent hover:bg-accent/10 rounded-lg transition-colors"
|
||||
>
|
||||
<Pencil size={18} />
|
||||
</Link>
|
||||
<DeleteProjectButton id={project.id} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
src/app/[locale]/admin/dashboard/skills/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
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 { SkillForm } from "@/features/skills/skill-form";
|
||||
|
||||
export default async function EditSkillPage({
|
||||
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 skill = await prisma.skill.findUnique({ where: { id } });
|
||||
if (!skill) notFound();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/10 p-6 lg:p-12">
|
||||
<SkillForm initialData={skill} skillId={skill.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
src/app/[locale]/admin/dashboard/skills/create/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { verifySession } from "@/core/security/session";
|
||||
import { redirect } from "@/i18n/routing";
|
||||
import { getLocale } from "next-intl/server";
|
||||
import { SkillForm } from "@/features/skills/skill-form";
|
||||
|
||||
export default async function CreateSkillPage() {
|
||||
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">
|
||||
<SkillForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
src/app/[locale]/admin/dashboard/skills/page.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
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, Code2 } from "lucide-react";
|
||||
import { DeleteSkillButton } from "@/features/skills/delete-skill-button";
|
||||
import { SkillIcon } from "@/features/skills/skill-icon";
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
backend: "Enterprise Backend",
|
||||
infra: "Database & Infra",
|
||||
frontend: "Frontend",
|
||||
mobile: "Mobile",
|
||||
};
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
backend: "text-blue-500 bg-blue-500/10",
|
||||
infra: "text-emerald-500 bg-emerald-500/10",
|
||||
frontend: "text-violet-500 bg-violet-500/10",
|
||||
mobile: "text-orange-500 bg-orange-500/10",
|
||||
};
|
||||
|
||||
export default async function AdminSkillsPage() {
|
||||
const session = await verifySession();
|
||||
const locale = await getLocale();
|
||||
|
||||
if (!session) {
|
||||
redirect({ href: "/admin/login", locale });
|
||||
}
|
||||
|
||||
const skills = await prisma.skill.findMany({
|
||||
orderBy: [{ category: "asc" }, { sortOrder: "asc" }, { name: "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">Tech Stack</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Manage your skills and technology arsenal.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/dashboard/skills/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 Skill
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{skills.length === 0 ? (
|
||||
<div className="p-12 text-center flex flex-col items-center bg-card rounded-2xl border border-border/50">
|
||||
<Code2 size={48} className="text-muted-foreground/40 mb-4" />
|
||||
<h3 className="text-lg font-bold">No skills yet</h3>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
Start adding your tech stack.
|
||||
</p>
|
||||
<Link
|
||||
href="/admin/dashboard/skills/create"
|
||||
className="px-6 py-2 rounded-xl border border-border hover:bg-muted/50 transition-colors font-medium text-sm"
|
||||
>
|
||||
Add First Skill
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{skills.map((skill) => (
|
||||
<div
|
||||
key={skill.id}
|
||||
className="group flex items-center justify-between p-4 rounded-2xl bg-card border border-border/50 hover:border-accent/30 hover:shadow-md transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<SkillIcon iconName={skill.iconName} name={skill.name} />
|
||||
<div>
|
||||
<p className="font-semibold text-sm">{skill.name}</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`text-[10px] font-mono font-bold px-2 py-0.5 rounded-full ${
|
||||
CATEGORY_COLORS[skill.category] ?? "text-muted-foreground bg-muted"
|
||||
}`}
|
||||
>
|
||||
{CATEGORY_LABELS[skill.category] ?? skill.category}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded-full">
|
||||
#{skill.sortOrder}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Link
|
||||
href={`/admin/dashboard/skills/${skill.id}/edit`}
|
||||
className="p-2 text-muted-foreground hover:text-accent hover:bg-accent/10 rounded-lg transition-colors"
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</Link>
|
||||
<DeleteSkillButton id={skill.id} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,48 @@
|
||||
import { Navbar } from "@/shared/components/navbar";
|
||||
import { Footer } from "@/shared/components/footer";
|
||||
import { WaveDivider } from "@/shared/components/wave-divider";
|
||||
import { HeroSection } from "@/features/hero/hero-section";
|
||||
import { ExperienceSection } from "@/features/experience/experience-section";
|
||||
import { EducationSection } from "@/features/education/education-section";
|
||||
import { TechStackSection } from "@/features/skills/tech-stack-section";
|
||||
import { ProjectsSection } from "@/features/projects/projects-section";
|
||||
import { ContactSection } from "@/features/messages/contact-section";
|
||||
import { prisma } from "@/core/db/prisma";
|
||||
|
||||
export default async function HomePage() {
|
||||
const publishedProjects = await prisma.project.findMany({
|
||||
where: { isPublished: true },
|
||||
orderBy: { createdAt: "desc" }
|
||||
});
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main className="flex-1">
|
||||
<HeroSection />
|
||||
|
||||
{/* Hero → Experience */}
|
||||
<div className="section-warm-bg">
|
||||
<ExperienceSection />
|
||||
<EducationSection />
|
||||
</div>
|
||||
|
||||
{/* Experience → TechStack (white/dark → muted tint) */}
|
||||
<div className="bg-background">
|
||||
<WaveDivider fill="var(--muted)" className="opacity-30" />
|
||||
</div>
|
||||
<TechStackSection />
|
||||
<ProjectsSection />
|
||||
|
||||
{/* TechStack → Projects (muted tint → white/dark) */}
|
||||
<div className="bg-muted/30">
|
||||
<WaveDivider fill="var(--background)" />
|
||||
</div>
|
||||
<ProjectsSection initialProjects={publishedProjects} />
|
||||
|
||||
{/* Projects → Contact (white/dark → muted tint) */}
|
||||
<div className="bg-background">
|
||||
<WaveDivider fill="var(--muted)" className="opacity-30" />
|
||||
</div>
|
||||
<ContactSection />
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
109
src/app/api/admin/projects/[id]/route.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/core/db/prisma";
|
||||
import { uploadFileLocally } from "@/core/storage/local";
|
||||
import { verifySession } from "@/core/security/session";
|
||||
import { projectSchema } from "@/features/projects/project-schema";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await verifySession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const formData = await req.formData();
|
||||
|
||||
const data = {
|
||||
title: formData.get("title") as string,
|
||||
slug: formData.get("slug") as string,
|
||||
description: formData.get("description") as string,
|
||||
category: formData.get("category") as string,
|
||||
repoUrl: formData.get("repoUrl") as string,
|
||||
liveUrl: formData.get("liveUrl") as string,
|
||||
isPublished: formData.get("isPublished") === "true" || formData.get("isPublished") === "on",
|
||||
techStack: formData.get("techStack") as string,
|
||||
year: formData.get("year") as string,
|
||||
highlights: formData.get("highlights") as string,
|
||||
};
|
||||
|
||||
const validation = projectSchema.safeParse(data);
|
||||
if (!validation.success) {
|
||||
return NextResponse.json({ success: false, message: validation.error.issues[0].message }, { status: 400 });
|
||||
}
|
||||
|
||||
// Existing images that should be retained (sent back from client)
|
||||
const existingImages = formData.getAll("existingImages") as string[];
|
||||
|
||||
// Handle new images upload
|
||||
const imageFiles = formData.getAll("images") as File[];
|
||||
const newUploadedUrls: string[] = [];
|
||||
|
||||
for (const file of imageFiles) {
|
||||
if (file && file.size > 0 && file.name) {
|
||||
try {
|
||||
const url = await uploadFileLocally(file);
|
||||
newUploadedUrls.push(url);
|
||||
} catch (e: any) {
|
||||
console.error("Local upload error:", e);
|
||||
return NextResponse.json({ success: false, message: "Failed to upload one or more images." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge: retained existing + newly uploaded
|
||||
const allImageUrls = [...existingImages, ...newUploadedUrls];
|
||||
|
||||
const project = await prisma.project.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title: validation.data.title,
|
||||
slug: validation.data.slug,
|
||||
description: validation.data.description,
|
||||
category: validation.data.category,
|
||||
repoUrl: validation.data.repoUrl || null,
|
||||
liveUrl: validation.data.liveUrl || null,
|
||||
isPublished: validation.data.isPublished,
|
||||
// First image is cover
|
||||
...(allImageUrls.length > 0 && { imageUrl: allImageUrls[0] }),
|
||||
imageUrls: allImageUrls,
|
||||
techStack: validation.data.techStack ? validation.data.techStack.split(",").map(s => s.trim()).filter(s => s) : [],
|
||||
year: validation.data.year || null,
|
||||
highlights: validation.data.highlights ? validation.data.highlights.split(",").map(s => s.trim()).filter(s => s) : [],
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/admin/dashboard/projects");
|
||||
revalidatePath("/admin/dashboard");
|
||||
revalidatePath("/");
|
||||
|
||||
return NextResponse.json({ success: true, project });
|
||||
} catch (error: any) {
|
||||
console.error("API Error (Update Project):", error);
|
||||
if (error?.code === "P2002") {
|
||||
return NextResponse.json({ success: false, message: "Project slug already exists" }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json({ success: false, message: "Failed to update project" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await verifySession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
await prisma.project.delete({ where: { id } });
|
||||
revalidatePath("/admin/dashboard/projects");
|
||||
revalidatePath("/admin/dashboard");
|
||||
revalidatePath("/");
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ success: false, message: "Failed to delete project" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
81
src/app/api/admin/projects/route.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/core/db/prisma";
|
||||
import { uploadFileLocally } from "@/core/storage/local";
|
||||
import { verifySession } from "@/core/security/session";
|
||||
import { projectSchema } from "@/features/projects/project-schema";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await verifySession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await req.formData();
|
||||
|
||||
const data = {
|
||||
title: formData.get("title") as string,
|
||||
slug: formData.get("slug") as string,
|
||||
description: formData.get("description") as string,
|
||||
category: formData.get("category") as string,
|
||||
repoUrl: formData.get("repoUrl") as string,
|
||||
liveUrl: formData.get("liveUrl") as string,
|
||||
isPublished: formData.get("isPublished") === "true" || formData.get("isPublished") === "on",
|
||||
techStack: formData.get("techStack") as string,
|
||||
year: formData.get("year") as string,
|
||||
highlights: formData.get("highlights") as string,
|
||||
};
|
||||
|
||||
const validation = projectSchema.safeParse(data);
|
||||
if (!validation.success) {
|
||||
return NextResponse.json({ success: false, message: validation.error.issues[0].message }, { status: 400 });
|
||||
}
|
||||
|
||||
// Handle multiple images upload
|
||||
const imageFiles = formData.getAll("images") as File[];
|
||||
const uploadedUrls: string[] = [];
|
||||
|
||||
for (const file of imageFiles) {
|
||||
if (file && file.size > 0 && file.name) {
|
||||
try {
|
||||
const url = await uploadFileLocally(file);
|
||||
uploadedUrls.push(url);
|
||||
} catch (e: any) {
|
||||
console.error("Local upload error:", e);
|
||||
return NextResponse.json({ success: false, message: "Failed to upload one or more images." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
title: validation.data.title,
|
||||
slug: validation.data.slug,
|
||||
description: validation.data.description,
|
||||
category: validation.data.category,
|
||||
repoUrl: validation.data.repoUrl || null,
|
||||
liveUrl: validation.data.liveUrl || null,
|
||||
isPublished: validation.data.isPublished,
|
||||
// First image becomes the cover (backward compat)
|
||||
imageUrl: uploadedUrls[0] || undefined,
|
||||
imageUrls: uploadedUrls,
|
||||
techStack: validation.data.techStack ? validation.data.techStack.split(",").map(s => s.trim()).filter(s => s) : [],
|
||||
year: validation.data.year || null,
|
||||
highlights: validation.data.highlights ? validation.data.highlights.split(",").map(s => s.trim()).filter(s => s) : [],
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/admin/dashboard/projects");
|
||||
revalidatePath("/admin/dashboard");
|
||||
revalidatePath("/");
|
||||
|
||||
return NextResponse.json({ success: true, project });
|
||||
} catch (error: any) {
|
||||
console.error("API Error (Create Project):", error);
|
||||
if (error?.code === "P2002") {
|
||||
return NextResponse.json({ success: false, message: "Project slug already exists" }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json({ success: false, message: "Failed to create project" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,10 @@
|
||||
|
||||
/* Section padding */
|
||||
--section-py: 6rem;
|
||||
|
||||
/* Light mode section tints */
|
||||
--hero-bg: linear-gradient(135deg, #fafafa 0%, #f0edff 40%, #ede9fe 60%, #f5f3ff 100%);
|
||||
--section-warm: linear-gradient(180deg, #fafafa 0%, #f8f7ff 50%, #fafafa 100%);
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -64,6 +68,9 @@
|
||||
--glass-border: rgba(255,255,255,0.08);
|
||||
|
||||
--gradient-subtle: linear-gradient(135deg, rgba(99,102,241,0.12) 0%, rgba(168,85,247,0.12) 100%);
|
||||
|
||||
--hero-bg: none;
|
||||
--section-warm: none;
|
||||
}
|
||||
|
||||
/* ========== BASE STYLES ========== */
|
||||
@@ -134,14 +141,30 @@ body {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Grid pattern background */
|
||||
/* Grid pattern background - softer dot pattern for light mode */
|
||||
.grid-pattern {
|
||||
background-image:
|
||||
radial-gradient(circle, var(--border) 1px, transparent 1px);
|
||||
background-size: 32px 32px;
|
||||
}
|
||||
|
||||
.dark .grid-pattern {
|
||||
background-image:
|
||||
linear-gradient(var(--border) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--border) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
}
|
||||
|
||||
/* Hero section background */
|
||||
.hero-gradient-bg {
|
||||
background: var(--hero-bg);
|
||||
}
|
||||
|
||||
/* Section warm tint for light mode */
|
||||
.section-warm-bg {
|
||||
background: var(--section-warm);
|
||||
}
|
||||
|
||||
/* Animated gradient orbs */
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px) rotate(0deg); }
|
||||
@@ -181,3 +204,65 @@ body {
|
||||
--section-py: 4rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== #14 SMOOTH THEME TRANSITION ========== */
|
||||
body,
|
||||
body * {
|
||||
transition: background-color 0.3s ease, border-color 0.3s ease, color 0.15s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
/* Exclude animated elements from theme transition to avoid conflicts */
|
||||
[data-framer-motion], .animate-spin, .animate-pulse {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* ========== #11 BADGE SHIMMER ========== */
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% center; }
|
||||
100% { background-position: 200% center; }
|
||||
}
|
||||
.badge-shimmer {
|
||||
background-size: 200% auto;
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(99, 102, 241, 0.08) 40%,
|
||||
rgba(99, 102, 241, 0.15) 50%,
|
||||
rgba(99, 102, 241, 0.08) 60%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: shimmer 3s linear infinite;
|
||||
}
|
||||
|
||||
/* ========== #13 SCROLL INDICATOR GLOW ========== */
|
||||
@keyframes ring-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.3); }
|
||||
50% { box-shadow: 0 0 0 10px rgba(99, 102, 241, 0); }
|
||||
}
|
||||
.scroll-indicator-ring {
|
||||
animation: ring-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ========== #10 SECTION WAVE DIVIDER ========== */
|
||||
.section-divider {
|
||||
position: relative;
|
||||
margin-top: -1px;
|
||||
}
|
||||
.section-divider svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.section-divider svg {
|
||||
height: 72px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== #7 PROJECT CARD TILT ========== */
|
||||
.project-card {
|
||||
transition: transform 0.4s ease, box-shadow 0.4s ease, border-color 0.4s ease;
|
||||
}
|
||||
.project-card:hover {
|
||||
transform: translateY(-6px);
|
||||
box-shadow: 0 20px 40px rgba(99, 102, 241, 0.08), 0 8px 16px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
28
src/core/storage/local.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { writeFile, mkdir } from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Uploads a file to the local public/uploads directory.
|
||||
* Returns the public relative URL to be saved in the database.
|
||||
*/
|
||||
export async function uploadFileLocally(file: File): Promise<string> {
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(bytes);
|
||||
|
||||
const uploadDir = path.join(process.cwd(), 'public', 'uploads');
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
try {
|
||||
await mkdir(uploadDir, { recursive: true });
|
||||
} catch (e) {
|
||||
// Directory already exists, ignore
|
||||
}
|
||||
|
||||
const uniqueName = `${Date.now()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, "_")}`;
|
||||
const filePath = path.join(uploadDir, uniqueName);
|
||||
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
// Return the path relative to the public directory so Next.js can serve it
|
||||
return `/uploads/${uniqueName}`;
|
||||
}
|
||||
38
src/core/storage/minio.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
|
||||
export const s3Client = new S3Client({
|
||||
region: "us-east-1",
|
||||
endpoint: process.env.MINIO_ENDPOINT || "http://localhost:9000",
|
||||
forcePathStyle: true, // Required for MinIO
|
||||
credentials: {
|
||||
accessKeyId: process.env.MINIO_ACCESS_KEY || "admin",
|
||||
secretAccessKey: process.env.MINIO_SECRET_KEY || "password123",
|
||||
},
|
||||
});
|
||||
|
||||
export const BUCKET_NAME = process.env.MINIO_BUCKET_NAME || "portfolio";
|
||||
|
||||
/**
|
||||
* Uploads a file to MinIO and returns the public URL
|
||||
*/
|
||||
export async function uploadFileToMinio(file: File): Promise<string> {
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(bytes);
|
||||
|
||||
// Generate unique filename to prevent overwriting
|
||||
const uniqueName = `${Date.now()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, "_")}`;
|
||||
|
||||
await s3Client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: BUCKET_NAME,
|
||||
Key: uniqueName,
|
||||
Body: buffer,
|
||||
ContentType: file.type,
|
||||
// Setting ACL public-read ensures the frontend can display the image natively
|
||||
// NOTE: Ensure the MinIO bucket policy itself allows public read if ACL doesn't stick
|
||||
// ACL: "public-read",
|
||||
})
|
||||
);
|
||||
|
||||
return `${process.env.MINIO_ENDPOINT}/${BUCKET_NAME}/${uniqueName}`;
|
||||
}
|
||||
156
src/features/education/actions.ts
Normal file
@@ -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" };
|
||||
}
|
||||
}
|
||||
28
src/features/education/delete-education-button.tsx
Normal file
@@ -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 (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
className="p-2 text-muted-foreground hover:text-red-500 hover:bg-red-500/10 rounded-lg transition-colors disabled:opacity-50"
|
||||
title="Delete Education"
|
||||
>
|
||||
{loading ? <Loader2 size={16} className="animate-spin" /> : <Trash2 size={16} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
259
src/features/education/education-form.tsx
Normal file
@@ -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<string | null>(null);
|
||||
const [isOngoing, setIsOngoing] = useState(initialData?.isOngoing ?? false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
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 (
|
||||
<div className="max-w-3xl mx-auto p-6 lg:p-10 rounded-3xl bg-card border border-border shadow-sm relative overflow-hidden">
|
||||
<div className="absolute top-0 inset-x-0 h-1 bg-gradient-to-r from-sky-500 to-indigo-500" />
|
||||
|
||||
<div className="mb-8 flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="p-2 rounded-full hover:bg-muted text-muted-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-sky-500 to-indigo-500 text-white flex items-center justify-center shadow-lg">
|
||||
<GraduationCap size={18} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{educationId ? "Edit Education" : "Add Education"}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{educationId
|
||||
? "Update your academic timeline entry"
|
||||
: "Add a new entry to your education history"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-500 text-sm font-medium">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">Institution</label>
|
||||
<input
|
||||
name="institution"
|
||||
required
|
||||
defaultValue={initialData?.institution ?? ""}
|
||||
placeholder="e.g. Universitas Indonesia"
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-sky-500/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">Location</label>
|
||||
<input
|
||||
name="location"
|
||||
defaultValue={initialData?.location ?? ""}
|
||||
placeholder="e.g. Depok, Indonesia"
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-sky-500/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">Degree</label>
|
||||
<input
|
||||
name="degree"
|
||||
required
|
||||
defaultValue={initialData?.degree ?? ""}
|
||||
placeholder="e.g. Bachelor of Computer Science"
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-sky-500/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">Field of Study</label>
|
||||
<input
|
||||
name="fieldOfStudy"
|
||||
required
|
||||
defaultValue={initialData?.fieldOfStudy ?? ""}
|
||||
placeholder="e.g. Information Systems"
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-sky-500/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">Start Year</label>
|
||||
<input
|
||||
name="startYear"
|
||||
type="number"
|
||||
min="1900"
|
||||
max="2100"
|
||||
required
|
||||
defaultValue={initialData?.startYear ?? ""}
|
||||
placeholder="2019"
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm font-mono focus:outline-none focus:ring-2 focus:ring-sky-500/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">End Year</label>
|
||||
<input
|
||||
name="endYear"
|
||||
type="number"
|
||||
min="1900"
|
||||
max="2100"
|
||||
disabled={isOngoing}
|
||||
defaultValue={initialData?.endYear ?? ""}
|
||||
placeholder={isOngoing ? "Still ongoing" : "2023"}
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm font-mono focus:outline-none focus:ring-2 focus:ring-sky-500/50 transition-all disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">Display Order</label>
|
||||
<input
|
||||
name="order"
|
||||
type="number"
|
||||
defaultValue={initialData?.order ?? 0}
|
||||
placeholder="0 = first"
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm font-mono focus:outline-none focus:ring-2 focus:ring-sky-500/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<input
|
||||
id="isOngoing"
|
||||
name="isOngoing"
|
||||
type="checkbox"
|
||||
checked={isOngoing}
|
||||
onChange={(e) => setIsOngoing(e.target.checked)}
|
||||
className="w-5 h-5 rounded border-border text-sky-500 focus:ring-sky-500/50"
|
||||
/>
|
||||
<label htmlFor="isOngoing" className="text-sm font-medium cursor-pointer">
|
||||
This education entry is ongoing
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">GPA</label>
|
||||
<input
|
||||
name="gpa"
|
||||
defaultValue={initialData?.gpa ?? ""}
|
||||
placeholder="e.g. 3.85 / 4.00"
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm font-mono focus:outline-none focus:ring-2 focus:ring-sky-500/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">Final Project Title</label>
|
||||
<input
|
||||
name="finalProjectTitle"
|
||||
defaultValue={initialData?.finalProjectTitle ?? ""}
|
||||
placeholder="e.g. Sistem Monitoring Distribusi Dokumen"
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-sky-500/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">Final Project URL</label>
|
||||
<input
|
||||
name="finalProjectUrl"
|
||||
type="url"
|
||||
defaultValue={initialData?.finalProjectUrl ?? ""}
|
||||
placeholder="https://repository.example.edu/final-project"
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm font-mono focus:outline-none focus:ring-2 focus:ring-sky-500/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">Description</label>
|
||||
<textarea
|
||||
name="description"
|
||||
rows={4}
|
||||
defaultValue={initialData?.description ?? ""}
|
||||
placeholder="Describe the focus of your studies, thesis theme, or relevant specialization..."
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-sky-500/50 transition-all resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 border-t border-border/50 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-8 py-3 rounded-xl bg-foreground text-background font-semibold text-sm hover:opacity-90 transition-all disabled:opacity-50 hover:scale-[1.02] shadow-md"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : educationId ? (
|
||||
<><Save size={18} /> Update</>
|
||||
) : (
|
||||
<><PlusCircle size={18} /> Save Education</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
src/features/education/education-section.tsx
Normal file
@@ -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 = [
|
||||
<GraduationCap key="graduation-cap" size={20} />,
|
||||
<BookOpen key="book-open" size={20} />,
|
||||
<Landmark key="landmark" size={20} />,
|
||||
<School key="school" size={20} />,
|
||||
];
|
||||
|
||||
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 (
|
||||
<section id="education" className="section-padding relative">
|
||||
<div className="max-w-5xl mx-auto px-6">
|
||||
<AnimatedSection>
|
||||
<SectionHeading title={t("title")} subtitle={t("subtitle")} />
|
||||
</AnimatedSection>
|
||||
|
||||
{timelineData.length === 0 ? (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<GraduationCap size={40} className="mx-auto mb-4 opacity-30" />
|
||||
<p className="text-lg font-semibold mb-2">{t("emptyTitle")}</p>
|
||||
<p className="text-sm">{t("emptyDescription")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<div className="timeline-line" />
|
||||
|
||||
<div className="space-y-12 md:space-y-16">
|
||||
{timelineData.map((item, index) => (
|
||||
<AnimatedSection key={item.id} delay={index * 0.15}>
|
||||
<div
|
||||
className={`relative flex flex-col md:flex-row items-start gap-8 md:gap-24 ${
|
||||
index % 2 === 0 ? "md:flex-row" : "md:flex-row-reverse"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex-1 ml-20 md:ml-0 ${
|
||||
index % 2 === 0 ? "md:text-right" : "md:text-left"
|
||||
}`}
|
||||
>
|
||||
<span className="inline-block font-mono text-xs text-accent font-semibold tracking-wider uppercase mb-2">
|
||||
{item.period}
|
||||
</span>
|
||||
<h3 className="text-xl font-bold mb-1">
|
||||
{item.degree} - {item.fieldOfStudy}
|
||||
</h3>
|
||||
<div
|
||||
className={`text-muted-foreground text-sm font-medium mb-3 flex items-center gap-1.5 ${
|
||||
index % 2 === 0 ? "md:justify-end" : "md:justify-start"
|
||||
}`}
|
||||
>
|
||||
<School size={14} className="text-accent/70" />
|
||||
<span>{item.institution}</span>
|
||||
</div>
|
||||
|
||||
{(item.location || item.gpa) && (
|
||||
<div
|
||||
className={`flex flex-wrap gap-2 mb-4 ${
|
||||
index % 2 === 0 ? "md:justify-end" : "md:justify-start"
|
||||
}`}
|
||||
>
|
||||
{item.location && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted/60 px-3 py-1 text-xs text-muted-foreground">
|
||||
<MapPin size={12} />
|
||||
{item.location}
|
||||
</span>
|
||||
)}
|
||||
{item.gpa && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-accent/10 px-3 py-1 text-xs text-accent">
|
||||
{t("gpaLabel")}: {item.gpa}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.description && (
|
||||
<p className="text-muted-foreground text-sm leading-relaxed mb-4">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{item.finalProjectUrl && (
|
||||
<div className="inline-flex max-w-md flex-col gap-3 rounded-2xl border border-accent/15 bg-accent/5 p-4">
|
||||
<span className="text-[10px] font-mono font-bold uppercase tracking-[0.2em] text-accent">
|
||||
{t("finalProjectLabel")}
|
||||
</span>
|
||||
{item.finalProjectTitle && (
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{item.finalProjectTitle}
|
||||
</p>
|
||||
)}
|
||||
<a
|
||||
href={item.finalProjectUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-accent hover:text-accent/80 transition-colors"
|
||||
>
|
||||
{t("viewFinalProject")}
|
||||
<ExternalLink size={14} />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute left-0 md:left-1/2 md:-translate-x-1/2 top-0 w-12 h-12 rounded-xl bg-card border-2 border-accent/30 flex items-center justify-center text-accent shadow-lg shadow-accent/10 z-10">
|
||||
{item.icon}
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block flex-1" />
|
||||
</div>
|
||||
</AnimatedSection>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
21
src/features/education/education-utils.ts
Normal file
@@ -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}`;
|
||||
}
|
||||
117
src/features/experience/actions.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/core/db/prisma";
|
||||
import { verifySession } from "@/core/security/session";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
|
||||
const experienceSchema = z.object({
|
||||
year: z.string().min(1, "Year/period is required"),
|
||||
title: z.string().min(1, "Job title is required"),
|
||||
company: z.string().min(1, "Company name is required"),
|
||||
description: z.string().min(1, "Description is required"),
|
||||
achievements: z.string().optional(),
|
||||
order: z.coerce.number().default(0),
|
||||
});
|
||||
|
||||
function parseAchievements(raw: string | undefined): string[] {
|
||||
if (!raw) return [];
|
||||
return raw
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export async function createExperienceAction(prevState: any, formData: FormData) {
|
||||
const session = await verifySession();
|
||||
if (!session) return { success: false, message: "Unauthorized" };
|
||||
|
||||
const data = {
|
||||
year: formData.get("year") as string,
|
||||
title: formData.get("title") as string,
|
||||
company: formData.get("company") as string,
|
||||
description: formData.get("description") as string,
|
||||
achievements: formData.get("achievements") as string,
|
||||
order: formData.get("order") as string,
|
||||
};
|
||||
|
||||
const validation = experienceSchema.safeParse(data);
|
||||
if (!validation.success) {
|
||||
return { success: false, message: validation.error.issues[0].message };
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.experience.create({
|
||||
data: {
|
||||
year: validation.data.year,
|
||||
title: validation.data.title,
|
||||
company: validation.data.company,
|
||||
description: validation.data.description,
|
||||
achievements: parseAchievements(validation.data.achievements),
|
||||
order: validation.data.order,
|
||||
},
|
||||
});
|
||||
revalidatePath("/admin/dashboard/experience");
|
||||
revalidatePath("/");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return { success: false, message: "Failed to create experience" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateExperienceAction(
|
||||
id: string,
|
||||
prevState: any,
|
||||
formData: FormData
|
||||
) {
|
||||
const session = await verifySession();
|
||||
if (!session) return { success: false, message: "Unauthorized" };
|
||||
|
||||
const data = {
|
||||
year: formData.get("year") as string,
|
||||
title: formData.get("title") as string,
|
||||
company: formData.get("company") as string,
|
||||
description: formData.get("description") as string,
|
||||
achievements: formData.get("achievements") as string,
|
||||
order: formData.get("order") as string,
|
||||
};
|
||||
|
||||
const validation = experienceSchema.safeParse(data);
|
||||
if (!validation.success) {
|
||||
return { success: false, message: validation.error.issues[0].message };
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.experience.update({
|
||||
where: { id },
|
||||
data: {
|
||||
year: validation.data.year,
|
||||
title: validation.data.title,
|
||||
company: validation.data.company,
|
||||
description: validation.data.description,
|
||||
achievements: parseAchievements(validation.data.achievements),
|
||||
order: validation.data.order,
|
||||
},
|
||||
});
|
||||
revalidatePath("/admin/dashboard/experience");
|
||||
revalidatePath("/");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, message: "Failed to update experience" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteExperienceAction(id: string) {
|
||||
const session = await verifySession();
|
||||
if (!session) return { success: false, message: "Unauthorized" };
|
||||
|
||||
try {
|
||||
await prisma.experience.delete({ where: { id } });
|
||||
revalidatePath("/admin/dashboard/experience");
|
||||
revalidatePath("/");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, message: "Failed to delete experience" };
|
||||
}
|
||||
}
|
||||
28
src/features/experience/delete-experience-button.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { deleteExperienceAction } from "./actions";
|
||||
import { Trash2, Loader2 } from "lucide-react";
|
||||
|
||||
export function DeleteExperienceButton({ id }: { id: string }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleDelete() {
|
||||
if (confirm("Delete this experience entry? This cannot be undone.")) {
|
||||
setLoading(true);
|
||||
await deleteExperienceAction(id);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
className="p-2 text-muted-foreground hover:text-red-500 hover:bg-red-500/10 rounded-lg transition-colors disabled:opacity-50"
|
||||
title="Delete Experience"
|
||||
>
|
||||
{loading ? <Loader2 size={16} className="animate-spin" /> : <Trash2 size={16} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
178
src/features/experience/experience-form.tsx
Normal file
@@ -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<string | null>(null);
|
||||
|
||||
const defaultAchievements = Array.isArray(initialData?.achievements)
|
||||
? initialData!.achievements.join("\n")
|
||||
: "";
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
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 (
|
||||
<div className="max-w-2xl mx-auto p-6 lg:p-10 rounded-3xl bg-card border border-border shadow-sm relative overflow-hidden">
|
||||
<div className="absolute top-0 inset-x-0 h-1 bg-gradient-to-r from-violet-500 to-purple-500" />
|
||||
|
||||
<div className="mb-8 flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="p-2 rounded-full hover:bg-muted text-muted-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{experienceId ? "Edit Experience" : "Add Work Experience"}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{experienceId
|
||||
? "Update your career timeline entry"
|
||||
: "Add a new entry to your career timeline"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-500 text-sm font-medium">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">Year / Period</label>
|
||||
<input
|
||||
name="year"
|
||||
required
|
||||
defaultValue={initialData?.year}
|
||||
placeholder="e.g. 2024 — Present"
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-violet-500/50 transition-all font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">Display Order</label>
|
||||
<input
|
||||
name="order"
|
||||
type="number"
|
||||
defaultValue={initialData?.order ?? 0}
|
||||
placeholder="0 = first"
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-violet-500/50 transition-all font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">Job Title</label>
|
||||
<input
|
||||
name="title"
|
||||
required
|
||||
defaultValue={initialData?.title}
|
||||
placeholder="e.g. Senior Backend Developer"
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-violet-500/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">Company</label>
|
||||
<input
|
||||
name="company"
|
||||
required
|
||||
defaultValue={initialData?.company}
|
||||
placeholder="e.g. PT Bank Mandiri"
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-violet-500/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">Description</label>
|
||||
<textarea
|
||||
name="description"
|
||||
required
|
||||
defaultValue={initialData?.description}
|
||||
rows={3}
|
||||
placeholder="Describe your role and responsibilities..."
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-violet-500/50 transition-all resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">
|
||||
Key Achievements{" "}
|
||||
<span className="text-muted-foreground font-normal text-xs">
|
||||
— satu baris per achievement
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
name="achievements"
|
||||
defaultValue={defaultAchievements}
|
||||
rows={5}
|
||||
placeholder={
|
||||
"Architected event-driven system processing 500K+ transactions/day\nReduced API response time by 40% through caching optimization\nMentored team of 4 junior developers"
|
||||
}
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-violet-500/50 transition-all font-mono resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 border-t border-border/50 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-8 py-3 rounded-xl bg-foreground text-background font-semibold text-sm hover:opacity-90 transition-all disabled:opacity-50 hover:scale-[1.02] shadow-md"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : experienceId ? (
|
||||
<><Save size={18} /> Update</>
|
||||
) : (
|
||||
<><PlusCircle size={18} /> Save Experience</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { prisma } from "@/core/db/prisma";
|
||||
import { AnimatedSection } from "@/shared/components/animated-section";
|
||||
import { SectionHeading } from "@/shared/components/section-heading";
|
||||
import { Briefcase, Award, Rocket, Code2, Building2 } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Award, Briefcase, Rocket, Code2, Building2 } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export function ExperienceSection() {
|
||||
const t = useTranslations("Experience");
|
||||
|
||||
const timelineData = [
|
||||
{
|
||||
year: t("jobs.enterprise.year"),
|
||||
title: t("jobs.enterprise.title"),
|
||||
company: t("jobs.enterprise.company"),
|
||||
description: t("jobs.enterprise.description"),
|
||||
achievements: [
|
||||
t("jobs.enterprise.achievements.0"),
|
||||
t("jobs.enterprise.achievements.1"),
|
||||
t("jobs.enterprise.achievements.2"),
|
||||
],
|
||||
icon: <Rocket size={20} />,
|
||||
},
|
||||
{
|
||||
year: t("jobs.digital.year"),
|
||||
title: t("jobs.digital.title"),
|
||||
company: t("jobs.digital.company"),
|
||||
description: t("jobs.digital.description"),
|
||||
achievements: [
|
||||
t("jobs.digital.achievements.0"),
|
||||
t("jobs.digital.achievements.1"),
|
||||
t("jobs.digital.achievements.2"),
|
||||
],
|
||||
icon: <Code2 size={20} />,
|
||||
},
|
||||
{
|
||||
year: t("jobs.fintech.year"),
|
||||
title: t("jobs.fintech.title"),
|
||||
company: t("jobs.fintech.company"),
|
||||
description: t("jobs.fintech.description"),
|
||||
achievements: [
|
||||
t("jobs.fintech.achievements.0"),
|
||||
t("jobs.fintech.achievements.1"),
|
||||
t("jobs.fintech.achievements.2"),
|
||||
],
|
||||
icon: <Building2 size={20} />,
|
||||
},
|
||||
const ICONS = [
|
||||
<Rocket key="rocket" size={20} />,
|
||||
<Code2 key="code2" size={20} />,
|
||||
<Building2 key="building2" size={20} />,
|
||||
<Briefcase key="briefcase" size={20} />,
|
||||
];
|
||||
|
||||
export async function ExperienceSection() {
|
||||
const t = await getTranslations("Experience");
|
||||
|
||||
// Fetch from DB, fallback gracefully if empty
|
||||
const dbExperiences = await prisma.experience.findMany({
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
|
||||
// If DB has data, use it — otherwise show empty state
|
||||
const timelineData = dbExperiences.map((exp, i) => ({
|
||||
id: exp.id,
|
||||
year: exp.year,
|
||||
title: exp.title,
|
||||
company: exp.company,
|
||||
description: exp.description,
|
||||
achievements: Array.isArray(exp.achievements)
|
||||
? (exp.achievements as string[])
|
||||
: [],
|
||||
icon: ICONS[i % ICONS.length],
|
||||
}));
|
||||
|
||||
return (
|
||||
<section id="experience" className="section-padding relative">
|
||||
<div className="max-w-5xl mx-auto px-6">
|
||||
<AnimatedSection>
|
||||
<SectionHeading
|
||||
badge={t("badge")}
|
||||
title={t("title")}
|
||||
subtitle={t("subtitle")}
|
||||
/>
|
||||
</AnimatedSection>
|
||||
|
||||
{/* Timeline */}
|
||||
{timelineData.length === 0 ? (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<Briefcase size={40} className="mx-auto mb-4 opacity-30" />
|
||||
<p className="text-sm">
|
||||
Belum ada pengalaman kerja. Tambahkan melalui{" "}
|
||||
<span className="font-mono text-accent">Admin Dashboard</span>.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
/* Timeline */
|
||||
<div className="relative">
|
||||
{/* Vertical line */}
|
||||
<div className="timeline-line" />
|
||||
|
||||
<div className="space-y-12 md:space-y-16">
|
||||
{timelineData.map((item, index) => (
|
||||
<AnimatedSection key={item.year} delay={index * 0.15}>
|
||||
<AnimatedSection key={item.id} delay={index * 0.15}>
|
||||
<div
|
||||
className={`relative flex flex-col md:flex-row items-start gap-6 md:gap-12 ${
|
||||
className={`relative flex flex-col md:flex-row items-start gap-8 md:gap-24 ${
|
||||
index % 2 === 0 ? "md:flex-row" : "md:flex-row-reverse"
|
||||
}`}
|
||||
>
|
||||
{/* Content */}
|
||||
<div
|
||||
className={`flex-1 ml-12 md:ml-0 ${
|
||||
className={`flex-1 ml-20 md:ml-0 ${
|
||||
index % 2 === 0 ? "md:text-right" : "md:text-left"
|
||||
}`}
|
||||
>
|
||||
@@ -81,13 +74,16 @@ export function ExperienceSection() {
|
||||
{item.year}
|
||||
</span>
|
||||
<h3 className="text-xl font-bold mb-1">{item.title}</h3>
|
||||
<p className="text-muted-foreground text-sm font-medium mb-3 flex items-center gap-1.5 md:justify-start">
|
||||
<p className={`text-muted-foreground text-sm font-medium mb-3 flex items-center gap-1.5 ${
|
||||
index % 2 === 0 ? "md:justify-end" : "md:justify-start"
|
||||
}`}>
|
||||
<Briefcase size={14} className="text-accent/60" />
|
||||
{item.company}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed mb-4">
|
||||
{item.description}
|
||||
</p>
|
||||
{item.achievements.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{item.achievements.map((achievement) => (
|
||||
<div
|
||||
@@ -108,10 +104,11 @@ export function ExperienceSection() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Center dot */}
|
||||
<div className="absolute left-0 md:left-1/2 md:-translate-x-1/2 w-12 h-12 rounded-xl bg-card border-2 border-accent/30 flex items-center justify-center text-accent shadow-lg shadow-accent/10 z-10">
|
||||
{/* Center dot — aligned with timeline-line */}
|
||||
<div className="absolute left-0 md:left-1/2 md:-translate-x-1/2 top-0 w-12 h-12 rounded-xl bg-card border-2 border-accent/30 flex items-center justify-center text-accent shadow-lg shadow-accent/10 z-10">
|
||||
{item.icon}
|
||||
</div>
|
||||
|
||||
@@ -122,6 +119,7 @@ export function ExperienceSection() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -2,17 +2,59 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion, PanInfo } from "framer-motion";
|
||||
import { ArrowDown, FileText, Send, Hand } from "lucide-react";
|
||||
import { ArrowDown, Download } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const PROFILE_IMAGES = [
|
||||
"https://images.unsplash.com/photo-1556157382-97eda2d62296?w=600&auto=format&fit=crop&q=80",
|
||||
"https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=600&auto=format&fit=crop&q=80",
|
||||
"https://images.unsplash.com/photo-1605379399642-870262d3d051?w=600&auto=format&fit=crop&q=80",
|
||||
"/brand/foto-1.jpg",
|
||||
"/brand/foto-2.jpeg",
|
||||
"/brand/foto-3.jpeg",
|
||||
];
|
||||
|
||||
const SOCIAL_LINKS = [
|
||||
{
|
||||
name: "LinkedIn",
|
||||
href: "https://www.linkedin.com/in/yolando-asri-e-g-manullang/",
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Instagram",
|
||||
href: "https://instagram.com/yolando_20",
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "WhatsApp",
|
||||
href: "https://wa.me/6282267852521",
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Email",
|
||||
href: "mailto:yolandomanullang@gmail.com",
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect width="20" height="16" x="2" y="4" rx="2" />
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
export function HeroSection() {
|
||||
const t = useTranslations("Hero");
|
||||
const tTech = useTranslations("TechStack");
|
||||
const [cards, setCards] = useState(PROFILE_IMAGES);
|
||||
|
||||
const handleDragEnd = (event: any, info: PanInfo) => {
|
||||
@@ -30,96 +72,151 @@ export function HeroSection() {
|
||||
return (
|
||||
<section
|
||||
id="hero"
|
||||
className="relative min-h-screen flex items-center justify-center overflow-hidden pt-20"
|
||||
className="relative min-h-screen flex items-center justify-center overflow-hidden pt-20 hero-gradient-bg"
|
||||
>
|
||||
<div className="absolute inset-0 grid-pattern opacity-30" />
|
||||
|
||||
{/* Decorative Blur Backgrounds */}
|
||||
<div className="absolute top-1/4 -left-32 w-96 h-96 rounded-full bg-accent/20 blur-[120px] animate-pulse-glow" />
|
||||
<div
|
||||
className="absolute bottom-1/4 -right-32 w-96 h-96 rounded-full bg-purple-500/15 blur-[120px] animate-pulse-glow"
|
||||
style={{ animationDelay: "2s" }}
|
||||
{/* Subtle ambient light blobs - organic, not grid lines */}
|
||||
<div className="absolute top-0 left-0 right-0 bottom-0 pointer-events-none overflow-hidden">
|
||||
<motion.div
|
||||
animate={{
|
||||
x: [0, 30, -20, 0],
|
||||
y: [0, -40, 20, 0],
|
||||
}}
|
||||
transition={{ duration: 20, repeat: Infinity, ease: "linear" }}
|
||||
className="absolute top-[10%] left-[5%] w-[500px] h-[500px] rounded-full bg-accent/[0.07] dark:bg-accent/[0.15] blur-[100px]"
|
||||
/>
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] rounded-full bg-indigo-500/5 blur-[100px]" />
|
||||
<motion.div
|
||||
animate={{
|
||||
x: [0, -25, 15, 0],
|
||||
y: [0, 30, -25, 0],
|
||||
}}
|
||||
transition={{ duration: 25, repeat: Infinity, ease: "linear" }}
|
||||
className="absolute bottom-[10%] right-[5%] w-[450px] h-[450px] rounded-full bg-purple-500/[0.05] dark:bg-purple-500/[0.12] blur-[100px]"
|
||||
/>
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] rounded-full bg-indigo-400/[0.03] dark:bg-indigo-500/[0.05] blur-[120px]" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 w-full max-w-6xl mx-auto px-6">
|
||||
<div className="grid lg:grid-cols-2 gap-12 lg:gap-8 items-center">
|
||||
<div className="grid lg:grid-cols-[1.1fr_0.9fr] gap-12 lg:gap-8 items-center">
|
||||
|
||||
{/* LEFT: Text Content */}
|
||||
<div className="text-center lg:text-left flex flex-col items-center lg:items-start order-2 lg:order-1">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
{/* Greeting - ekspresif & casual */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="flex items-center gap-3 mb-4"
|
||||
className="text-lg sm:text-xl font-bold text-accent mb-3 tracking-wide"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 px-4 py-2 rounded-full text-xs font-mono font-medium bg-success/10 text-success border border-success/20">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-success opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-success" />
|
||||
</span>
|
||||
{t("badge")}
|
||||
{t("greeting")} 👋
|
||||
</motion.p>
|
||||
|
||||
{/* Name Heading */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.7, delay: 0.3 }}
|
||||
className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight mb-2"
|
||||
>
|
||||
{t("iAm")}
|
||||
<span className="gradient-text">{t("name")}</span>
|
||||
</motion.h1>
|
||||
|
||||
{/* Role / Subtitle */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.45 }}
|
||||
className="flex flex-wrap items-center gap-2 mb-6"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold border border-accent/40 text-accent bg-accent/10 tracking-wide">
|
||||
{t("role")}
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, delay: 0.3 }}
|
||||
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold tracking-tight leading-[1.1] mb-6"
|
||||
>
|
||||
<span className="block text-2xl sm:text-3xl lg:text-4xl text-muted-foreground font-medium mb-2">
|
||||
{t("greeting")}{" "}
|
||||
<span className="text-foreground gradient-text inline-block">{t("name")}</span>
|
||||
</span>
|
||||
{t("titlePart1")}{" "}
|
||||
<span className="gradient-text whitespace-nowrap">{t("titleHighlight")}</span>
|
||||
<br />
|
||||
{t("titlePart2")}
|
||||
</motion.h1>
|
||||
{/* Horizontal Divider */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scaleX: 0 }}
|
||||
animate={{ opacity: 1, scaleX: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.55 }}
|
||||
className="w-16 h-0.5 bg-accent/60 mb-6 origin-left"
|
||||
/>
|
||||
|
||||
{/* Description */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, delay: 0.5 }}
|
||||
className="text-lg text-muted-foreground mb-10 leading-relaxed max-w-xl"
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.7, delay: 0.6 }}
|
||||
className="text-sm text-muted-foreground font-normal leading-relaxed max-w-md mb-10"
|
||||
>
|
||||
<span className="font-mono text-accent font-semibold">
|
||||
{t("yearsExp")}
|
||||
</span>{" "}
|
||||
{t("subtitle")}
|
||||
{t("description")}
|
||||
</motion.p>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, delay: 0.7 }}
|
||||
transition={{ duration: 0.7, delay: 0.75 }}
|
||||
className="flex flex-col sm:flex-row items-center gap-4 w-full sm:w-auto"
|
||||
>
|
||||
<a
|
||||
href="#contact"
|
||||
className="w-full sm:w-auto group inline-flex items-center justify-center gap-2 px-7 py-3.5 rounded-xl bg-gradient-to-r from-accent to-purple-500 text-white font-semibold text-sm shadow-lg shadow-accent/25 hover:shadow-accent/40 hover:scale-105 transition-all duration-300"
|
||||
href="/cv.pdf"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full sm:w-auto group inline-flex items-center justify-center gap-2 px-7 py-3 rounded-full bg-gradient-to-r from-accent to-purple-500 text-white font-semibold text-sm shadow-lg shadow-accent/25 hover:shadow-accent/40 hover:scale-105 transition-all duration-300"
|
||||
>
|
||||
<Send size={16} />
|
||||
{t("ctaContact")}
|
||||
<Download size={16} />
|
||||
{t("ctaDownloadCV")}
|
||||
</a>
|
||||
<a
|
||||
href="#projects"
|
||||
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-7 py-3.5 rounded-xl font-semibold text-sm border border-border hover:bg-muted/50 transition-all duration-300 hover:scale-105"
|
||||
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-10 py-3 rounded-full font-semibold text-sm border border-border hover:bg-muted/50 transition-all duration-300 hover:scale-105"
|
||||
>
|
||||
<FileText size={16} />
|
||||
{t("ctaProjects")}
|
||||
{t("ctaMore")}
|
||||
</a>
|
||||
</motion.div>
|
||||
|
||||
{/* Tech Stack */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, delay: 0.9 }}
|
||||
className="mt-12 flex flex-col items-center lg:items-start gap-5 w-full relative z-30 pointer-events-auto"
|
||||
>
|
||||
<p className="text-sm font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{tTech("title")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-4 md:gap-5 w-full justify-center lg:justify-start items-center">
|
||||
{[
|
||||
{ src: "https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/java/java-original.svg", alt: "Java" },
|
||||
{ src: "https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/spring/spring-original.svg", alt: "Spring Boot" },
|
||||
{ src: "https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/oracle/oracle-original.svg", alt: "Oracle" },
|
||||
{ src: "https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/docker/docker-original.svg", alt: "Docker" },
|
||||
].map((icon) => (
|
||||
<img key={icon.alt} src={icon.src} alt={icon.alt} title={icon.alt} className="w-9 h-9 md:w-11 md:h-11 opacity-60 grayscale transition-all duration-300 hover:opacity-100 hover:grayscale-0 hover:scale-125 hover:-translate-y-2 cursor-pointer drop-shadow-sm hover:drop-shadow-xl" />
|
||||
))}
|
||||
<a
|
||||
href="#tech-stack"
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-full text-xs font-semibold border border-accent/40 text-accent bg-accent/10 hover:bg-accent/20 hover:border-accent/70 transition-all duration-300 hover:scale-105 whitespace-nowrap"
|
||||
>
|
||||
{tTech("seeMore")}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT: Swipeable Card Deck */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
initial={{ opacity: 0, x: 40 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.4 }}
|
||||
className="order-1 lg:order-2 flex justify-center lg:justify-end relative w-full perspective-1000 h-[380px] sm:h-[450px]"
|
||||
>
|
||||
{/* Soft glow behind card deck */}
|
||||
<div className="absolute -bottom-10 left-1/2 -translate-x-1/2 w-72 h-48 rounded-full bg-accent/[0.08] dark:bg-accent/[0.15] blur-[80px] pointer-events-none" />
|
||||
|
||||
<div className="relative w-full max-w-[280px] sm:max-w-[320px] h-full mx-auto lg:mx-0 lg:mr-8 xl:mr-16">
|
||||
{cards.map((imgUrl, index) => {
|
||||
const isTop = index === 0;
|
||||
@@ -127,7 +224,7 @@ export function HeroSection() {
|
||||
return (
|
||||
<motion.div
|
||||
key={imgUrl}
|
||||
className="absolute inset-0 rounded-3xl overflow-hidden shadow-2xl border border-white/10 glass cursor-grab active:cursor-grabbing bg-card"
|
||||
className="absolute inset-0 rounded-3xl overflow-hidden shadow-2xl border border-white/10 dark:border-white/10 border-black/[0.04] glass cursor-grab active:cursor-grabbing bg-card"
|
||||
style={{
|
||||
transformOrigin: "bottom center",
|
||||
zIndex: cards.length - index,
|
||||
@@ -173,8 +270,38 @@ export function HeroSection() {
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Social Media Bar */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, delay: 1.2 }}
|
||||
className="mt-16 flex flex-col sm:flex-row items-center justify-end gap-4 sm:gap-6"
|
||||
>
|
||||
<span className="text-sm font-semibold text-muted-foreground tracking-wide">
|
||||
{t("findMeOn")}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{SOCIAL_LINKS.map((social, i) => (
|
||||
<motion.a
|
||||
key={social.name}
|
||||
href={social.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 1.3 + i * 0.1 }}
|
||||
className="w-10 h-10 rounded-full border border-border/60 flex items-center justify-center text-muted-foreground hover:border-accent hover:text-accent hover:bg-accent/10 hover:scale-110 transition-all duration-300"
|
||||
aria-label={social.name}
|
||||
>
|
||||
{social.icon}
|
||||
</motion.a>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Scroll Indicator */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -183,14 +310,15 @@ export function HeroSection() {
|
||||
>
|
||||
<a
|
||||
href="#experience"
|
||||
className="flex flex-col items-center gap-2 text-muted-foreground hover:text-accent transition-colors"
|
||||
className="flex flex-col items-center gap-3 text-muted-foreground hover:text-accent transition-colors group"
|
||||
>
|
||||
<span className="text-xs font-mono">{t("scroll")}</span>
|
||||
<span className="text-xs font-mono tracking-wider uppercase">{t("scroll")}</span>
|
||||
<motion.div
|
||||
animate={{ y: [0, 8, 0] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity }}
|
||||
className="w-10 h-10 rounded-full border-2 border-accent/30 flex items-center justify-center scroll-indicator-ring group-hover:border-accent/60 transition-colors"
|
||||
>
|
||||
<ArrowDown size={16} />
|
||||
<ArrowDown size={16} className="text-accent" />
|
||||
</motion.div>
|
||||
</a>
|
||||
</motion.div>
|
||||
|
||||
70
src/features/messages/actions.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/core/db/prisma";
|
||||
import { verifySession } from "@/core/security/session";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
|
||||
const contactSchema = z.object({
|
||||
senderName: z.string().min(1, "Nama harus diisi"),
|
||||
senderEmail: z.string().email("Email tidak valid"),
|
||||
content: z.string().min(5, "Pesan minimal 5 karakter"),
|
||||
});
|
||||
|
||||
// Public action — no auth required
|
||||
export async function sendMessageAction(prevState: any, formData: FormData) {
|
||||
const data = {
|
||||
senderName: formData.get("name") as string,
|
||||
senderEmail: formData.get("email") as string,
|
||||
content: formData.get("message") as string,
|
||||
};
|
||||
|
||||
const validation = contactSchema.safeParse(data);
|
||||
if (!validation.success) {
|
||||
return { success: false, message: validation.error.issues[0].message };
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.message.create({
|
||||
data: {
|
||||
senderName: validation.data.senderName,
|
||||
senderEmail: validation.data.senderEmail,
|
||||
content: validation.data.content,
|
||||
},
|
||||
});
|
||||
revalidatePath("/admin/dashboard/inbox");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return { success: false, message: "Gagal mengirim pesan. Coba lagi." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function markMessageAsReadAction(id: string) {
|
||||
const session = await verifySession();
|
||||
if (!session) return { success: false, message: "Unauthorized" };
|
||||
|
||||
try {
|
||||
await prisma.message.update({
|
||||
where: { id },
|
||||
data: { isRead: true },
|
||||
});
|
||||
revalidatePath("/admin/dashboard/inbox");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, message: "Failed to mark as read" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMessageAction(id: string) {
|
||||
const session = await verifySession();
|
||||
if (!session) return { success: false, message: "Unauthorized" };
|
||||
|
||||
try {
|
||||
await prisma.message.delete({ where: { id } });
|
||||
revalidatePath("/admin/dashboard/inbox");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, message: "Failed to delete message" };
|
||||
}
|
||||
}
|
||||
@@ -3,47 +3,150 @@
|
||||
import { useState } from "react";
|
||||
import { AnimatedSection } from "@/shared/components/animated-section";
|
||||
import { SectionHeading } from "@/shared/components/section-heading";
|
||||
import { Send, Mail, User, MessageSquare, CheckCircle, Loader2 } from "lucide-react";
|
||||
import {
|
||||
Send,
|
||||
Mail,
|
||||
User,
|
||||
MessageSquare,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
MapPin,
|
||||
Clock,
|
||||
Sparkles,
|
||||
MessageCircle,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { sendMessageAction } from "./actions";
|
||||
|
||||
export function ContactSection() {
|
||||
const t = useTranslations("Contact");
|
||||
const [formState, setFormState] = useState<"idle" | "loading" | "success">("idle");
|
||||
const [formState, setFormState] = useState<
|
||||
"idle" | "loading" | "success" | "error"
|
||||
>("idle");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
message: "",
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setFormState("loading");
|
||||
setErrorMessage(null);
|
||||
|
||||
// Simulate submission (will connect to Server Action in Phase 2)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
const fd = new FormData(e.currentTarget);
|
||||
const result = await sendMessageAction(null, fd);
|
||||
|
||||
if (result.success) {
|
||||
setFormState("success");
|
||||
|
||||
setTimeout(() => {
|
||||
setFormState("idle");
|
||||
setFormData({ name: "", email: "", message: "" });
|
||||
}, 3000);
|
||||
setTimeout(() => setFormState("idle"), 4000);
|
||||
} else {
|
||||
setFormState("error");
|
||||
setErrorMessage(result.message || "Terjadi kesalahan.");
|
||||
setTimeout(() => setFormState("idle"), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="contact" className="section-padding relative bg-muted/30">
|
||||
<div className="absolute inset-0 grid-pattern opacity-20" />
|
||||
|
||||
<div className="relative max-w-4xl mx-auto px-6">
|
||||
<div className="relative max-w-5xl mx-auto px-6">
|
||||
<AnimatedSection>
|
||||
<SectionHeading
|
||||
badge={t("badge")}
|
||||
title={t("title")}
|
||||
subtitle={t("subtitle")}
|
||||
/>
|
||||
</AnimatedSection>
|
||||
|
||||
<AnimatedSection delay={0.2}>
|
||||
<div className="relative p-8 md:p-10 rounded-2xl bg-card border border-border/50 shadow-lg">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-8">
|
||||
{/* Left — Contact Info */}
|
||||
<div className="lg:col-span-2 space-y-8">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold mb-4">{t("info.title")}</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{t("info.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2.5 rounded-xl bg-accent/10 text-accent flex-shrink-0">
|
||||
<Mail size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-0.5">{t("info.email")}</p>
|
||||
<p className="text-sm text-muted-foreground font-mono">
|
||||
yolandomanullang@gmail.com
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2.5 rounded-xl bg-emerald-500/10 text-emerald-500 flex-shrink-0">
|
||||
<MapPin size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-0.5">{t("info.location")}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("info.locationValue")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2.5 rounded-xl bg-violet-500/10 text-violet-500 flex-shrink-0">
|
||||
<Clock size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-0.5">{t("info.responseTime")}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("info.responseTimeValue")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* WhatsApp */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2.5 rounded-xl bg-green-500/10 text-green-500 flex-shrink-0">
|
||||
<MessageCircle size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-0.5 flex items-center gap-2">
|
||||
WhatsApp
|
||||
<span className="px-2 py-0.5 rounded-md bg-green-500/10 text-green-500 text-[10px] uppercase font-bold tracking-wider">
|
||||
{t("info.fastResponse", { fallback: "Fast Response" })}
|
||||
</span>
|
||||
</p>
|
||||
<a
|
||||
href={`https://wa.me/6282267852521?text=${encodeURIComponent(t("info.whatsappTemplate", { fallback: "Halo Yolando," }))}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-muted-foreground font-mono hover:text-green-500 transition-colors"
|
||||
>
|
||||
+62 822-6785-2521
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative card */}
|
||||
<div className="hidden lg:block p-5 rounded-2xl border border-border/50 bg-card/50">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Sparkles size={16} className="text-accent" />
|
||||
<span className="text-xs font-mono font-bold text-accent uppercase tracking-wider">
|
||||
{t("info.openToWork")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("info.openToWorkDesc")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right — Form */}
|
||||
<div className="lg:col-span-3 relative p-6 md:p-8 rounded-2xl bg-card border border-border/50 shadow-lg">
|
||||
{/* Success overlay */}
|
||||
{formState === "success" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-card/95 rounded-2xl z-10">
|
||||
@@ -51,7 +154,9 @@ export function ContactSection() {
|
||||
<div className="w-16 h-16 rounded-full bg-success/10 flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle size={32} className="text-success" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-2">{t("form.successTitle")}</h3>
|
||||
<h3 className="text-xl font-bold mb-2">
|
||||
{t("form.successTitle")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("form.successDesc")}
|
||||
</p>
|
||||
@@ -59,8 +164,23 @@ export function ContactSection() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Error notification */}
|
||||
{formState === "error" && errorMessage && (
|
||||
<div className="mb-4 flex items-center gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-500 text-sm font-medium">
|
||||
<AlertCircle size={16} className="flex-shrink-0" />
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xl md:text-2xl font-bold mb-2">{t("form.heading")}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("form.subheading")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
@@ -72,14 +192,18 @@ export function ContactSection() {
|
||||
</label>
|
||||
<input
|
||||
id="contact-name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, name: e.target.value }))
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
name: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={t("form.namePlaceholder")}
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/50 border border-border/50 text-sm placeholder:text-muted-foreground/50 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent/50 transition-all"
|
||||
className="w-full px-4 py-3 rounded-xl bg-background/50 border border-border hover:border-accent/40 text-sm placeholder:text-muted-foreground/40 focus:outline-none focus:ring-4 focus:ring-accent/10 focus:border-accent transition-all duration-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -94,14 +218,18 @@ export function ContactSection() {
|
||||
</label>
|
||||
<input
|
||||
id="contact-email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, email: e.target.value }))
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
email: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={t("form.emailPlaceholder")}
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/50 border border-border/50 text-sm placeholder:text-muted-foreground/50 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent/50 transition-all"
|
||||
className="w-full px-4 py-3 rounded-xl bg-background/50 border border-border hover:border-accent/40 text-sm placeholder:text-muted-foreground/40 focus:outline-none focus:ring-4 focus:ring-accent/10 focus:border-accent transition-all duration-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,14 +245,18 @@ export function ContactSection() {
|
||||
</label>
|
||||
<textarea
|
||||
id="contact-message"
|
||||
name="message"
|
||||
required
|
||||
rows={5}
|
||||
value={formData.message}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, message: e.target.value }))
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
message: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={t("form.messagePlaceholder")}
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/50 border border-border/50 text-sm placeholder:text-muted-foreground/50 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent/50 transition-all resize-none"
|
||||
className="w-full px-4 py-3 rounded-xl bg-background/50 border border-border hover:border-accent/40 text-sm placeholder:text-muted-foreground/40 focus:outline-none focus:ring-4 focus:ring-accent/10 focus:border-accent transition-all duration-300 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -132,7 +264,7 @@ export function ContactSection() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={formState === "loading"}
|
||||
className="w-full md:w-auto inline-flex items-center justify-center gap-2 px-8 py-3.5 rounded-xl bg-gradient-to-r from-accent to-purple-500 text-white font-semibold text-sm shadow-lg shadow-accent/25 hover:shadow-accent/40 hover:scale-[1.02] transition-all duration-300 disabled:opacity-60 disabled:cursor-not-allowed disabled:hover:scale-100"
|
||||
className="w-full inline-flex items-center justify-center gap-2 px-8 py-3.5 rounded-xl bg-gradient-to-r from-accent to-purple-500 text-white font-semibold text-sm shadow-lg shadow-accent/25 hover:shadow-accent/40 hover:scale-[1.02] transition-all duration-300 disabled:opacity-60 disabled:cursor-not-allowed disabled:hover:scale-100"
|
||||
>
|
||||
{formState === "loading" ? (
|
||||
<>
|
||||
@@ -148,6 +280,7 @@ export function ContactSection() {
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedSection>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
52
src/features/messages/message-actions.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { markMessageAsReadAction, deleteMessageAction } from "./actions";
|
||||
import { Trash2, Loader2, MailCheck } from "lucide-react";
|
||||
import { useRouter } from "@/i18n/routing";
|
||||
|
||||
export function MarkReadButton({ id }: { id: string }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
async function handleMark() {
|
||||
setLoading(true);
|
||||
await markMessageAsReadAction(id);
|
||||
router.refresh();
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleMark}
|
||||
disabled={loading}
|
||||
className="p-2 text-muted-foreground hover:text-blue-500 hover:bg-blue-500/10 rounded-lg transition-colors disabled:opacity-50"
|
||||
title="Mark as read"
|
||||
>
|
||||
{loading ? <Loader2 size={16} className="animate-spin" /> : <MailCheck size={16} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeleteMessageButton({ id }: { id: string }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleDelete() {
|
||||
if (confirm("Delete this message permanently?")) {
|
||||
setLoading(true);
|
||||
await deleteMessageAction(id);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
className="p-2 text-muted-foreground hover:text-red-500 hover:bg-red-500/10 rounded-lg transition-colors disabled:opacity-50"
|
||||
title="Delete message"
|
||||
>
|
||||
{loading ? <Loader2 size={16} className="animate-spin" /> : <Trash2 size={16} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
168
src/features/projects/actions.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/core/db/prisma";
|
||||
import { uploadFileToMinio } from "@/core/storage/minio";
|
||||
import { verifySession } from "@/core/security/session";
|
||||
import { projectSchema } from "./project-schema";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function createProjectAction(prevState: any, formData: FormData) {
|
||||
// 1. Verify Authentication
|
||||
const session = await verifySession();
|
||||
if (!session) return { success: false, message: "Unauthorized" };
|
||||
|
||||
// 2. Extract and Validate Form Data
|
||||
const data = {
|
||||
title: formData.get("title") as string,
|
||||
slug: formData.get("slug") as string,
|
||||
description: formData.get("description") as string,
|
||||
category: formData.get("category") as string,
|
||||
repoUrl: formData.get("repoUrl") as string,
|
||||
liveUrl: formData.get("liveUrl") as string,
|
||||
isPublished: formData.get("isPublished") === "on",
|
||||
image: formData.get("image") as File | null,
|
||||
techStack: formData.get("techStack") as string,
|
||||
year: formData.get("year") as string,
|
||||
highlights: formData.get("highlights") as string,
|
||||
};
|
||||
|
||||
const validation = projectSchema.safeParse(data);
|
||||
if (!validation.success) {
|
||||
return { success: false, message: validation.error.issues[0].message };
|
||||
}
|
||||
|
||||
// 3. Process Image Upload
|
||||
let imageUrl: string | undefined = undefined;
|
||||
if (data.image && data.image.size > 0 && data.image.name) {
|
||||
try {
|
||||
imageUrl = await uploadFileToMinio(data.image);
|
||||
} catch (e) {
|
||||
console.error("Image upload failed:", e);
|
||||
return { success: false, message: "Failed to upload image. Ensure MinIO is running and bucket exists." };
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Save to Database
|
||||
try {
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
title: validation.data.title,
|
||||
slug: validation.data.slug,
|
||||
description: validation.data.description,
|
||||
category: validation.data.category,
|
||||
repoUrl: validation.data.repoUrl || null,
|
||||
liveUrl: validation.data.liveUrl || null,
|
||||
isPublished: validation.data.isPublished,
|
||||
imageUrl: imageUrl,
|
||||
techStack: validation.data.techStack ? validation.data.techStack.split(",").map(s => s.trim()).filter(s => s) : [],
|
||||
year: validation.data.year || null,
|
||||
highlights: validation.data.highlights ? validation.data.highlights.split(",").map(s => s.trim()).filter(s => s) : [],
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/admin/dashboard");
|
||||
revalidatePath("/");
|
||||
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
console.error("DB Error:", error);
|
||||
if (error?.code === "P2002") {
|
||||
return { success: false, message: "Project slug already exists" };
|
||||
}
|
||||
return { success: false, message: "Failed to create project" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProjectAction(id: string, prevState: any, formData: FormData) {
|
||||
const session = await verifySession();
|
||||
if (!session) return { success: false, message: "Unauthorized" };
|
||||
|
||||
const data = {
|
||||
title: formData.get("title") as string,
|
||||
slug: formData.get("slug") as string,
|
||||
description: formData.get("description") as string,
|
||||
category: formData.get("category") as string,
|
||||
repoUrl: formData.get("repoUrl") as string,
|
||||
liveUrl: formData.get("liveUrl") as string,
|
||||
isPublished: formData.get("isPublished") === "on",
|
||||
image: formData.get("image") as File | null,
|
||||
techStack: formData.get("techStack") as string,
|
||||
year: formData.get("year") as string,
|
||||
highlights: formData.get("highlights") as string,
|
||||
};
|
||||
|
||||
const validation = projectSchema.safeParse(data);
|
||||
if (!validation.success) {
|
||||
return { success: false, message: validation.error.issues[0].message };
|
||||
}
|
||||
|
||||
let imageUrl: string | undefined = undefined;
|
||||
if (data.image && data.image.size > 0 && data.image.name) {
|
||||
try {
|
||||
imageUrl = await uploadFileToMinio(data.image);
|
||||
} catch (e) {
|
||||
return { success: false, message: "Failed to upload new image." };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.project.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title: validation.data.title,
|
||||
slug: validation.data.slug,
|
||||
description: validation.data.description,
|
||||
category: validation.data.category,
|
||||
repoUrl: validation.data.repoUrl || null,
|
||||
liveUrl: validation.data.liveUrl || null,
|
||||
isPublished: validation.data.isPublished,
|
||||
...(imageUrl && { imageUrl }), // only update image if a new one was uploaded
|
||||
techStack: validation.data.techStack ? validation.data.techStack.split(",").map(s => s.trim()).filter(s => s) : [],
|
||||
year: validation.data.year || null,
|
||||
highlights: validation.data.highlights ? validation.data.highlights.split(",").map(s => s.trim()).filter(s => s) : [],
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/admin/dashboard");
|
||||
revalidatePath("/admin/dashboard/projects");
|
||||
revalidatePath("/");
|
||||
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
if (error?.code === "P2002") {
|
||||
return { success: false, message: "Project slug already exists" };
|
||||
}
|
||||
return { success: false, message: "Failed to update project" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteProjectAction(id: string) {
|
||||
const session = await verifySession();
|
||||
if (!session) return { success: false, message: "Unauthorized" };
|
||||
|
||||
try {
|
||||
await prisma.project.delete({ where: { id } });
|
||||
revalidatePath("/admin/dashboard");
|
||||
revalidatePath("/");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, message: "Failed to delete project" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleProjectPublishAction(id: string, isPublished: boolean) {
|
||||
const session = await verifySession();
|
||||
if (!session) return { success: false, message: "Unauthorized" };
|
||||
|
||||
try {
|
||||
await prisma.project.update({
|
||||
where: { id },
|
||||
data: { isPublished: !isPublished },
|
||||
});
|
||||
revalidatePath("/admin/dashboard");
|
||||
revalidatePath("/");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, message: "Failed to update project status" };
|
||||
}
|
||||
}
|
||||
28
src/features/projects/delete-button.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { deleteProjectAction } from "./actions";
|
||||
import { Trash2, Loader2 } from "lucide-react";
|
||||
|
||||
export function DeleteProjectButton({ id }: { id: string }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleDelete() {
|
||||
if (confirm("Are you sure you want to delete this project? This cannot be undone.")) {
|
||||
setLoading(true);
|
||||
await deleteProjectAction(id);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
className="p-2 text-muted-foreground hover:text-error hover:bg-error/10 rounded-lg transition-colors disabled:opacity-50"
|
||||
title="Delete Project"
|
||||
>
|
||||
{loading ? <Loader2 size={18} className="animate-spin" /> : <Trash2 size={18} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
265
src/features/projects/project-detail-modal.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
X,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
GitFork,
|
||||
Calendar,
|
||||
LayoutGrid,
|
||||
Award,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Images,
|
||||
} from "lucide-react";
|
||||
|
||||
export function ProjectDetailModal({ project, onClose }: { project: any; onClose: () => void }) {
|
||||
const techStack: string[] = project?.techStack || [];
|
||||
const highlights: string[] = project?.highlights || [];
|
||||
|
||||
// Build image gallery: prefer imageUrls array, fallback to single imageUrl
|
||||
const images: string[] =
|
||||
project?.imageUrls && project.imageUrls.length > 0
|
||||
? project.imageUrls
|
||||
: project?.imageUrl
|
||||
? [project.imageUrl]
|
||||
: [];
|
||||
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
|
||||
function prev() {
|
||||
setActiveIdx((i) => (i === 0 ? images.length - 1 : i - 1));
|
||||
}
|
||||
function next() {
|
||||
setActiveIdx((i) => (i === images.length - 1 ? 0 : i + 1));
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
className="fixed inset-0 z-50 bg-black/70 backdrop-blur-md flex items-center justify-center p-4 lg:p-8"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Modal container — wide 2-col layout */}
|
||||
<motion.div
|
||||
className="relative w-full max-w-6xl bg-card border border-border/50 rounded-[2rem] shadow-2xl overflow-hidden flex flex-col lg:flex-row"
|
||||
style={{ maxHeight: "92vh" }}
|
||||
initial={{ scale: 0.95, opacity: 0, y: 20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.95, opacity: 0, y: 20 }}
|
||||
transition={{ type: "spring", stiffness: 280, damping: 28 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* ── Close button ── */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 z-30 p-2.5 bg-black/40 hover:bg-black/70 backdrop-blur-md rounded-full text-white transition-all duration-300 hover:scale-110 shadow-lg"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
{/* ══════════════════════════════════════════
|
||||
LEFT COLUMN — Details (scrollable)
|
||||
══════════════════════════════════════════ */}
|
||||
<div className="flex flex-col lg:w-[42%] overflow-y-auto custom-scrollbar shrink-0">
|
||||
{/* Header */}
|
||||
<div className="px-7 pt-8 pb-5 border-b border-border/30">
|
||||
<h2 className="text-2xl md:text-3xl font-extrabold tracking-tight text-foreground leading-tight pr-10">
|
||||
{project.title}
|
||||
</h2>
|
||||
{project.year && (
|
||||
<div className="flex items-center gap-2 mt-3 text-sm font-medium text-muted-foreground">
|
||||
<Calendar size={14} className="text-accent" />
|
||||
<span>{project.year}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scrollable body */}
|
||||
<div className="flex-1 px-7 py-6 space-y-7">
|
||||
{/* Description */}
|
||||
<div>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{project.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tech Stack */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<LayoutGrid size={15} className="text-accent" />
|
||||
<h4 className="text-sm font-bold uppercase tracking-wider text-muted-foreground">Tech Stack</h4>
|
||||
</div>
|
||||
{techStack.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{techStack.map((tech: string) => (
|
||||
<span
|
||||
key={tech}
|
||||
className="px-3 py-1 rounded-lg text-xs font-semibold bg-accent/8 text-accent border border-accent/15 hover:border-accent/40 hover:bg-accent/15 transition-colors"
|
||||
>
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground italic">Belum ada tech stack.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Highlights */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Award size={15} className="text-emerald-500" />
|
||||
<h4 className="text-sm font-bold uppercase tracking-wider text-muted-foreground">Pencapaian</h4>
|
||||
</div>
|
||||
{highlights.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
{highlights.map((item: string, i: number) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex items-start gap-2.5 p-2.5 rounded-lg bg-emerald-500/5 border border-emerald-500/10 hover:bg-emerald-500/10 transition-colors"
|
||||
>
|
||||
<CheckCircle2 size={14} className="text-emerald-500 mt-0.5 shrink-0" />
|
||||
<span className="text-xs font-medium text-foreground/80">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground italic">Belum ada data pencapaian.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons — sticky at bottom */}
|
||||
<div className="px-7 py-5 border-t border-border/30 flex flex-wrap gap-3 bg-muted/10 shrink-0">
|
||||
{project.liveUrl && (
|
||||
<a
|
||||
href={project.liveUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 min-w-[120px] flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl bg-accent text-accent-foreground text-sm font-semibold shadow-lg shadow-accent/20 hover:shadow-xl hover:shadow-accent/40 hover:-translate-y-0.5 transition-all duration-300"
|
||||
>
|
||||
<ExternalLink size={15} /> Kunjungi Web
|
||||
</a>
|
||||
)}
|
||||
{project.repoUrl && (
|
||||
<a
|
||||
href={project.repoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 min-w-[120px] flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl bg-card border border-border/50 hover:border-border hover:bg-muted text-foreground text-sm font-semibold transition-all duration-300"
|
||||
>
|
||||
<GitFork size={15} /> Source Code
|
||||
</a>
|
||||
)}
|
||||
{!project.liveUrl && !project.repoUrl && (
|
||||
<p className="text-xs text-muted-foreground italic">Tidak ada link yang tersedia.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ══════════════════════════════════════════
|
||||
RIGHT COLUMN — Image Gallery
|
||||
══════════════════════════════════════════ */}
|
||||
<div className="flex-1 flex flex-col bg-black/20 min-h-[300px] lg:min-h-0 border-t lg:border-t-0 lg:border-l border-border/20">
|
||||
{images.length > 0 ? (
|
||||
<>
|
||||
{/* Main image viewer */}
|
||||
<div className="relative flex-1 flex items-center justify-center overflow-hidden bg-black/30">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.img
|
||||
key={activeIdx}
|
||||
src={images[activeIdx]}
|
||||
alt={`${project.title} — image ${activeIdx + 1}`}
|
||||
className="max-w-full max-h-full object-contain select-none"
|
||||
style={{ maxHeight: "calc(92vh - 120px)" }}
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -30 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
draggable={false}
|
||||
/>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Arrow navigation (only when multiple images) */}
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={prev}
|
||||
className="absolute left-3 p-2.5 bg-black/50 hover:bg-black/80 rounded-full text-white transition-all hover:scale-110 backdrop-blur-sm shadow-lg"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={next}
|
||||
className="absolute right-3 p-2.5 bg-black/50 hover:bg-black/80 rounded-full text-white transition-all hover:scale-110 backdrop-blur-sm shadow-lg"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Image counter badge */}
|
||||
{images.length > 1 && (
|
||||
<div className="absolute top-3 left-3 flex items-center gap-1.5 px-2.5 py-1 bg-black/50 backdrop-blur-sm rounded-full text-white text-xs font-semibold">
|
||||
<Images size={12} />
|
||||
{activeIdx + 1} / {images.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thumbnail strip (only when multiple images) */}
|
||||
{images.length > 1 && (
|
||||
<div className="flex gap-2 p-3 overflow-x-auto bg-black/40 shrink-0 custom-scrollbar">
|
||||
{images.map((url, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setActiveIdx(i)}
|
||||
className={`shrink-0 w-16 h-12 rounded-lg overflow-hidden border-2 transition-all hover:scale-105 ${
|
||||
i === activeIdx
|
||||
? "border-accent shadow-lg shadow-accent/30"
|
||||
: "border-white/10 opacity-60 hover:opacity-100"
|
||||
}`}
|
||||
>
|
||||
<img src={url} alt={`thumb-${i}`} className="w-full h-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dot indicators */}
|
||||
{images.length > 1 && (
|
||||
<div className="flex justify-center gap-1.5 py-2 bg-black/40 shrink-0">
|
||||
{images.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setActiveIdx(i)}
|
||||
className={`rounded-full transition-all ${
|
||||
i === activeIdx
|
||||
? "w-5 h-2 bg-accent"
|
||||
: "w-2 h-2 bg-white/30 hover:bg-white/60"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* No image placeholder */
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 text-muted-foreground/40 p-10">
|
||||
<LayoutGrid size={60} strokeWidth={1} />
|
||||
<p className="text-sm font-medium">Belum ada gambar</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
334
src/features/projects/project-form.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { createProjectAction, updateProjectAction } from "./actions";
|
||||
import { useRouter } from "@/i18n/routing";
|
||||
import { Loader2, Upload, PlusCircle, ArrowLeft, Save, X, ImagePlus } from "lucide-react";
|
||||
|
||||
export function ProjectForm({ initialData, projectId }: { initialData?: any; projectId?: string }) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Multiple images state
|
||||
// existingPreviews: URLs of images already saved (for edit mode)
|
||||
// newPreviews: blob URLs of newly selected files
|
||||
const [existingPreviews, setExistingPreviews] = useState<string[]>(
|
||||
initialData?.imageUrls && initialData.imageUrls.length > 0
|
||||
? initialData.imageUrls
|
||||
: initialData?.imageUrl
|
||||
? [initialData.imageUrl]
|
||||
: []
|
||||
);
|
||||
const [newFiles, setNewFiles] = useState<File[]>([]);
|
||||
const [newPreviews, setNewPreviews] = useState<string[]>([]);
|
||||
|
||||
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(e.target.files || []);
|
||||
const previews = files.map((f) => URL.createObjectURL(f));
|
||||
setNewFiles((prev) => [...prev, ...files]);
|
||||
setNewPreviews((prev) => [...prev, ...previews]);
|
||||
// Reset input so same files can be re-selected
|
||||
e.target.value = "";
|
||||
}
|
||||
|
||||
function removeExistingImage(idx: number) {
|
||||
setExistingPreviews((prev) => prev.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
function removeNewImage(idx: number) {
|
||||
URL.revokeObjectURL(newPreviews[idx]);
|
||||
setNewFiles((prev) => prev.filter((_, i) => i !== idx));
|
||||
setNewPreviews((prev) => prev.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
const allPreviews = [...existingPreviews, ...newPreviews];
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData(e.currentTarget);
|
||||
|
||||
// Remove the placeholder file inputs, we'll add manually
|
||||
formData.delete("images");
|
||||
|
||||
// Append existing image URLs that were kept
|
||||
existingPreviews.forEach((url) => formData.append("existingImages", url));
|
||||
|
||||
// Append new file objects
|
||||
newFiles.forEach((file) => formData.append("images", file));
|
||||
|
||||
const url = projectId ? `/api/admin/projects/${projectId}` : "/api/admin/projects";
|
||||
const method = projectId ? "PUT" : "POST";
|
||||
|
||||
const response = await fetch(url, { method, body: formData });
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
setError(result.message || "An error occurred");
|
||||
setLoading(false);
|
||||
} else {
|
||||
setLoading(false);
|
||||
router.refresh();
|
||||
router.push("/admin/dashboard/projects");
|
||||
}
|
||||
} catch (err) {
|
||||
setError("An unexpected error occurred during submission.");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-6 lg:p-10 rounded-3xl bg-card border border-border overflow-hidden relative shadow-sm">
|
||||
<div className="absolute top-0 inset-x-0 h-1 bg-gradient-to-r from-accent to-purple-500" />
|
||||
|
||||
<div className="mb-8 flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="p-2 rounded-full hover:bg-muted text-muted-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{projectId ? "Edit Project" : "Create New Project"}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{projectId ? "Update your portfolio case study" : "Add a new case study to your portfolio"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="p-4 rounded-xl bg-error/10 border border-error/20 text-error text-sm font-medium">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">Project Title</label>
|
||||
<input
|
||||
name="title"
|
||||
required
|
||||
defaultValue={initialData?.title}
|
||||
placeholder="Core Banking API"
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 transition-all font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">URL Slug</label>
|
||||
<input
|
||||
name="slug"
|
||||
required
|
||||
defaultValue={initialData?.slug}
|
||||
placeholder="core-banking-api"
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 transition-all font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">Category</label>
|
||||
<select
|
||||
name="category"
|
||||
required
|
||||
defaultValue={initialData?.category || "Enterprise Backend"}
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 transition-all font-mono cursor-pointer"
|
||||
>
|
||||
<option value="Enterprise Backend">Enterprise Backend</option>
|
||||
<option value="Frontend Development">Frontend Development</option>
|
||||
<option value="Mobile Development">Mobile Development</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">Description</label>
|
||||
<textarea
|
||||
name="description"
|
||||
required
|
||||
defaultValue={initialData?.description}
|
||||
rows={4}
|
||||
placeholder="High-performance API Gateway handling 500K+ daily transactions..."
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 transition-all font-mono resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">Live URL (Optional)</label>
|
||||
<input
|
||||
name="liveUrl"
|
||||
type="url"
|
||||
defaultValue={initialData?.liveUrl || ""}
|
||||
placeholder="https://example.com"
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 transition-all font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">Repository URL (Optional)</label>
|
||||
<input
|
||||
name="repoUrl"
|
||||
type="url"
|
||||
defaultValue={initialData?.repoUrl || ""}
|
||||
placeholder="https://github.com/..."
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 transition-all font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">Tech Stack</label>
|
||||
<input
|
||||
name="techStack"
|
||||
defaultValue={initialData?.techStack?.join(", ") || ""}
|
||||
placeholder="Java, Spring Boot, PostgreSQL, Docker"
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 transition-all font-mono"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Pisahkan setiap teknologi dengan koma</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">Tahun Pengerjaan</label>
|
||||
<input
|
||||
name="year"
|
||||
type="number"
|
||||
min="2000"
|
||||
max="2099"
|
||||
defaultValue={initialData?.year || new Date().getFullYear()}
|
||||
placeholder={new Date().getFullYear().toString()}
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 transition-all font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">Target & Pencapaian</label>
|
||||
<textarea
|
||||
name="highlights"
|
||||
defaultValue={initialData?.highlights?.join(", ") || ""}
|
||||
rows={3}
|
||||
placeholder="Reduce API latency 40%, Handle 10K daily users, 99.9% uptime"
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 transition-all font-mono"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Pisahkan setiap pencapaian dengan koma</p>
|
||||
</div>
|
||||
|
||||
{/* --- Multiple Image Upload --- */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-semibold">Project Images</label>
|
||||
<span className="text-xs text-muted-foreground">{allPreviews.length} gambar dipilih</span>
|
||||
</div>
|
||||
|
||||
{/* Grid preview */}
|
||||
{allPreviews.length > 0 && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{/* Existing images */}
|
||||
{existingPreviews.map((url, idx) => (
|
||||
<div key={`existing-${idx}`} className="relative group rounded-xl overflow-hidden border border-border aspect-video bg-muted/20">
|
||||
<img src={url} alt={`Image ${idx + 1}`} className="w-full h-full object-cover" />
|
||||
{idx === 0 && (
|
||||
<span className="absolute top-1.5 left-1.5 text-[10px] font-bold bg-accent text-accent-foreground px-2 py-0.5 rounded-full">
|
||||
Cover
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeExistingImage(idx)}
|
||||
className="absolute top-1.5 right-1.5 p-1 bg-black/60 hover:bg-red-600 rounded-full text-white opacity-0 group-hover:opacity-100 transition-all"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{/* New images */}
|
||||
{newPreviews.map((url, idx) => (
|
||||
<div key={`new-${idx}`} className="relative group rounded-xl overflow-hidden border border-accent/30 aspect-video bg-muted/20">
|
||||
<img src={url} alt={`New ${idx + 1}`} className="w-full h-full object-cover" />
|
||||
{existingPreviews.length === 0 && idx === 0 && (
|
||||
<span className="absolute top-1.5 left-1.5 text-[10px] font-bold bg-accent text-accent-foreground px-2 py-0.5 rounded-full">
|
||||
Cover
|
||||
</span>
|
||||
)}
|
||||
<span className="absolute bottom-1.5 left-1.5 text-[10px] bg-black/50 text-white px-1.5 py-0.5 rounded">
|
||||
Baru
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeNewImage(idx)}
|
||||
className="absolute top-1.5 right-1.5 p-1 bg-black/60 hover:bg-red-600 rounded-full text-white opacity-0 group-hover:opacity-100 transition-all"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload trigger */}
|
||||
<label className="flex items-center justify-center gap-3 w-full py-4 rounded-xl border-2 border-dashed border-border hover:border-accent/50 bg-muted/10 hover:bg-muted/30 cursor-pointer transition-colors">
|
||||
<ImagePlus size={20} className="text-muted-foreground" />
|
||||
<div className="text-center">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{allPreviews.length > 0 ? "Tambah lebih banyak gambar" : "Klik untuk upload gambar"}
|
||||
</span>
|
||||
<p className="text-xs text-muted-foreground/60 mt-0.5">PNG, JPG, WEBP · Bisa pilih banyak sekaligus</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
name="images"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="isPublished"
|
||||
id="isPublished"
|
||||
className="w-5 h-5 rounded border-border text-accent focus:ring-accent/50"
|
||||
defaultChecked={initialData ? initialData.isPublished : true}
|
||||
/>
|
||||
<label htmlFor="isPublished" className="text-sm font-medium cursor-pointer">
|
||||
Publish immediately (visible to visitors)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 border-t border-border/50 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-8 py-3 rounded-xl bg-foreground text-background font-semibold text-sm hover:opacity-90 transition-all disabled:opacity-50 disabled:scale-100 hover:scale-[1.02] shadow-md"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : projectId ? (
|
||||
<>
|
||||
<Save size={18} />
|
||||
Update Project
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlusCircle size={18} />
|
||||
Save Project
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
src/features/projects/project-schema.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Ensure image is optional but if provided it must be a File object
|
||||
export const projectSchema = z.object({
|
||||
title: z.string().min(3, "Title must be at least 3 characters long"),
|
||||
slug: z.string().min(3, "Slug must be at least 3 characters long").regex(/^[a-z0-9-]+$/, "Slug must only contain lowercase letters, numbers, and dashes"),
|
||||
description: z.string().min(10, "Description must be at least 10 characters long"),
|
||||
category: z.string().min(1, "Category is required"),
|
||||
repoUrl: z.union([z.string().url("Must be a valid URL"), z.literal("")]).optional(),
|
||||
liveUrl: z.union([z.string().url("Must be a valid URL"), z.literal("")]).optional(),
|
||||
isPublished: z.boolean().default(false),
|
||||
// Validation for image handles Server ActionFormData natively
|
||||
image: z
|
||||
.any()
|
||||
.refine((file) => !file || file?.size === 0 || file?.name, "Invalid file format")
|
||||
.optional(),
|
||||
techStack: z.string().optional(),
|
||||
year: z.coerce.number().optional(),
|
||||
highlights: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ProjectFormValues = z.infer<typeof projectSchema>;
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { AnimatedSection } from "@/shared/components/animated-section";
|
||||
import { SectionHeading } from "@/shared/components/section-heading";
|
||||
import { ProjectDetailModal } from "./project-detail-modal";
|
||||
import {
|
||||
ExternalLink,
|
||||
GitFork,
|
||||
@@ -15,66 +16,19 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function ProjectsSection() {
|
||||
export function ProjectsSection({ initialProjects }: { initialProjects?: any[] }) {
|
||||
const t = useTranslations("Projects");
|
||||
const [activeFilter, setActiveFilter] = useState("all");
|
||||
const [selectedProject, setSelectedProject] = useState<any | null>(null);
|
||||
|
||||
const projects = [
|
||||
{
|
||||
id: "1",
|
||||
title: t("items.apiGateway.title"),
|
||||
description: t("items.apiGateway.description"),
|
||||
category: "backend",
|
||||
tags: ["Spring Boot", "Kafka", "Redis", "Docker"],
|
||||
metrics: t.has("items.apiGateway.metrics") ? t("items.apiGateway.metrics") : undefined,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: t("items.paymentEngine.title"),
|
||||
description: t("items.paymentEngine.description"),
|
||||
category: "backend",
|
||||
tags: ["Java", "Kafka", "PostgreSQL", "gRPC"],
|
||||
metrics: t.has("items.paymentEngine.metrics") ? t("items.paymentEngine.metrics") : undefined,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: t("items.onboarding.title"),
|
||||
description: t("items.onboarding.description"),
|
||||
category: "frontend",
|
||||
tags: ["React", "Next.js", "TypeScript", "Tailwind"],
|
||||
repoUrl: "#",
|
||||
liveUrl: "#",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
title: t("items.dashboard.title"),
|
||||
description: t("items.dashboard.description"),
|
||||
category: "frontend",
|
||||
tags: ["React", "Chart.js", "WebSocket", "REST"],
|
||||
repoUrl: "#",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
title: t("items.mobileApp.title"),
|
||||
description: t("items.mobileApp.description"),
|
||||
category: "mobile",
|
||||
tags: ["React Native", "TypeScript", "Redux"],
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
title: t("items.authService.title"),
|
||||
description: t("items.authService.description"),
|
||||
category: "backend",
|
||||
tags: ["Spring Security", "OAuth2", "JWT", "Redis"],
|
||||
metrics: t.has("items.authService.metrics") ? t("items.authService.metrics") : undefined,
|
||||
},
|
||||
];
|
||||
// Use initialProjects from DB or fallback to empty array
|
||||
const projects = initialProjects || [];
|
||||
|
||||
const filters = [
|
||||
{ value: "all", label: t("filters.all"), icon: <Layers size={16} /> },
|
||||
{ value: "backend", label: t("filters.backend"), icon: <Server size={16} /> },
|
||||
{ value: "frontend", label: t("filters.frontend"), icon: <Globe size={16} /> },
|
||||
{ value: "mobile", label: t("filters.mobile"), icon: <Smartphone size={16} /> },
|
||||
{ value: "Enterprise Backend", label: "Backend", icon: <Server size={16} /> },
|
||||
{ value: "Frontend Development", label: "Frontend", icon: <Globe size={16} /> },
|
||||
{ value: "Mobile Development", label: "Mobile", icon: <Smartphone size={16} /> },
|
||||
];
|
||||
|
||||
const filteredProjects =
|
||||
@@ -87,7 +41,6 @@ export function ProjectsSection() {
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
<AnimatedSection>
|
||||
<SectionHeading
|
||||
badge={t("badge")}
|
||||
title={t("title")}
|
||||
subtitle={t("subtitle")}
|
||||
/>
|
||||
@@ -112,6 +65,21 @@ export function ProjectsSection() {
|
||||
</div>
|
||||
</AnimatedSection>
|
||||
|
||||
{/* Empty state */}
|
||||
{filteredProjects.length === 0 ? (
|
||||
<AnimatedSection delay={0.2}>
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="w-20 h-20 rounded-2xl bg-muted/50 border border-border/50 flex items-center justify-center mb-6">
|
||||
<Layers size={32} className="text-muted-foreground/40" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold mb-2">Belum ada project</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm">
|
||||
Project sedang dalam persiapan. Nantikan case study yang akan datang!
|
||||
</p>
|
||||
</div>
|
||||
</AnimatedSection>
|
||||
) : (
|
||||
<>
|
||||
{/* Projects grid */}
|
||||
<motion.div
|
||||
layout
|
||||
@@ -127,25 +95,28 @@ export function ProjectsSection() {
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="group relative h-full flex flex-col p-6 rounded-2xl bg-card border border-border/50 hover:border-accent/30 transition-all duration-500 hover:shadow-lg hover:shadow-accent/5">
|
||||
<div
|
||||
className="project-card group relative h-full flex flex-col p-6 rounded-2xl bg-card border border-border/50 hover:border-accent/30 transition-all duration-500 cursor-pointer"
|
||||
onClick={() => setSelectedProject(project)}
|
||||
>
|
||||
{/* Category badge */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[10px] font-mono font-semibold uppercase tracking-wider ${project.category === "backend"
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[10px] font-mono font-semibold uppercase tracking-wider ${project.category === "Enterprise Backend"
|
||||
? "bg-blue-500/10 text-blue-500 dark:text-blue-400"
|
||||
: project.category === "frontend"
|
||||
: project.category === "Frontend Development"
|
||||
? "bg-violet-500/10 text-violet-500 dark:text-violet-400"
|
||||
: "bg-orange-500/10 text-orange-500 dark:text-orange-400"
|
||||
}`}
|
||||
>
|
||||
{project.category === "backend" ? (
|
||||
{project.category === "Enterprise Backend" ? (
|
||||
<Server size={12} />
|
||||
) : project.category === "frontend" ? (
|
||||
) : project.category === "Frontend Development" ? (
|
||||
<Globe size={12} />
|
||||
) : (
|
||||
<Smartphone size={12} />
|
||||
)}
|
||||
{project.category}
|
||||
{project.category.replace(" Development", "").replace("Enterprise ", "")}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{project.repoUrl && (
|
||||
@@ -169,6 +140,18 @@ export function ProjectsSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image banner */}
|
||||
{project.imageUrl && (
|
||||
<div className="w-full h-40 mb-4 rounded-xl overflow-hidden bg-muted/20 relative">
|
||||
<img
|
||||
src={project.imageUrl}
|
||||
alt={project.title}
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-card/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title & description */}
|
||||
<h3 className="text-lg font-bold mb-2 group-hover:text-accent transition-colors">
|
||||
{project.title}
|
||||
@@ -187,22 +170,52 @@ export function ProjectsSection() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{/* Tech Stack Badges */}
|
||||
<div className="flex flex-wrap gap-1.5 mt-auto">
|
||||
{project.tags.map((tag) => (
|
||||
{project.techStack?.length > 0 ? (
|
||||
project.techStack.map((tech: string) => (
|
||||
<span
|
||||
key={tech}
|
||||
className="px-2 py-1 rounded-md text-[10px] font-mono bg-accent/10 text-accent border border-accent/20"
|
||||
>
|
||||
{tech}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
(project.tags || [project.category.split(" ")[0]]).map((tag: string) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-2 py-1 rounded-md text-[10px] font-mono bg-muted/50 text-muted-foreground border border-border/30"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Year */}
|
||||
{project.year && (
|
||||
<div className="absolute top-6 right-6 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="px-2 py-1 rounded-lg bg-card/80 backdrop-blur-sm border border-border text-[10px] font-mono text-muted-foreground shadow-sm">
|
||||
{project.year}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{/* Detail Modal */}
|
||||
{selectedProject && (
|
||||
<ProjectDetailModal
|
||||
project={selectedProject}
|
||||
onClose={() => setSelectedProject(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
36
src/features/projects/toggle-button.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toggleProjectPublishAction } from "./actions";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export function TogglePublishButton({ id, isPublished }: { id: string; isPublished: boolean }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleToggle() {
|
||||
setLoading(true);
|
||||
await toggleProjectPublishAction(id, isPublished);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
disabled={loading}
|
||||
className={`px-3 py-1 rounded-full text-xs font-bold font-mono border transition-all disabled:opacity-50 ${
|
||||
isPublished
|
||||
? "bg-success/10 text-success border-success/30 hover:bg-error/10 hover:text-error hover:border-error/30 group"
|
||||
: "bg-muted/50 text-muted-foreground border-border hover:bg-success/10 hover:text-success hover:border-success/30"
|
||||
}`}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : isPublished ? (
|
||||
<span className="group-hover:hidden">Published</span>
|
||||
) : (
|
||||
<span>Draft</span>
|
||||
)}
|
||||
{isPublished && !loading && <span className="hidden group-hover:inline">Unpublish</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
102
src/features/skills/actions.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/core/db/prisma";
|
||||
import { verifySession } from "@/core/security/session";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
|
||||
const skillSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
category: z.enum(["backend", "infra", "frontend", "mobile"]).refine(
|
||||
(v) => ["backend", "infra", "frontend", "mobile"].includes(v),
|
||||
{ message: "Invalid category" }
|
||||
),
|
||||
iconName: z.string().optional(),
|
||||
sortOrder: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
export async function createSkillAction(prevState: any, formData: FormData) {
|
||||
const session = await verifySession();
|
||||
if (!session) return { success: false, message: "Unauthorized" };
|
||||
|
||||
const data = {
|
||||
name: formData.get("name") as string,
|
||||
category: formData.get("category") as string,
|
||||
iconName: formData.get("iconName") as string,
|
||||
sortOrder: formData.get("sortOrder") as string,
|
||||
};
|
||||
|
||||
const validation = skillSchema.safeParse(data);
|
||||
if (!validation.success) {
|
||||
return { success: false, message: validation.error.issues[0].message };
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.skill.create({
|
||||
data: {
|
||||
name: validation.data.name,
|
||||
category: validation.data.category,
|
||||
iconName: validation.data.iconName || null,
|
||||
sortOrder: validation.data.sortOrder,
|
||||
},
|
||||
});
|
||||
revalidatePath("/admin/dashboard/skills");
|
||||
revalidatePath("/");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return { success: false, message: "Failed to create skill" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSkillAction(
|
||||
id: string,
|
||||
prevState: any,
|
||||
formData: FormData
|
||||
) {
|
||||
const session = await verifySession();
|
||||
if (!session) return { success: false, message: "Unauthorized" };
|
||||
|
||||
const data = {
|
||||
name: formData.get("name") as string,
|
||||
category: formData.get("category") as string,
|
||||
iconName: formData.get("iconName") as string,
|
||||
sortOrder: formData.get("sortOrder") as string,
|
||||
};
|
||||
|
||||
const validation = skillSchema.safeParse(data);
|
||||
if (!validation.success) {
|
||||
return { success: false, message: validation.error.issues[0].message };
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.skill.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: validation.data.name,
|
||||
category: validation.data.category,
|
||||
iconName: validation.data.iconName || null,
|
||||
sortOrder: validation.data.sortOrder,
|
||||
},
|
||||
});
|
||||
revalidatePath("/admin/dashboard/skills");
|
||||
revalidatePath("/");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, message: "Failed to update skill" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSkillAction(id: string) {
|
||||
const session = await verifySession();
|
||||
if (!session) return { success: false, message: "Unauthorized" };
|
||||
|
||||
try {
|
||||
await prisma.skill.delete({ where: { id } });
|
||||
revalidatePath("/admin/dashboard/skills");
|
||||
revalidatePath("/");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, message: "Failed to delete skill" };
|
||||
}
|
||||
}
|
||||
53
src/features/skills/animated-tech-item.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Code2 } from "lucide-react";
|
||||
|
||||
interface AnimatedTechItemProps {
|
||||
name: string;
|
||||
iconName: string | null;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export function AnimatedTechItem({ name, iconName, index }: AnimatedTechItemProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
delay: index * 0.05,
|
||||
ease: [0.25, 0.4, 0.25, 1],
|
||||
}}
|
||||
whileHover={{
|
||||
scale: 1.06,
|
||||
y: -4,
|
||||
transition: { duration: 0.25, ease: "easeOut" },
|
||||
}}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl bg-muted/50 border border-border/30
|
||||
hover:border-accent/40 hover:bg-accent/8 hover:shadow-lg hover:shadow-accent/5
|
||||
transition-colors duration-300 cursor-default group/item"
|
||||
>
|
||||
{iconName ? (
|
||||
<motion.img
|
||||
src={`https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/${iconName}/${iconName}-original.svg`}
|
||||
alt={name}
|
||||
className="w-5 h-5 object-contain flex-shrink-0"
|
||||
whileHover={{
|
||||
rotate: [0, -10, 10, -5, 0],
|
||||
transition: { duration: 0.5 },
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Code2
|
||||
size={16}
|
||||
className="text-muted-foreground group-hover/item:text-accent transition-colors flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs font-mono font-medium truncate group-hover/item:text-accent transition-colors duration-300">
|
||||
{name}
|
||||
</span>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
28
src/features/skills/delete-skill-button.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { deleteSkillAction } from "./actions";
|
||||
import { Trash2, Loader2 } from "lucide-react";
|
||||
|
||||
export function DeleteSkillButton({ id }: { id: string }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleDelete() {
|
||||
if (confirm("Delete this skill? This cannot be undone.")) {
|
||||
setLoading(true);
|
||||
await deleteSkillAction(id);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
className="p-2 text-muted-foreground hover:text-red-500 hover:bg-red-500/10 rounded-lg transition-colors disabled:opacity-50"
|
||||
title="Delete Skill"
|
||||
>
|
||||
{loading ? <Loader2 size={16} className="animate-spin" /> : <Trash2 size={16} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
181
src/features/skills/skill-form.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { createSkillAction, updateSkillAction } from "./actions";
|
||||
import { useRouter } from "@/i18n/routing";
|
||||
import { Loader2, ArrowLeft, Save, PlusCircle } from "lucide-react";
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: "backend", label: "Enterprise Backend" },
|
||||
{ value: "infra", label: "Database & Infrastructure" },
|
||||
{ value: "frontend", label: "Frontend Development" },
|
||||
{ value: "mobile", label: "Mobile Development" },
|
||||
];
|
||||
|
||||
const DEVICON_SUGGESTIONS = [
|
||||
"java", "spring", "oracle", "docker", "postgresql", "redis", "kubernetes",
|
||||
"react", "nextjs", "typescript", "tailwindcss", "angular", "flutter", "swift",
|
||||
"kafka", "nginx", "jenkins", "github",
|
||||
];
|
||||
|
||||
export function SkillForm({ initialData, skillId }: { initialData?: any; skillId?: string }) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [iconPreview, setIconPreview] = useState(initialData?.iconName || "");
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
let result;
|
||||
if (skillId) {
|
||||
result = await updateSkillAction(skillId, null, formData);
|
||||
} else {
|
||||
result = await createSkillAction(null, formData);
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.message || "An error occurred");
|
||||
setLoading(false);
|
||||
} else {
|
||||
router.push("/admin/dashboard/skills");
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6 lg:p-10 rounded-3xl bg-card border border-border shadow-sm relative overflow-hidden">
|
||||
<div className="absolute top-0 inset-x-0 h-1 bg-gradient-to-r from-emerald-500 to-teal-500" />
|
||||
|
||||
<div className="mb-8 flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="p-2 rounded-full hover:bg-muted text-muted-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{skillId ? "Edit Skill" : "Add New Skill"}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{skillId ? "Update tech stack item" : "Add a new technology to your arsenal"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-500 text-sm font-medium">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">Skill Name</label>
|
||||
<input
|
||||
name="name"
|
||||
required
|
||||
defaultValue={initialData?.name}
|
||||
placeholder="e.g. Spring Boot"
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/50 transition-all font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">Category</label>
|
||||
<select
|
||||
name="category"
|
||||
required
|
||||
defaultValue={initialData?.category || "backend"}
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/50 transition-all cursor-pointer"
|
||||
>
|
||||
{CATEGORIES.map((cat) => (
|
||||
<option key={cat.value} value={cat.value}>
|
||||
{cat.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-semibold">
|
||||
Devicon Slug / Custom URL <span className="text-muted-foreground font-normal">(contoh: spring, atau /icons/logo.svg)</span>
|
||||
</label>
|
||||
<input
|
||||
name="iconName"
|
||||
defaultValue={initialData?.iconName || ""}
|
||||
placeholder="e.g. spring, java, docker"
|
||||
onChange={(e) => setIconPreview(e.target.value.trim())}
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/50 transition-all font-mono"
|
||||
/>
|
||||
{/* Icon Preview */}
|
||||
{iconPreview && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl border border-border bg-muted/20">
|
||||
<img
|
||||
src={iconPreview.startsWith("http") || iconPreview.startsWith("/")
|
||||
? iconPreview
|
||||
: `https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/${iconPreview}/${iconPreview}-original.svg`}
|
||||
alt={iconPreview}
|
||||
className="w-10 h-10 object-contain"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground font-mono">{iconPreview}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Quick suggestions */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DEVICON_SUGGESTIONS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIconPreview(s);
|
||||
const input = document.querySelector('input[name="iconName"]') as HTMLInputElement;
|
||||
if (input) input.value = s;
|
||||
}}
|
||||
className="px-2 py-1 text-[11px] font-mono rounded-lg bg-muted/50 border border-border hover:border-emerald-500/50 hover:bg-emerald-500/10 transition-colors"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">
|
||||
Sort Order <span className="text-muted-foreground font-normal">(angka kecil = tampil duluan)</span>
|
||||
</label>
|
||||
<input
|
||||
name="sortOrder"
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={initialData?.sortOrder ?? 0}
|
||||
placeholder="0"
|
||||
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/50 transition-all font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 border-t border-border/50 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-8 py-3 rounded-xl bg-foreground text-background font-semibold text-sm hover:opacity-90 transition-all disabled:opacity-50 hover:scale-[1.02] shadow-md"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : skillId ? (
|
||||
<><Save size={18} /> Update Skill</>
|
||||
) : (
|
||||
<><PlusCircle size={18} /> Save Skill</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
src/features/skills/skill-icon.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { Code2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export function SkillIcon({ iconName, name }: { iconName: string | null; name: string }) {
|
||||
const [errored, setErrored] = useState(false);
|
||||
|
||||
if (!iconName || errored) {
|
||||
return (
|
||||
<div className="w-9 h-9 rounded-xl bg-muted flex items-center justify-center">
|
||||
<Code2 size={18} className="text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const iconSrc = iconName.startsWith("http") || iconName.startsWith("/")
|
||||
? iconName
|
||||
: `https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/${iconName}/${iconName}-original.svg`;
|
||||
|
||||
return (
|
||||
<img
|
||||
src={iconSrc}
|
||||
alt={name}
|
||||
className="w-9 h-9 object-contain"
|
||||
onError={() => setErrored(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,128 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { prisma } from "@/core/db/prisma";
|
||||
import { AnimatedSection } from "@/shared/components/animated-section";
|
||||
import { SectionHeading } from "@/shared/components/section-heading";
|
||||
import {
|
||||
Database,
|
||||
Server,
|
||||
Smartphone,
|
||||
Globe,
|
||||
Container,
|
||||
Shield,
|
||||
Cpu,
|
||||
Layers,
|
||||
GitBranch,
|
||||
MonitorSmartphone,
|
||||
Cloud,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AnimatedTechItem } from "./animated-tech-item";
|
||||
import { Layers, Code2 } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export function TechStackSection() {
|
||||
const t = useTranslations("TechStack");
|
||||
|
||||
const techCategories = [
|
||||
{
|
||||
title: t("categories.backend.title"),
|
||||
description: t("categories.backend.description"),
|
||||
const CATEGORY_CONFIG: Record<
|
||||
string,
|
||||
{ label: string; accent: string; descKey: string }
|
||||
> = {
|
||||
backend: {
|
||||
label: "Enterprise Backend",
|
||||
accent: "from-blue-500 to-cyan-500",
|
||||
items: [
|
||||
{ name: "Java", icon: <Cpu size={22} /> },
|
||||
{ name: "Spring Boot", icon: <Layers size={22} /> },
|
||||
{ name: "Apache Kafka", icon: <Workflow size={22} /> },
|
||||
{ name: "REST API", icon: <Server size={22} /> },
|
||||
{ name: "gRPC", icon: <GitBranch size={22} /> },
|
||||
{ name: "Spring Security", icon: <Shield size={22} /> },
|
||||
],
|
||||
descKey: "categories.backend.description",
|
||||
},
|
||||
{
|
||||
title: t("categories.infra.title"),
|
||||
description: t("categories.infra.description"),
|
||||
infra: {
|
||||
label: "Database & Infrastructure",
|
||||
accent: "from-emerald-500 to-teal-500",
|
||||
items: [
|
||||
{ name: "PostgreSQL", icon: <Database size={22} /> },
|
||||
{ name: "Redis", icon: <Database size={22} /> },
|
||||
{ name: "Docker", icon: <Container size={22} /> },
|
||||
{ name: "Kubernetes", icon: <Cloud size={22} /> },
|
||||
{ name: "Jenkins CI/CD", icon: <GitBranch size={22} /> },
|
||||
{ name: "Nginx", icon: <Server size={22} /> },
|
||||
],
|
||||
descKey: "categories.infra.description",
|
||||
},
|
||||
{
|
||||
title: t("categories.frontend.title"),
|
||||
description: t("categories.frontend.description"),
|
||||
frontend: {
|
||||
label: "Frontend Development",
|
||||
accent: "from-violet-500 to-purple-500",
|
||||
items: [
|
||||
{ name: "React / Next.js", icon: <Globe size={22} /> },
|
||||
{ name: "TypeScript", icon: <Cpu size={22} /> },
|
||||
{ name: "Tailwind CSS", icon: <Layers size={22} /> },
|
||||
{ name: "Framer Motion", icon: <Workflow size={22} /> },
|
||||
],
|
||||
descKey: "categories.frontend.description",
|
||||
},
|
||||
{
|
||||
title: t("categories.mobile.title"),
|
||||
description: t("categories.mobile.description"),
|
||||
mobile: {
|
||||
label: "Mobile Development",
|
||||
accent: "from-orange-500 to-rose-500",
|
||||
items: [
|
||||
{ name: "React Native", icon: <Smartphone size={22} /> },
|
||||
{ name: "Flutter", icon: <MonitorSmartphone size={22} /> },
|
||||
],
|
||||
descKey: "categories.mobile.description",
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER = ["backend", "infra", "frontend", "mobile"];
|
||||
|
||||
export async function TechStackSection() {
|
||||
const t = await getTranslations("TechStack");
|
||||
|
||||
const skills = await prisma.skill.findMany({
|
||||
orderBy: [{ category: "asc" }, { sortOrder: "asc" }, { name: "asc" }],
|
||||
});
|
||||
|
||||
// Group by category
|
||||
const grouped: Record<string, typeof skills> = {};
|
||||
for (const skill of skills) {
|
||||
if (!grouped[skill.category]) grouped[skill.category] = [];
|
||||
grouped[skill.category].push(skill);
|
||||
}
|
||||
|
||||
const categories = CATEGORY_ORDER.filter(
|
||||
(cat) => grouped[cat] && grouped[cat].length > 0
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
id="tech-stack"
|
||||
className="section-padding relative bg-muted/30"
|
||||
>
|
||||
<section id="tech-stack" className="section-padding relative bg-muted/30">
|
||||
<div className="absolute inset-0 grid-pattern opacity-20" />
|
||||
|
||||
<div className="relative max-w-6xl mx-auto px-6">
|
||||
<AnimatedSection>
|
||||
<SectionHeading
|
||||
badge={t("badge")}
|
||||
title={t("title")}
|
||||
subtitle={t("subtitle")}
|
||||
/>
|
||||
</AnimatedSection>
|
||||
|
||||
{categories.length === 0 ? (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<Code2 size={40} className="mx-auto mb-4 opacity-30" />
|
||||
<p className="text-sm">
|
||||
Belum ada tech stack. Tambahkan melalui{" "}
|
||||
<span className="font-mono text-accent">Admin Dashboard</span>.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{techCategories.map((category, catIndex) => (
|
||||
<AnimatedSection key={category.title} delay={catIndex * 0.1}>
|
||||
{categories.map((cat, catIndex) => {
|
||||
const config = CATEGORY_CONFIG[cat] ?? {
|
||||
label: cat,
|
||||
accent: "from-gray-500 to-gray-600",
|
||||
descKey: "",
|
||||
};
|
||||
const items = grouped[cat];
|
||||
|
||||
return (
|
||||
<AnimatedSection key={cat} delay={catIndex * 0.1}>
|
||||
<div className="group relative h-full p-6 rounded-2xl bg-card border border-border/50 hover:border-accent/30 transition-all duration-500 hover:shadow-lg">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-xl bg-gradient-to-br ${category.accent} flex items-center justify-center text-white shadow-lg`}
|
||||
className={`w-10 h-10 rounded-xl bg-gradient-to-br ${config.accent} flex items-center justify-center text-white shadow-lg`}
|
||||
>
|
||||
<Layers size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-sm">{category.title}</h3>
|
||||
<h3 className="font-bold text-sm">{config.label}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{category.description}
|
||||
{config.descKey ? t(config.descKey as any) : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{category.items.map((tech) => (
|
||||
<div
|
||||
key={tech.name}
|
||||
className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl bg-muted/50 border border-border/30 hover:border-accent/30 hover:bg-accent/5 transition-all duration-300 group/item"
|
||||
>
|
||||
<span className="text-muted-foreground group-hover/item:text-accent transition-colors">
|
||||
{tech.icon}
|
||||
</span>
|
||||
<span className="text-xs font-mono font-medium truncate">
|
||||
{tech.name}
|
||||
</span>
|
||||
</div>
|
||||
{items.map((skill, skillIndex) => (
|
||||
<AnimatedTechItem
|
||||
key={skill.id}
|
||||
name={skill.name}
|
||||
iconName={skill.iconName}
|
||||
index={skillIndex}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedSection>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,8 @@ import {createNavigation} from 'next-intl/navigation';
|
||||
export const routing = defineRouting({
|
||||
locales: ['id', 'en'],
|
||||
defaultLocale: 'id',
|
||||
localePrefix: 'as-needed' // Don't show /id for the default locale
|
||||
localePrefix: 'as-needed', // Don't show /id for the default locale
|
||||
localeDetection: false // Force id on first visit regardless of browser lang
|
||||
});
|
||||
|
||||
export const {Link, redirect, usePathname, useRouter, getPathname} = createNavigation(routing);
|
||||
|
||||
53
src/shared/components/brand-logo.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
|
||||
type BrandLogoProps = {
|
||||
href?: string;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
textClassName?: string;
|
||||
iconSize?: number;
|
||||
priority?: boolean;
|
||||
};
|
||||
|
||||
export function BrandLogo({
|
||||
href = "#",
|
||||
className = "",
|
||||
iconClassName = "",
|
||||
textClassName = "font-mono font-bold text-lg tracking-tight",
|
||||
iconSize = 36,
|
||||
priority = false,
|
||||
}: BrandLogoProps) {
|
||||
const [imageFailed, setImageFailed] = useState(false);
|
||||
|
||||
return (
|
||||
<a href={href} className={`flex items-center gap-2 group ${className}`.trim()}>
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-lg bg-gradient-to-br from-accent to-purple-500 text-white shadow-lg shadow-accent/25 ${iconClassName}`.trim()}
|
||||
style={{ width: iconSize, height: iconSize }}
|
||||
>
|
||||
{imageFailed ? (
|
||||
<div className="flex h-full w-full items-center justify-center font-bold text-sm">
|
||||
S
|
||||
</div>
|
||||
) : (
|
||||
<Image
|
||||
src="/brand/icon.png"
|
||||
alt="Simanullang Dev logo"
|
||||
fill
|
||||
sizes={`${iconSize}px`}
|
||||
className="object-cover"
|
||||
priority={priority}
|
||||
onError={() => setImageFailed(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className={textClassName}>
|
||||
simanullang<span className="text-accent">.dev</span>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +1,106 @@
|
||||
import { GitFork, Link2, Mail, ArrowUp } 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", 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");
|
||||
const tNav = useTranslations("Navigation");
|
||||
|
||||
return (
|
||||
<footer className="relative border-t border-border/50 bg-card/50">
|
||||
<div className="max-w-6xl mx-auto px-6 py-12">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-6">
|
||||
{/* Brand */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-accent to-purple-500 flex items-center justify-center text-white font-bold text-xs">
|
||||
A
|
||||
{/* Gradient top line */}
|
||||
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-accent/50 to-transparent" />
|
||||
|
||||
<div className="max-w-6xl mx-auto px-6 py-16">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-10">
|
||||
{/* Brand & tagline */}
|
||||
<div className="md:col-span-1">
|
||||
<div className="mb-4">
|
||||
<BrandLogo />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4 max-w-xs">
|
||||
{t("description")}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground/60">
|
||||
<MapPin size={12} />
|
||||
<span>Jakarta, Indonesia</span>
|
||||
</div>
|
||||
<span className="font-mono font-bold tracking-tight">
|
||||
ando<span className="text-accent">.dev</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Social Links */}
|
||||
{/* Quick Links */}
|
||||
<div>
|
||||
<h4 className="text-sm font-bold mb-4 uppercase tracking-wider text-muted-foreground">
|
||||
{t("quickLinksTitle", { fallback: "Quick Links" })}
|
||||
</h4>
|
||||
<div className="space-y-2.5">
|
||||
{quickLinks.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="block text-sm text-muted-foreground hover:text-accent transition-colors"
|
||||
>
|
||||
{tNav(link.labelKey)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Social & Back to top */}
|
||||
<div className="flex flex-col items-start md:items-end gap-6">
|
||||
<div>
|
||||
<h4 className="text-sm font-bold mb-4 uppercase tracking-wider text-muted-foreground md:text-right">
|
||||
{t("connectTitle", { fallback: "Connect" })}
|
||||
</h4>
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="https://github.com"
|
||||
href="https://www.linkedin.com/in/yolando-asri-e-g-manullang/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2.5 rounded-xl bg-muted/50 border border-border/50 hover:bg-accent hover:text-accent-foreground hover:border-accent transition-all duration-300 hover:scale-105"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<GitFork size={18} />
|
||||
</a>
|
||||
<a
|
||||
href="https://linkedin.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2.5 rounded-xl bg-muted/50 border border-border/50 hover:bg-accent hover:text-accent-foreground hover:border-accent transition-all duration-300 hover:scale-105"
|
||||
className="p-2.5 rounded-xl bg-muted/50 border border-border/50 hover:bg-accent hover:text-accent-foreground hover:border-accent transition-all duration-300 hover:scale-105 hover:shadow-lg hover:shadow-accent/20"
|
||||
aria-label="LinkedIn"
|
||||
>
|
||||
<Link2 size={18} />
|
||||
</a>
|
||||
<a
|
||||
href="mailto:hello@ando.dev"
|
||||
className="p-2.5 rounded-xl bg-muted/50 border border-border/50 hover:bg-accent hover:text-accent-foreground hover:border-accent transition-all duration-300 hover:scale-105"
|
||||
href="mailto:yolandomanullang@gmail.com"
|
||||
className="p-2.5 rounded-xl bg-muted/50 border border-border/50 hover:bg-accent hover:text-accent-foreground hover:border-accent transition-all duration-300 hover:scale-105 hover:shadow-lg hover:shadow-accent/20"
|
||||
aria-label="Email"
|
||||
>
|
||||
<Mail size={18} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Back to top */}
|
||||
<a
|
||||
href="#"
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-accent transition-colors group"
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-accent transition-colors group mt-auto"
|
||||
>
|
||||
{t("backToTop")}
|
||||
<ArrowUp
|
||||
size={14}
|
||||
className="transition-transform group-hover:-translate-y-0.5"
|
||||
className="transition-transform group-hover:-translate-y-1"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-border/30 text-center">
|
||||
<p className="text-sm text-muted-foreground font-mono">
|
||||
{/* Bottom bar */}
|
||||
<div className="mt-12 pt-6 border-t border-border/30 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
© {new Date().getFullYear()} {t("copyright")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/60 flex items-center gap-1">
|
||||
Crafted with <Heart size={10} className="text-red-500 fill-red-500" /> using Next.js & Tailwind
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -4,12 +4,16 @@ import { useState, useEffect, useTransition } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Menu, X, Languages } from "lucide-react";
|
||||
import { ThemeToggle } from "@/shared/components/theme-toggle";
|
||||
import { BrandLogo } from "@/shared/components/brand-logo";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { usePathname, useRouter } from "@/i18n/routing";
|
||||
|
||||
const SECTIONS = ["experience", "education", "tech-stack", "projects", "contact"];
|
||||
|
||||
export function Navbar() {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [activeSection, setActiveSection] = useState("");
|
||||
const t = useTranslations("Navigation");
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
@@ -22,11 +26,33 @@ export function Navbar() {
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
// #4 Scroll spy — highlight active nav link
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
setActiveSection(entry.target.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: "-40% 0px -55% 0px", threshold: 0 }
|
||||
);
|
||||
|
||||
for (const id of SECTIONS) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) observer.observe(el);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const navLinks = [
|
||||
{ href: "#experience", label: t("experience") },
|
||||
{ href: "#tech-stack", label: t("techStack") },
|
||||
{ href: "#projects", label: t("projects") },
|
||||
{ href: "#contact", label: t("contact") },
|
||||
{ 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") },
|
||||
];
|
||||
|
||||
const switchLocale = (newLocale: string) => {
|
||||
@@ -48,26 +74,29 @@ export function Navbar() {
|
||||
}`}
|
||||
>
|
||||
<nav className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
{/* Logo */}
|
||||
<a href="#" className="flex items-center gap-2 group">
|
||||
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-accent to-purple-500 flex items-center justify-center text-white font-bold text-sm shadow-lg shadow-accent/25 group-hover:shadow-accent/40 transition-shadow">
|
||||
A
|
||||
</div>
|
||||
<span className="font-mono font-bold text-lg tracking-tight">
|
||||
ando<span className="text-accent">.dev</span>
|
||||
</span>
|
||||
</a>
|
||||
<BrandLogo
|
||||
priority
|
||||
iconClassName="group-hover:shadow-accent/50 group-hover:scale-110 transition-all duration-300"
|
||||
/>
|
||||
|
||||
{/* Desktop Nav */}
|
||||
{/* Desktop Nav with scroll spy */}
|
||||
<div className="hidden md:flex items-center gap-1">
|
||||
{navLinks.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="relative px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors group"
|
||||
className={`relative px-4 py-2 text-sm font-medium transition-colors group ${
|
||||
activeSection === link.id
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
<span className="absolute bottom-0 left-1/2 -translate-x-1/2 w-0 h-0.5 bg-accent rounded-full transition-all duration-300 group-hover:w-6" />
|
||||
<span
|
||||
className={`absolute bottom-0 left-1/2 -translate-x-1/2 h-0.5 bg-accent rounded-full transition-all duration-300 ${
|
||||
activeSection === link.id ? "w-6" : "w-0 group-hover:w-6"
|
||||
}`}
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
<div className="ml-4 pl-4 border-l border-border/50 flex items-center gap-2">
|
||||
@@ -129,7 +158,11 @@ export function Navbar() {
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="px-4 py-3 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-lg transition-colors"
|
||||
className={`px-4 py-3 text-sm font-medium rounded-lg transition-colors ${
|
||||
activeSection === link.id
|
||||
? "text-accent bg-accent/5"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
|
||||
@@ -8,7 +8,7 @@ export function SectionHeading({ badge, title, subtitle }: SectionHeadingProps)
|
||||
return (
|
||||
<div className="text-center mb-16">
|
||||
{badge && (
|
||||
<span className="inline-block px-4 py-1.5 rounded-full text-xs font-mono font-semibold tracking-wider uppercase bg-accent/10 text-accent border border-accent/20 mb-4">
|
||||
<span className="inline-block px-4 py-1.5 rounded-full text-xs font-mono font-semibold tracking-wider uppercase bg-accent/10 text-accent border border-accent/20 mb-4 badge-shimmer">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
|
||||
37
src/shared/components/wave-divider.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Decorative wave SVG between sections.
|
||||
* `flip` mirrors the wave vertically.
|
||||
* `fromColor` and `toColor` set the CSS fill accordingly.
|
||||
*/
|
||||
|
||||
type WaveDividerProps = {
|
||||
/** Fill color — use CSS variable or tailwind class value */
|
||||
fill?: string;
|
||||
/** Flip vertically (place at bottom of a dark section going to light) */
|
||||
flip?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function WaveDivider({
|
||||
fill = "var(--background)",
|
||||
flip = false,
|
||||
className = "",
|
||||
}: WaveDividerProps) {
|
||||
return (
|
||||
<div
|
||||
className={`section-divider ${className}`}
|
||||
style={{ transform: flip ? "rotate(180deg)" : undefined }}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 1440 58"
|
||||
preserveAspectRatio="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M0,0 C360,58 1080,0 1440,44 L1440,58 L0,58 Z"
|
||||
fill={fill}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
test-db.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
const projects = await prisma.project.findMany({ select: { title: true, techStack: true, year: true, highlights: true }})
|
||||
console.log(JSON.stringify(projects, null, 2))
|
||||
}
|
||||
main().catch(console.error).finally(() => prisma.$disconnect())
|
||||