feat: implement portfolio dashboard with skill, experience, and message management features
This commit is contained in:
@@ -7,7 +7,7 @@
|
|||||||
},
|
},
|
||||||
"Hero": {
|
"Hero": {
|
||||||
"greeting": "Hi, I'm",
|
"greeting": "Hi, I'm",
|
||||||
"name": "Yolando.",
|
"name": "Yolando Manullang.",
|
||||||
"badge": "Available for opportunities",
|
"badge": "Available for opportunities",
|
||||||
"titlePart1": "Building",
|
"titlePart1": "Building",
|
||||||
"titleHighlight": "Secure, Scalable",
|
"titleHighlight": "Secure, Scalable",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
},
|
},
|
||||||
"Hero": {
|
"Hero": {
|
||||||
"greeting": "Hai, saya",
|
"greeting": "Hai, saya",
|
||||||
"name": "Yolando.",
|
"name": "Yolando Manullang.",
|
||||||
"badge": "Tersedia untuk peluang baru",
|
"badge": "Tersedia untuk peluang baru",
|
||||||
"titlePart1": "Membangun Sistem",
|
"titlePart1": "Membangun Sistem",
|
||||||
"titleHighlight": "Aman & Skalabel",
|
"titleHighlight": "Aman & Skalabel",
|
||||||
|
|||||||
@@ -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;
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
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"
|
||||||
@@ -66,3 +66,16 @@ model Message {
|
|||||||
|
|
||||||
@@map("messages")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
BIN
public/uploads/1774986132810-Screenshot_2026-03-28_205042.png
Normal file
BIN
public/uploads/1774986132810-Screenshot_2026-03-28_205042.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 180 KiB |
@@ -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
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
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
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,7 @@
|
|||||||
import { verifySession, clearSession } from "@/core/security/session";
|
import { verifySession, clearSession } from "@/core/security/session";
|
||||||
import { Link, redirect } from "@/i18n/routing";
|
import { Link, redirect } from "@/i18n/routing";
|
||||||
import { getLocale } from "next-intl/server";
|
import { getLocale } from "next-intl/server";
|
||||||
import { LogOut, ArrowRight } from "lucide-react";
|
import { LogOut, ArrowRight, Code2, Inbox, Briefcase, FolderKanban } from "lucide-react";
|
||||||
import { prisma } from "@/core/db/prisma";
|
import { prisma } from "@/core/db/prisma";
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
@@ -12,6 +12,55 @@ export default async function DashboardPage() {
|
|||||||
redirect({ href: "/admin/login", locale });
|
redirect({ href: "/admin/login", locale });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch counts in parallel
|
||||||
|
const [projectCount, skillCount, unreadMessageCount, experienceCount] =
|
||||||
|
await Promise.all([
|
||||||
|
prisma.project.count(),
|
||||||
|
prisma.skill.count(),
|
||||||
|
prisma.message.count({ where: { isRead: false } }),
|
||||||
|
prisma.experience.count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
{
|
||||||
|
href: "/admin/dashboard/projects",
|
||||||
|
icon: <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",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-muted/30">
|
<div className="min-h-screen bg-muted/30">
|
||||||
{/* Navbar Minimal Dashboard */}
|
{/* Navbar Minimal Dashboard */}
|
||||||
@@ -22,14 +71,16 @@ export default async function DashboardPage() {
|
|||||||
<span className="text-xs text-muted-foreground">{session.email}</span>
|
<span className="text-xs text-muted-foreground">{session.email}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form action={async () => {
|
<form
|
||||||
"use server";
|
action={async () => {
|
||||||
await clearSession();
|
"use server";
|
||||||
redirect({ href: "/admin/login", locale });
|
await clearSession();
|
||||||
}}>
|
redirect({ href: "/admin/login", locale });
|
||||||
|
}}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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} />
|
<LogOut size={16} />
|
||||||
Sign Out
|
Sign Out
|
||||||
@@ -40,30 +91,42 @@ export default async function DashboardPage() {
|
|||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="max-w-7xl mx-auto px-6 py-12">
|
<main className="max-w-7xl mx-auto px-6 py-12">
|
||||||
<div className="grid gap-6 md:grid-cols-3">
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||||
{/* Card: Projects */}
|
{cards.map((card) => (
|
||||||
<Link href="/admin/dashboard/projects" className="p-6 rounded-2xl bg-card border border-border/50 shadow-sm hover:shadow-md hover:border-accent/40 hover:-translate-y-1 transition-all group block">
|
<Link
|
||||||
<div className="flex justify-between items-start mb-2">
|
key={card.href}
|
||||||
<h2 className="text-lg font-bold">Projects</h2>
|
href={card.href}
|
||||||
<ArrowRight size={18} className="text-muted-foreground group-hover:text-accent transition-colors" />
|
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"
|
||||||
</div>
|
>
|
||||||
<p className="text-sm text-muted-foreground mb-4">Manage portfolio projects and case studies.</p>
|
{/* Top accent bar */}
|
||||||
<div className="text-3xl font-mono font-bold text-accent">Go →</div>
|
<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`} />
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Card: Skills */}
|
<div className="flex justify-between items-start mb-4">
|
||||||
<div className="p-6 rounded-2xl bg-card border border-border/50 shadow-sm hover:shadow-md transition-shadow">
|
<div className={`p-2.5 rounded-xl bg-gradient-to-br ${card.accentClass} text-white shadow-md`}>
|
||||||
<h2 className="text-lg font-bold mb-2">Tech Stack</h2>
|
{card.icon}
|
||||||
<p className="text-sm text-muted-foreground mb-4">Update your skills and technical arsenal.</p>
|
</div>
|
||||||
<div className="text-3xl font-mono font-bold text-emerald-500">--</div>
|
<ArrowRight
|
||||||
</div>
|
size={18}
|
||||||
|
className="text-muted-foreground group-hover:text-accent group-hover:translate-x-1 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Card: Messages */}
|
<h2 className="text-lg font-bold mb-1 flex items-center gap-2">
|
||||||
<div className="p-6 rounded-2xl bg-card border border-border/50 shadow-sm hover:shadow-md transition-shadow relative overflow-hidden">
|
{card.title}
|
||||||
<h2 className="text-lg font-bold mb-2">Inbox</h2>
|
{card.badge && (
|
||||||
<p className="text-sm text-muted-foreground mb-4">Read messages from visitors.</p>
|
<span className="text-[10px] font-mono font-bold px-2 py-0.5 rounded-full bg-blue-500 text-white">
|
||||||
<div className="text-3xl font-mono font-bold text-blue-500">--</div>
|
{card.badge}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
26
src/app/[locale]/admin/dashboard/skills/[id]/edit/page.tsx
Normal file
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
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
src/app/[locale]/admin/dashboard/skills/page.tsx
Normal file
112
src/app/[locale]/admin/dashboard/skills/page.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
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" }, { 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>
|
||||||
|
<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>
|
||||||
|
</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,5 +1,6 @@
|
|||||||
import { Navbar } from "@/shared/components/navbar";
|
import { Navbar } from "@/shared/components/navbar";
|
||||||
import { Footer } from "@/shared/components/footer";
|
import { Footer } from "@/shared/components/footer";
|
||||||
|
import { WaveDivider } from "@/shared/components/wave-divider";
|
||||||
import { HeroSection } from "@/features/hero/hero-section";
|
import { HeroSection } from "@/features/hero/hero-section";
|
||||||
import { ExperienceSection } from "@/features/experience/experience-section";
|
import { ExperienceSection } from "@/features/experience/experience-section";
|
||||||
import { TechStackSection } from "@/features/skills/tech-stack-section";
|
import { TechStackSection } from "@/features/skills/tech-stack-section";
|
||||||
@@ -18,9 +19,26 @@ export default async function HomePage() {
|
|||||||
<Navbar />
|
<Navbar />
|
||||||
<main className="flex-1">
|
<main className="flex-1">
|
||||||
<HeroSection />
|
<HeroSection />
|
||||||
|
|
||||||
|
{/* Hero → Experience */}
|
||||||
<ExperienceSection />
|
<ExperienceSection />
|
||||||
|
|
||||||
|
{/* Experience → TechStack (white/dark → muted tint) */}
|
||||||
|
<div className="bg-background">
|
||||||
|
<WaveDivider fill="var(--muted)" className="opacity-30" />
|
||||||
|
</div>
|
||||||
<TechStackSection />
|
<TechStackSection />
|
||||||
|
|
||||||
|
{/* TechStack → Projects (muted tint → white/dark) */}
|
||||||
|
<div className="bg-muted/30">
|
||||||
|
<WaveDivider fill="var(--background)" />
|
||||||
|
</div>
|
||||||
<ProjectsSection initialProjects={publishedProjects} />
|
<ProjectsSection initialProjects={publishedProjects} />
|
||||||
|
|
||||||
|
{/* Projects → Contact (white/dark → muted tint) */}
|
||||||
|
<div className="bg-background">
|
||||||
|
<WaveDivider fill="var(--muted)" className="opacity-30" />
|
||||||
|
</div>
|
||||||
<ContactSection />
|
<ContactSection />
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
@@ -181,3 +181,65 @@ body {
|
|||||||
--section-py: 4rem;
|
--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);
|
||||||
|
}
|
||||||
|
|||||||
117
src/features/experience/actions.ts
Normal file
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
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
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,51 +1,36 @@
|
|||||||
"use client";
|
import { prisma } from "@/core/db/prisma";
|
||||||
|
|
||||||
import { AnimatedSection } from "@/shared/components/animated-section";
|
import { AnimatedSection } from "@/shared/components/animated-section";
|
||||||
import { SectionHeading } from "@/shared/components/section-heading";
|
import { SectionHeading } from "@/shared/components/section-heading";
|
||||||
import { Briefcase, Award, Rocket, Code2, Building2 } from "lucide-react";
|
import { Award, Briefcase, Rocket, Code2, Building2 } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
export function ExperienceSection() {
|
const ICONS = [
|
||||||
const t = useTranslations("Experience");
|
<Rocket key="rocket" size={20} />,
|
||||||
|
<Code2 key="code2" size={20} />,
|
||||||
|
<Building2 key="building2" size={20} />,
|
||||||
|
<Briefcase key="briefcase" size={20} />,
|
||||||
|
];
|
||||||
|
|
||||||
const timelineData = [
|
export async function ExperienceSection() {
|
||||||
{
|
const t = await getTranslations("Experience");
|
||||||
year: t("jobs.enterprise.year"),
|
|
||||||
title: t("jobs.enterprise.title"),
|
// Fetch from DB, fallback gracefully if empty
|
||||||
company: t("jobs.enterprise.company"),
|
const dbExperiences = await prisma.experience.findMany({
|
||||||
description: t("jobs.enterprise.description"),
|
orderBy: { order: "asc" },
|
||||||
achievements: [
|
});
|
||||||
t("jobs.enterprise.achievements.0"),
|
|
||||||
t("jobs.enterprise.achievements.1"),
|
// If DB has data, use it — otherwise show empty state
|
||||||
t("jobs.enterprise.achievements.2"),
|
const timelineData = dbExperiences.map((exp, i) => ({
|
||||||
],
|
id: exp.id,
|
||||||
icon: <Rocket size={20} />,
|
year: exp.year,
|
||||||
},
|
title: exp.title,
|
||||||
{
|
company: exp.company,
|
||||||
year: t("jobs.digital.year"),
|
description: exp.description,
|
||||||
title: t("jobs.digital.title"),
|
achievements: Array.isArray(exp.achievements)
|
||||||
company: t("jobs.digital.company"),
|
? (exp.achievements as string[])
|
||||||
description: t("jobs.digital.description"),
|
: [],
|
||||||
achievements: [
|
icon: ICONS[i % ICONS.length],
|
||||||
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} />,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="experience" className="section-padding relative">
|
<section id="experience" className="section-padding relative">
|
||||||
@@ -58,70 +43,84 @@ export function ExperienceSection() {
|
|||||||
/>
|
/>
|
||||||
</AnimatedSection>
|
</AnimatedSection>
|
||||||
|
|
||||||
{/* Timeline */}
|
{timelineData.length === 0 ? (
|
||||||
<div className="relative">
|
<div className="text-center py-16 text-muted-foreground">
|
||||||
{/* Vertical line */}
|
<Briefcase size={40} className="mx-auto mb-4 opacity-30" />
|
||||||
<div className="timeline-line" />
|
<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">
|
<div className="space-y-12 md:space-y-16">
|
||||||
{timelineData.map((item, index) => (
|
{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 ${
|
|
||||||
index % 2 === 0 ? "md:flex-row" : "md:flex-row-reverse"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{/* Content */}
|
|
||||||
<div
|
<div
|
||||||
className={`flex-1 ml-12 md:ml-0 ${
|
className={`relative flex flex-col md:flex-row items-start gap-6 md:gap-12 ${
|
||||||
index % 2 === 0 ? "md:text-right" : "md:text-left"
|
index % 2 === 0 ? "md:flex-row" : "md:flex-row-reverse"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="inline-block font-mono text-xs text-accent font-semibold tracking-wider uppercase mb-2">
|
{/* Content */}
|
||||||
{item.year}
|
<div
|
||||||
</span>
|
className={`flex-1 ml-16 md:ml-0 ${
|
||||||
<h3 className="text-xl font-bold mb-1">{item.title}</h3>
|
index % 2 === 0 ? "md:text-right" : "md:text-left"
|
||||||
<p className="text-muted-foreground text-sm font-medium mb-3 flex items-center gap-1.5 md:justify-start">
|
}`}
|
||||||
<Briefcase size={14} className="text-accent/60" />
|
>
|
||||||
{item.company}
|
<span className="inline-block font-mono text-xs text-accent font-semibold tracking-wider uppercase mb-2">
|
||||||
</p>
|
{item.year}
|
||||||
<p className="text-muted-foreground text-sm leading-relaxed mb-4">
|
</span>
|
||||||
{item.description}
|
<h3 className="text-xl font-bold mb-1">{item.title}</h3>
|
||||||
</p>
|
<p className={`text-muted-foreground text-sm font-medium mb-3 flex items-center gap-1.5 ${
|
||||||
<div className="space-y-2">
|
index % 2 === 0 ? "md:justify-end" : "md:justify-start"
|
||||||
{item.achievements.map((achievement) => (
|
}`}>
|
||||||
<div
|
<Briefcase size={14} className="text-accent/60" />
|
||||||
key={achievement}
|
{item.company}
|
||||||
className={`flex items-start gap-2 text-sm ${
|
</p>
|
||||||
index % 2 === 0
|
<p className="text-muted-foreground text-sm leading-relaxed mb-4">
|
||||||
? "md:flex-row-reverse md:text-right"
|
{item.description}
|
||||||
: ""
|
</p>
|
||||||
}`}
|
{item.achievements.length > 0 && (
|
||||||
>
|
<div className="space-y-2">
|
||||||
<Award
|
{item.achievements.map((achievement) => (
|
||||||
size={14}
|
<div
|
||||||
className="text-accent mt-0.5 flex-shrink-0"
|
key={achievement}
|
||||||
/>
|
className={`flex items-start gap-2 text-sm ${
|
||||||
<span className="text-muted-foreground">
|
index % 2 === 0
|
||||||
{achievement}
|
? "md:flex-row-reverse md:text-right"
|
||||||
</span>
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Award
|
||||||
|
size={14}
|
||||||
|
className="text-accent mt-0.5 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{achievement}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Center dot */}
|
{/* Center dot — aligned with timeline-line */}
|
||||||
<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">
|
<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}
|
{item.icon}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Empty space for other side */}
|
{/* Empty space for other side */}
|
||||||
<div className="hidden md:block flex-1" />
|
<div className="hidden md:block flex-1" />
|
||||||
</div>
|
</div>
|
||||||
</AnimatedSection>
|
</AnimatedSection>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const PROFILE_IMAGES = [
|
|||||||
|
|
||||||
export function HeroSection() {
|
export function HeroSection() {
|
||||||
const t = useTranslations("Hero");
|
const t = useTranslations("Hero");
|
||||||
|
const tTech = useTranslations("TechStack");
|
||||||
const [cards, setCards] = useState(PROFILE_IMAGES);
|
const [cards, setCards] = useState(PROFILE_IMAGES);
|
||||||
|
|
||||||
const handleDragEnd = (event: any, info: PanInfo) => {
|
const handleDragEnd = (event: any, info: PanInfo) => {
|
||||||
@@ -35,33 +36,18 @@ export function HeroSection() {
|
|||||||
<div className="absolute inset-0 grid-pattern opacity-30" />
|
<div className="absolute inset-0 grid-pattern opacity-30" />
|
||||||
|
|
||||||
{/* Decorative Blur Backgrounds */}
|
{/* 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 top-1/4 -left-32 w-96 h-96 rounded-full bg-accent/20 blur-[120px] animate-pulse-glow pointer-events-none" />
|
||||||
<div
|
<div
|
||||||
className="absolute bottom-1/4 -right-32 w-96 h-96 rounded-full bg-purple-500/15 blur-[120px] animate-pulse-glow"
|
className="absolute bottom-1/4 -right-32 w-96 h-96 rounded-full bg-purple-500/15 blur-[120px] animate-pulse-glow pointer-events-none"
|
||||||
style={{ animationDelay: "2s" }}
|
style={{ animationDelay: "2s" }}
|
||||||
/>
|
/>
|
||||||
<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]" />
|
<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] pointer-events-none" />
|
||||||
|
|
||||||
<div className="relative z-10 w-full max-w-6xl mx-auto px-6">
|
<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-2 gap-12 lg:gap-8 items-center">
|
||||||
|
|
||||||
{/* LEFT: Text Content */}
|
{/* LEFT: Text Content */}
|
||||||
<div className="text-center lg:text-left flex flex-col items-center lg:items-start order-2 lg:order-1">
|
<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 }}
|
|
||||||
transition={{ duration: 0.6, delay: 0.2 }}
|
|
||||||
className="flex items-center gap-3 mb-4"
|
|
||||||
>
|
|
||||||
<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")}
|
|
||||||
</span>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.h1
|
<motion.h1
|
||||||
initial={{ opacity: 0, y: 30 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
@@ -111,6 +97,35 @@ export function HeroSection() {
|
|||||||
{t("ctaProjects")}
|
{t("ctaProjects")}
|
||||||
</a>
|
</a>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
<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" },
|
||||||
|
{ src: "https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/postgresql/postgresql-original.svg", alt: "PostgreSQL" },
|
||||||
|
{ src: "https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/flutter/flutter-original.svg", alt: "Flutter" },
|
||||||
|
{ src: "https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/react/react-original.svg", alt: "React" },
|
||||||
|
{ src: "https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/angular/angular-original.svg", alt: "Angular" },
|
||||||
|
].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" />
|
||||||
|
))}
|
||||||
|
<img src="https://upload.wikimedia.org/wikipedia/commons/5/51/IBM_logo.svg" alt="IBM MQ" title="IBM MQ" className="w-10 h-6 md:w-12 md:h-8 object-contain 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 dark:invert" />
|
||||||
|
<div title="SWIFT Payment (MT/MX)" className="flex items-center justify-center opacity-60 grayscale transition-all duration-300 hover:opacity-100 hover:grayscale-0 hover:scale-125 hover:-translate-y-2 cursor-pointer">
|
||||||
|
<span className="font-black italic text-sm md:text-base text-[#0033A0] dark:text-[#6FA8FF] tracking-wider">SWIFT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RIGHT: Swipeable Card Deck */}
|
{/* RIGHT: Swipeable Card Deck */}
|
||||||
@@ -183,14 +198,15 @@ export function HeroSection() {
|
|||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="#experience"
|
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
|
<motion.div
|
||||||
animate={{ y: [0, 8, 0] }}
|
animate={{ y: [0, 8, 0] }}
|
||||||
transition={{ duration: 1.5, repeat: Infinity }}
|
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>
|
</motion.div>
|
||||||
</a>
|
</a>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
70
src/features/messages/actions.ts
Normal file
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,37 +3,57 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { AnimatedSection } from "@/shared/components/animated-section";
|
import { AnimatedSection } from "@/shared/components/animated-section";
|
||||||
import { SectionHeading } from "@/shared/components/section-heading";
|
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,
|
||||||
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { sendMessageAction } from "./actions";
|
||||||
|
|
||||||
export function ContactSection() {
|
export function ContactSection() {
|
||||||
const t = useTranslations("Contact");
|
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({
|
const [formData, setFormData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
email: "",
|
email: "",
|
||||||
message: "",
|
message: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setFormState("loading");
|
setFormState("loading");
|
||||||
|
setErrorMessage(null);
|
||||||
|
|
||||||
// Simulate submission (will connect to Server Action in Phase 2)
|
const fd = new FormData(e.currentTarget);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
const result = await sendMessageAction(null, fd);
|
||||||
setFormState("success");
|
|
||||||
|
|
||||||
setTimeout(() => {
|
if (result.success) {
|
||||||
setFormState("idle");
|
setFormState("success");
|
||||||
setFormData({ name: "", email: "", message: "" });
|
setFormData({ name: "", email: "", message: "" });
|
||||||
}, 3000);
|
setTimeout(() => setFormState("idle"), 4000);
|
||||||
|
} else {
|
||||||
|
setFormState("error");
|
||||||
|
setErrorMessage(result.message || "Terjadi kesalahan.");
|
||||||
|
setTimeout(() => setFormState("idle"), 5000);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="contact" className="section-padding relative bg-muted/30">
|
<section id="contact" className="section-padding relative bg-muted/30">
|
||||||
<div className="absolute inset-0 grid-pattern opacity-20" />
|
<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>
|
<AnimatedSection>
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
badge={t("badge")}
|
badge={t("badge")}
|
||||||
@@ -43,110 +63,199 @@ export function ContactSection() {
|
|||||||
</AnimatedSection>
|
</AnimatedSection>
|
||||||
|
|
||||||
<AnimatedSection delay={0.2}>
|
<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">
|
||||||
{/* Success overlay */}
|
{/* Left — Contact Info */}
|
||||||
{formState === "success" && (
|
<div className="lg:col-span-2 space-y-8">
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-card/95 rounded-2xl z-10">
|
<div>
|
||||||
<div className="text-center">
|
<h3 className="text-lg font-bold mb-4">Let's Connect</h3>
|
||||||
<div className="w-16 h-16 rounded-full bg-success/10 flex items-center justify-center mx-auto mb-4">
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
<CheckCircle size={32} className="text-success" />
|
Tertarik untuk berkolaborasi atau punya pertanyaan? Jangan ragu
|
||||||
|
untuk menghubungi saya. Saya selalu terbuka untuk peluang baru.
|
||||||
|
</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">Email</p>
|
||||||
|
<p className="text-sm text-muted-foreground font-mono">
|
||||||
|
yolando@pm.me
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold mb-2">{t("form.successTitle")}</h3>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
{t("form.successDesc")}
|
|
||||||
</p>
|
|
||||||
</div>
|
</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} />
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div>
|
||||||
{/* Name */}
|
<p className="text-sm font-semibold mb-0.5">Lokasi</p>
|
||||||
<div className="space-y-2">
|
<p className="text-sm text-muted-foreground">
|
||||||
<label
|
Jakarta, Indonesia
|
||||||
htmlFor="contact-name"
|
</p>
|
||||||
className="flex items-center gap-2 text-sm font-medium"
|
</div>
|
||||||
>
|
|
||||||
<User size={14} className="text-accent" />
|
|
||||||
{t("form.nameLabel")}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="contact-name"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) =>
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
{/* Email */}
|
<div className="p-2.5 rounded-xl bg-violet-500/10 text-violet-500 flex-shrink-0">
|
||||||
<div className="space-y-2">
|
<Clock size={18} />
|
||||||
<label
|
</div>
|
||||||
htmlFor="contact-email"
|
<div>
|
||||||
className="flex items-center gap-2 text-sm font-medium"
|
<p className="text-sm font-semibold mb-0.5">Response Time</p>
|
||||||
>
|
<p className="text-sm text-muted-foreground">
|
||||||
<Mail size={14} className="text-accent" />
|
Biasanya dalam 24 jam
|
||||||
{t("form.emailLabel")}
|
</p>
|
||||||
</label>
|
</div>
|
||||||
<input
|
|
||||||
id="contact-email"
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
value={formData.email}
|
|
||||||
onChange={(e) =>
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Message */}
|
{/* Decorative card */}
|
||||||
<div className="space-y-2">
|
<div className="hidden lg:block p-5 rounded-2xl border border-border/50 bg-card/50">
|
||||||
<label
|
<div className="flex items-center gap-2 mb-2">
|
||||||
htmlFor="contact-message"
|
<Sparkles size={16} className="text-accent" />
|
||||||
className="flex items-center gap-2 text-sm font-medium"
|
<span className="text-xs font-mono font-bold text-accent uppercase tracking-wider">
|
||||||
|
Open to Work
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
Saat ini tersedia untuk kesempatan backend/fullstack di industri
|
||||||
|
perbankan & fintech.
|
||||||
|
</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">
|
||||||
|
{/* Subtle top accent */}
|
||||||
|
<div className="absolute top-0 inset-x-0 h-0.5 bg-gradient-to-r from-accent to-purple-500 rounded-t-2xl" />
|
||||||
|
|
||||||
|
{/* Success overlay */}
|
||||||
|
{formState === "success" && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-card/95 rounded-2xl z-10">
|
||||||
|
<div className="text-center">
|
||||||
|
<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>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t("form.successDesc")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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
|
||||||
|
htmlFor="contact-name"
|
||||||
|
className="flex items-center gap-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<User size={14} className="text-accent" />
|
||||||
|
{t("form.nameLabel")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="contact-name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="contact-email"
|
||||||
|
className="flex items-center gap-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<Mail size={14} className="text-accent" />
|
||||||
|
{t("form.emailLabel")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="contact-email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="contact-message"
|
||||||
|
className="flex items-center gap-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<MessageSquare size={14} className="text-accent" />
|
||||||
|
{t("form.messageLabel")}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="contact-message"
|
||||||
|
name="message"
|
||||||
|
required
|
||||||
|
rows={5}
|
||||||
|
value={formData.message}
|
||||||
|
onChange={(e) =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={formState === "loading"}
|
||||||
|
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"
|
||||||
>
|
>
|
||||||
<MessageSquare size={14} className="text-accent" />
|
{formState === "loading" ? (
|
||||||
{t("form.messageLabel")}
|
<>
|
||||||
</label>
|
<Loader2 size={16} className="animate-spin" />
|
||||||
<textarea
|
{t("form.submitting")}
|
||||||
id="contact-message"
|
</>
|
||||||
required
|
) : (
|
||||||
rows={5}
|
<>
|
||||||
value={formData.message}
|
<Send size={16} />
|
||||||
onChange={(e) =>
|
{t("form.submit")}
|
||||||
setFormData((prev) => ({ ...prev, message: e.target.value }))
|
</>
|
||||||
}
|
)}
|
||||||
placeholder={t("form.messagePlaceholder")}
|
</button>
|
||||||
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"
|
</form>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Submit */}
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
{formState === "loading" ? (
|
|
||||||
<>
|
|
||||||
<Loader2 size={16} className="animate-spin" />
|
|
||||||
{t("form.submitting")}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Send size={16} />
|
|
||||||
{t("form.submit")}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</AnimatedSection>
|
</AnimatedSection>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
52
src/features/messages/message-actions.tsx
Normal file
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -64,6 +64,21 @@ export function ProjectsSection({ initialProjects }: { initialProjects?: any[] }
|
|||||||
</div>
|
</div>
|
||||||
</AnimatedSection>
|
</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 */}
|
{/* Projects grid */}
|
||||||
<motion.div
|
<motion.div
|
||||||
layout
|
layout
|
||||||
@@ -79,7 +94,7 @@ export function ProjectsSection({ initialProjects }: { initialProjects?: any[] }
|
|||||||
exit={{ opacity: 0, scale: 0.9 }}
|
exit={{ opacity: 0, scale: 0.9 }}
|
||||||
transition={{ duration: 0.3 }}
|
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">
|
||||||
{/* Category badge */}
|
{/* Category badge */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<span
|
<span
|
||||||
@@ -123,12 +138,13 @@ export function ProjectsSection({ initialProjects }: { initialProjects?: any[] }
|
|||||||
|
|
||||||
{/* Image banner */}
|
{/* Image banner */}
|
||||||
{project.imageUrl && (
|
{project.imageUrl && (
|
||||||
<div className="w-full h-40 mb-4 rounded-xl overflow-hidden bg-muted/20 relative group-hover:shadow-md transition-shadow">
|
<div className="w-full h-40 mb-4 rounded-xl overflow-hidden bg-muted/20 relative">
|
||||||
<img
|
<img
|
||||||
src={project.imageUrl}
|
src={project.imageUrl}
|
||||||
alt={project.title}
|
alt={project.title}
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -166,6 +182,8 @@ export function ProjectsSection({ initialProjects }: { initialProjects?: any[] }
|
|||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
97
src/features/skills/actions.ts
Normal file
97
src/features/skills/actions.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/features/skills/delete-skill-button.tsx
Normal file
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
src/features/skills/skill-form.tsx
Normal file
165
src/features/skills/skill-form.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
"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 <span className="text-muted-foreground font-normal">(opsional — untuk ikon di Hero)</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={`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="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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/features/skills/skill-icon.tsx
Normal file
25
src/features/skills/skill-icon.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={`https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/${iconName}/${iconName}-original.svg`}
|
||||||
|
alt={name}
|
||||||
|
className="w-9 h-9 object-contain"
|
||||||
|
onError={() => setErrored(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,80 +1,57 @@
|
|||||||
"use client";
|
import { prisma } from "@/core/db/prisma";
|
||||||
|
|
||||||
import { AnimatedSection } from "@/shared/components/animated-section";
|
import { AnimatedSection } from "@/shared/components/animated-section";
|
||||||
import { SectionHeading } from "@/shared/components/section-heading";
|
import { SectionHeading } from "@/shared/components/section-heading";
|
||||||
import {
|
import { Layers, Code2 } from "lucide-react";
|
||||||
Database,
|
import { getTranslations } from "next-intl/server";
|
||||||
Server,
|
|
||||||
Smartphone,
|
|
||||||
Globe,
|
|
||||||
Container,
|
|
||||||
Shield,
|
|
||||||
Cpu,
|
|
||||||
Layers,
|
|
||||||
GitBranch,
|
|
||||||
MonitorSmartphone,
|
|
||||||
Cloud,
|
|
||||||
Workflow,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
|
|
||||||
export function TechStackSection() {
|
const CATEGORY_CONFIG: Record<
|
||||||
const t = useTranslations("TechStack");
|
string,
|
||||||
|
{ label: string; accent: string; descKey: string }
|
||||||
|
> = {
|
||||||
|
backend: {
|
||||||
|
label: "Enterprise Backend",
|
||||||
|
accent: "from-blue-500 to-cyan-500",
|
||||||
|
descKey: "categories.backend.description",
|
||||||
|
},
|
||||||
|
infra: {
|
||||||
|
label: "Database & Infrastructure",
|
||||||
|
accent: "from-emerald-500 to-teal-500",
|
||||||
|
descKey: "categories.infra.description",
|
||||||
|
},
|
||||||
|
frontend: {
|
||||||
|
label: "Frontend Development",
|
||||||
|
accent: "from-violet-500 to-purple-500",
|
||||||
|
descKey: "categories.frontend.description",
|
||||||
|
},
|
||||||
|
mobile: {
|
||||||
|
label: "Mobile Development",
|
||||||
|
accent: "from-orange-500 to-rose-500",
|
||||||
|
descKey: "categories.mobile.description",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const techCategories = [
|
const CATEGORY_ORDER = ["backend", "infra", "frontend", "mobile"];
|
||||||
{
|
|
||||||
title: t("categories.backend.title"),
|
export async function TechStackSection() {
|
||||||
description: t("categories.backend.description"),
|
const t = await getTranslations("TechStack");
|
||||||
accent: "from-blue-500 to-cyan-500",
|
|
||||||
items: [
|
const skills = await prisma.skill.findMany({
|
||||||
{ name: "Java", icon: <Cpu size={22} /> },
|
orderBy: [{ category: "asc" }, { name: "asc" }],
|
||||||
{ name: "Spring Boot", icon: <Layers size={22} /> },
|
});
|
||||||
{ name: "Apache Kafka", icon: <Workflow size={22} /> },
|
|
||||||
{ name: "REST API", icon: <Server size={22} /> },
|
// Group by category
|
||||||
{ name: "gRPC", icon: <GitBranch size={22} /> },
|
const grouped: Record<string, typeof skills> = {};
|
||||||
{ name: "Spring Security", icon: <Shield size={22} /> },
|
for (const skill of skills) {
|
||||||
],
|
if (!grouped[skill.category]) grouped[skill.category] = [];
|
||||||
},
|
grouped[skill.category].push(skill);
|
||||||
{
|
}
|
||||||
title: t("categories.infra.title"),
|
|
||||||
description: t("categories.infra.description"),
|
const categories = CATEGORY_ORDER.filter(
|
||||||
accent: "from-emerald-500 to-teal-500",
|
(cat) => grouped[cat] && grouped[cat].length > 0
|
||||||
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} /> },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("categories.frontend.title"),
|
|
||||||
description: t("categories.frontend.description"),
|
|
||||||
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} /> },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("categories.mobile.title"),
|
|
||||||
description: t("categories.mobile.description"),
|
|
||||||
accent: "from-orange-500 to-rose-500",
|
|
||||||
items: [
|
|
||||||
{ name: "React Native", icon: <Smartphone size={22} /> },
|
|
||||||
{ name: "Flutter", icon: <MonitorSmartphone size={22} /> },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section id="tech-stack" className="section-padding relative bg-muted/30">
|
||||||
id="tech-stack"
|
|
||||||
className="section-padding relative bg-muted/30"
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 grid-pattern opacity-20" />
|
<div className="absolute inset-0 grid-pattern opacity-20" />
|
||||||
|
|
||||||
<div className="relative max-w-6xl mx-auto px-6">
|
<div className="relative max-w-6xl mx-auto px-6">
|
||||||
@@ -86,43 +63,71 @@ export function TechStackSection() {
|
|||||||
/>
|
/>
|
||||||
</AnimatedSection>
|
</AnimatedSection>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
{categories.length === 0 ? (
|
||||||
{techCategories.map((category, catIndex) => (
|
<div className="text-center py-16 text-muted-foreground">
|
||||||
<AnimatedSection key={category.title} delay={catIndex * 0.1}>
|
<Code2 size={40} className="mx-auto mb-4 opacity-30" />
|
||||||
<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">
|
<p className="text-sm">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
Belum ada tech stack. Tambahkan melalui{" "}
|
||||||
<div
|
<span className="font-mono text-accent">Admin Dashboard</span>.
|
||||||
className={`w-10 h-10 rounded-xl bg-gradient-to-br ${category.accent} flex items-center justify-center text-white shadow-lg`}
|
</p>
|
||||||
>
|
</div>
|
||||||
<Layers size={18} />
|
) : (
|
||||||
</div>
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
{categories.map((cat, catIndex) => {
|
||||||
<h3 className="font-bold text-sm">{category.title}</h3>
|
const config = CATEGORY_CONFIG[cat] ?? {
|
||||||
<p className="text-xs text-muted-foreground">
|
label: cat,
|
||||||
{category.description}
|
accent: "from-gray-500 to-gray-600",
|
||||||
</p>
|
descKey: "",
|
||||||
</div>
|
};
|
||||||
</div>
|
const items = grouped[cat];
|
||||||
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
return (
|
||||||
{category.items.map((tech) => (
|
<AnimatedSection key={cat} delay={catIndex * 0.1}>
|
||||||
<div
|
<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">
|
||||||
key={tech.name}
|
<div className="flex items-center gap-3 mb-4">
|
||||||
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"
|
<div
|
||||||
>
|
className={`w-10 h-10 rounded-xl bg-gradient-to-br ${config.accent} flex items-center justify-center text-white shadow-lg`}
|
||||||
<span className="text-muted-foreground group-hover/item:text-accent transition-colors">
|
>
|
||||||
{tech.icon}
|
<Layers size={18} />
|
||||||
</span>
|
</div>
|
||||||
<span className="text-xs font-mono font-medium truncate">
|
<div>
|
||||||
{tech.name}
|
<h3 className="font-bold text-sm">{config.label}</h3>
|
||||||
</span>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{config.descKey ? t(config.descKey as any) : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||||
</div>
|
{items.map((skill) => (
|
||||||
</AnimatedSection>
|
<div
|
||||||
))}
|
key={skill.id}
|
||||||
</div>
|
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"
|
||||||
|
>
|
||||||
|
{skill.iconName ? (
|
||||||
|
<img
|
||||||
|
src={`https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/${skill.iconName}/${skill.iconName}-original.svg`}
|
||||||
|
alt={skill.name}
|
||||||
|
className="w-5 h-5 object-contain flex-shrink-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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">
|
||||||
|
{skill.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AnimatedSection>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,69 +1,118 @@
|
|||||||
import { GitFork, Link2, Mail, ArrowUp } from "lucide-react";
|
import { GitFork, Link2, Mail, ArrowUp, Heart, MapPin } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
const quickLinks = [
|
||||||
|
{ href: "#experience", label: "Experience" },
|
||||||
|
{ href: "#tech-stack", label: "Tech Stack" },
|
||||||
|
{ href: "#projects", label: "Projects" },
|
||||||
|
{ href: "#contact", label: "Contact" },
|
||||||
|
];
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
const t = useTranslations("Footer");
|
const t = useTranslations("Footer");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="relative border-t border-border/50 bg-card/50">
|
<footer className="relative border-t border-border/50 bg-card/50">
|
||||||
<div className="max-w-6xl mx-auto px-6 py-12">
|
{/* Gradient top line */}
|
||||||
<div className="flex flex-col md:flex-row items-center justify-between gap-6">
|
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-accent/50 to-transparent" />
|
||||||
{/* Brand */}
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="max-w-6xl mx-auto px-6 py-16">
|
||||||
<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">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-10">
|
||||||
A
|
{/* Brand & tagline */}
|
||||||
|
<div className="md:col-span-1">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<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">
|
||||||
|
A
|
||||||
|
</div>
|
||||||
|
<span className="font-mono font-bold text-lg tracking-tight">
|
||||||
|
ando<span className="text-accent">.dev</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed mb-4 max-w-xs">
|
||||||
|
Building secure, scalable, enterprise-grade systems.
|
||||||
|
Specializing in banking & fintech infrastructure.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground/60">
|
||||||
|
<MapPin size={12} />
|
||||||
|
<span>Jakarta, Indonesia</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-mono font-bold tracking-tight">
|
|
||||||
ando<span className="text-accent">.dev</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Social Links */}
|
{/* Quick Links */}
|
||||||
<div className="flex items-center gap-3">
|
<div>
|
||||||
<a
|
<h4 className="text-sm font-bold mb-4 uppercase tracking-wider text-muted-foreground">
|
||||||
href="https://github.com"
|
Quick Links
|
||||||
target="_blank"
|
</h4>
|
||||||
rel="noopener noreferrer"
|
<div className="space-y-2.5">
|
||||||
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"
|
{quickLinks.map((link) => (
|
||||||
aria-label="GitHub"
|
<a
|
||||||
>
|
key={link.href}
|
||||||
<GitFork size={18} />
|
href={link.href}
|
||||||
</a>
|
className="block text-sm text-muted-foreground hover:text-accent transition-colors"
|
||||||
<a
|
>
|
||||||
href="https://linkedin.com"
|
{link.label}
|
||||||
target="_blank"
|
</a>
|
||||||
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"
|
</div>
|
||||||
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"
|
|
||||||
aria-label="Email"
|
|
||||||
>
|
|
||||||
<Mail size={18} />
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Back to top */}
|
{/* Social & Back to top */}
|
||||||
<a
|
<div className="flex flex-col items-start md:items-end gap-6">
|
||||||
href="#"
|
<div>
|
||||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-accent transition-colors group"
|
<h4 className="text-sm font-bold mb-4 uppercase tracking-wider text-muted-foreground md:text-right">
|
||||||
>
|
Connect
|
||||||
{t("backToTop")}
|
</h4>
|
||||||
<ArrowUp
|
<div className="flex items-center gap-3">
|
||||||
size={14}
|
<a
|
||||||
className="transition-transform group-hover:-translate-y-0.5"
|
href="https://github.com/yolandoando"
|
||||||
/>
|
target="_blank"
|
||||||
</a>
|
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 hover:shadow-lg hover:shadow-accent/20"
|
||||||
|
aria-label="GitHub"
|
||||||
|
>
|
||||||
|
<GitFork size={18} />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://linkedin.com/in/yolandoando"
|
||||||
|
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 hover:shadow-lg hover:shadow-accent/20"
|
||||||
|
aria-label="LinkedIn"
|
||||||
|
>
|
||||||
|
<Link2 size={18} />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="mailto:yolando@pm.me"
|
||||||
|
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 mt-auto"
|
||||||
|
>
|
||||||
|
{t("backToTop")}
|
||||||
|
<ArrowUp
|
||||||
|
size={14}
|
||||||
|
className="transition-transform group-hover:-translate-y-1"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 pt-6 border-t border-border/30 text-center">
|
{/* Bottom bar */}
|
||||||
<p className="text-sm text-muted-foreground font-mono">
|
<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")}
|
© {new Date().getFullYear()} {t("copyright")}
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ import { ThemeToggle } from "@/shared/components/theme-toggle";
|
|||||||
import { useTranslations, useLocale } from "next-intl";
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
import { usePathname, useRouter } from "@/i18n/routing";
|
import { usePathname, useRouter } from "@/i18n/routing";
|
||||||
|
|
||||||
|
const SECTIONS = ["experience", "tech-stack", "projects", "contact"];
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [activeSection, setActiveSection] = useState("");
|
||||||
const t = useTranslations("Navigation");
|
const t = useTranslations("Navigation");
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -22,11 +25,32 @@ export function Navbar() {
|
|||||||
return () => window.removeEventListener("scroll", handleScroll);
|
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 = [
|
const navLinks = [
|
||||||
{ href: "#experience", label: t("experience") },
|
{ href: "#experience", id: "experience", label: t("experience") },
|
||||||
{ href: "#tech-stack", label: t("techStack") },
|
{ href: "#tech-stack", id: "tech-stack", label: t("techStack") },
|
||||||
{ href: "#projects", label: t("projects") },
|
{ href: "#projects", id: "projects", label: t("projects") },
|
||||||
{ href: "#contact", label: t("contact") },
|
{ href: "#contact", id: "contact", label: t("contact") },
|
||||||
];
|
];
|
||||||
|
|
||||||
const switchLocale = (newLocale: string) => {
|
const switchLocale = (newLocale: string) => {
|
||||||
@@ -48,9 +72,9 @@ export function Navbar() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<nav className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
<nav className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
{/* Logo */}
|
{/* #12 Logo with hover glow */}
|
||||||
<a href="#" className="flex items-center gap-2 group">
|
<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">
|
<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/50 group-hover:scale-110 transition-all duration-300">
|
||||||
A
|
A
|
||||||
</div>
|
</div>
|
||||||
<span className="font-mono font-bold text-lg tracking-tight">
|
<span className="font-mono font-bold text-lg tracking-tight">
|
||||||
@@ -58,16 +82,24 @@ export function Navbar() {
|
|||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{/* Desktop Nav */}
|
{/* Desktop Nav with scroll spy */}
|
||||||
<div className="hidden md:flex items-center gap-1">
|
<div className="hidden md:flex items-center gap-1">
|
||||||
{navLinks.map((link) => (
|
{navLinks.map((link) => (
|
||||||
<a
|
<a
|
||||||
key={link.href}
|
key={link.href}
|
||||||
href={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}
|
{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>
|
</a>
|
||||||
))}
|
))}
|
||||||
<div className="ml-4 pl-4 border-l border-border/50 flex items-center gap-2">
|
<div className="ml-4 pl-4 border-l border-border/50 flex items-center gap-2">
|
||||||
@@ -129,7 +161,11 @@ export function Navbar() {
|
|||||||
key={link.href}
|
key={link.href}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
onClick={() => setIsOpen(false)}
|
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}
|
{link.label}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export function SectionHeading({ badge, title, subtitle }: SectionHeadingProps)
|
|||||||
return (
|
return (
|
||||||
<div className="text-center mb-16">
|
<div className="text-center mb-16">
|
||||||
{badge && (
|
{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}
|
{badge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
37
src/shared/components/wave-divider.tsx
Normal file
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user