feat: implement initial fullstack portfolio application including dashboard, CMS, and analytics features.

This commit is contained in:
Moh Dzulfikri Maulana
2026-03-07 16:32:49 +07:00
commit bdd61d11d3
59 changed files with 11107 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET() {
const now = new Date();
await prisma.blog.updateMany({
where: {
status: "SCHEDULED",
scheduledAt: { lte: now },
},
data: {
status: "PUBLISHED",
publishedAt: now,
},
});
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import { cookies } from "next/headers";
export async function POST(req: Request) {
const { email, password } = await req.json();
console.log("API LOGIN", email, password);
const user = await prisma.user.findUnique({
where: { email },
});
if (!user) {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
const isValidPassword = await bcrypt.compare(password, user.password);
if (!isValidPassword) {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET!, {
expiresIn: "1h",
});
(await cookies()).set("token", token, {
httpOnly: true,
secure: true,
sameSite: "strict",
path: "/",
});
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,7 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
export async function POST() {
(await cookies()).delete("token");
return NextResponse.json({ success: true, message: "Logout success" });
}

View File

@@ -0,0 +1,14 @@
import { prisma } from "@/lib/prisma";
export async function POST(req: Request) {
const { visitorId, path } = await req.json();
await prisma.pageView.create({
data: {
visitorId,
path,
},
});
return Response.json({ ok: true });
}

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { z } from 'zod'
const contactSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
subject: z.string().min(4, 'Subject must be at least 4 characters'),
message: z.string().min(10, 'Message must be at least 10 characters'),
})
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const parsed = contactSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: parsed.error.errors[0].message },
{ status: 400 }
)
}
const { name, email, subject, message } = parsed.data
await prisma.contactMessage.create({
data: { name, email, subject, message },
})
return NextResponse.json({
success: true,
message: 'Message received! I will get back to you soon.',
})
} catch (error) {
console.error('Contact form error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to send message. Please try again.' },
{ status: 500 }
)
}
}

0
src/app/blog/page.tsx Normal file
View File

View File

@@ -0,0 +1,13 @@
import { LoginForm } from "@/components/dashboard/auth/LoginForm";
export default function AuthPage() {
return (
<section className="min-h-[100vh] flex items-center justify-center p-4 relative overflow-hidden bg-background">
{/* Background radial gradients for premium depth */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] bg-accent/5 rounded-full blur-[120px] pointer-events-none" />
<div className="absolute bottom-0 right-0 w-[500px] h-[500px] bg-primary/5 rounded-full blur-[100px] pointer-events-none" />
<LoginForm />
</section>
);
}

View File

@@ -0,0 +1,16 @@
import { DashboardSidebar } from "@/components/sections/portfolio/DashboardSidebar";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex min-h-screen bg-background">
<DashboardSidebar />
<div className="flex-1 flex flex-col min-w-0 md:pt-0 pt-16">
<main className="flex-1">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,7 @@
export default function CMSPage() {
return (
<section className="min-h-[100vh] flex items-center justify-center p-4 relative overflow-hidden bg-background">
<h1>CMS</h1>
</section>
);
}

193
src/app/globals.css Normal file
View File

@@ -0,0 +1,193 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 248 250 252;
--foreground: 15 23 42;
--card: 255 255 255;
--card-foreground: 15 23 42;
--popover: 255 255 255;
--popover-foreground: 15 23 42;
--primary: 99 102 241;
--primary-foreground: 255 255 255;
--secondary: 34 211 238;
--secondary-foreground: 248 250 252;
--muted: 241 245 249;
--muted-foreground: 71 85 105;
--accent: 99 102 241;
--accent-foreground: 15 23 42;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 34 211 238;
--input: 34 211 238;
--ring: 99 102 241;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 2 6 23;
--foreground: 248 250 252;
--card: 15 23 42;
--card-foreground: 248 250 252;
--popover: 15 23 42;
--popover-foreground: 248 250 252;
--primary: 99 102 241;
--primary-foreground: 255 255 255;
--secondary: 71 85 105;
--secondary-foreground: 248 250 252;
--muted: 248 250 252;
--muted-foreground: 248 250 252;
--accent: 99 102 241;
--accent-foreground: 2 6 23;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 71 85 105;
--input: 71 85 105;
--ring: 99 102 241;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
/* ─────────────────────────────────────────── */
/* DARK THEME (default) */
/* ─────────────────────────────────────────── */
html,
:root,
.dark {
--color-background: #020617;
--color-surface: #020617;
--color-surface-elevated: #0F172A;
--color-border: #475569;
--color-border-subtle: #47556980;
--color-accent: #6366F1;
--color-accent-glow: #6366F140;
--color-accent-dim: #6366F1;
--color-muted: #F8FAFC;
--color-subtle: #F8FAFC;
--color-foreground: #F8FAFC;
--color-foreground-muted: #F8FAFC;
--color-foreground-subtle: #F8FAFC;
}
/* ─────────────────────────────────────────── */
/* LIGHT THEME */
/* ─────────────────────────────────────────── */
:root.light,
.light {
--color-background: #F8FAFC;
--color-surface: #FFFFFF;
--color-surface-elevated: #F1F5F9;
--color-border: #22D3EE;
--color-border-subtle: #22D3EE60;
--color-accent: #6366F1;
--color-accent-glow: #6366F120;
--color-accent-dim: #6366F1;
--color-muted: #64748B;
--color-subtle: #475569;
--color-foreground: #0F172A;
--color-foreground-muted: #475569;
--color-foreground-subtle: #64748B;
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
html {
scroll-behavior: smooth;
scroll-padding-top: 80px;
}
body {
background-color: var(--color-background);
color: var(--color-foreground);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow-x: hidden;
transition:
background-color 0.3s ease,
color 0.3s ease;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: var(--color-background);
}
::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-accent);
}
/* Selection */
::selection {
background: var(--color-accent-glow);
color: var(--color-foreground);
}
/* Grid background utility */
.bg-grid {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' width='32' height='32' fill='none' stroke='rgb(71 85 105 / 0.4)'%3e%3cpath d='M0 .5H31.5V32'/%3e%3c/svg%3e");
}
.light .bg-grid {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' width='32' height='32' fill='none' stroke='rgb(2 6 23 / 0.3)'%3e%3cpath d='M0 .5H31.5V32'/%3e%3c/svg%3e");
}
/* Gradient text utility */
.gradient-text {
background: linear-gradient(
135deg,
var(--color-foreground) 0%,
var(--color-accent) 50%,
#22D3EE 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.gradient-text-blue {
background: linear-gradient(135deg, var(--color-accent-dim) 0%, var(--color-accent) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Noise texture overlay */
.noise::after {
content: "";
position: absolute;
inset: 0;
opacity: 0.03;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
pointer-events: none;
}
/* Glow effect */
.glow-blue {
box-shadow: 0 0 30px var(--color-accent-glow);
}
/* Timeline */
.timeline-line {
background: linear-gradient(to bottom, var(--color-accent), transparent);
}

119
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,119 @@
import type { Metadata } from "next";
import { Syne, DM_Sans, DM_Mono } from "next/font/google";
import "./globals.css";
import { Navbar } from "@/components/sections/portfolio/Navbar";
import { Footer } from "@/components/sections/portfolio/Footer";
import { ThemeProvider } from "@/components/ThemeProvider";
import { Analytics } from "@vercel/analytics/react";
const syne = Syne({
subsets: ["latin"],
weight: ["400", "500", "600", "700", "800"],
variable: "--font-display",
display: "swap",
});
const dmSans = DM_Sans({
subsets: ["latin"],
weight: ["300", "400", "500", "600"],
style: ["normal", "italic"],
variable: "--font-sans",
display: "swap",
});
const dmMono = DM_Mono({
subsets: ["latin"],
weight: ["300", "400", "500"],
variable: "--font-mono",
display: "swap",
});
export const metadata: Metadata = {
metadataBase: new URL(
process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
),
title: {
default: "Fikri — Fullstack Developer",
template: "%s | Fikri",
},
description:
"Fullstack Developer with 3+ years of experience building scalable and production-ready web applications using Next.js, NestJS, and PostgreSQL. Focused on clean architecture, performance, and maintainable systems.",
keywords: [
"Fikri",
"Fullstack Developer",
"Next.js Developer",
"NestJS Developer",
"TypeScript",
"React",
"Node.js",
"PostgreSQL",
"Prisma",
"Clean Architecture",
"Web Application Development",
],
authors: [{ name: "Fikri" }],
creator: "Fikri",
openGraph: {
type: "website",
locale: "en_US",
url: "https://yourportfolio.dev", // ganti dengan domain kamu
siteName: "Fikri — Fullstack Developer",
title: "Fikri — Fullstack Developer",
description:
"Fullstack Developer specializing in Next.js & NestJS with 3+ years of experience building scalable and production-ready web applications.",
images: [
{
url: "/og-image.png",
width: 1200,
height: 630,
alt: "Fikri — Fullstack Developer",
},
],
},
twitter: {
card: "summary_large_image",
title: "Fikri — Fullstack Developer",
description:
"Next.js & NestJS Specialist | Clean Architecture | Scalable Systems",
images: ["/og-image.png"],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${syne.variable} ${dmSans.variable} ${dmMono.variable} dark`}
suppressHydrationWarning
>
<body className="bg-background text-foreground antialiased">
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem
disableTransitionOnChange={false}
>
<Navbar />
<main>{children}</main>
<Footer />
<Analytics />
</ThemeProvider>
</body>
</html>
);
}

161
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,161 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
import { Home, MoveLeft, Terminal as TerminalIcon, AlertTriangle } from "lucide-react";
import { useEffect, useState } from "react";
export default function NotFound() {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
return (
<div className="relative min-h-[calc(100vh-80px)] flex flex-col items-center justify-center overflow-hidden px-6">
{/* Background effects */}
<div
className="absolute inset-0 bg-grid opacity-20"
aria-hidden="true"
/>
<div
className="absolute inset-0 bg-gradient-to-b from-background via-background/95 to-background"
aria-hidden="true"
/>
{/* Blur orbs */}
<div
className="absolute top-1/4 left-1/4 w-96 h-96 bg-accent/20 rounded-full blur-[120px] pointer-events-none animate-pulse-slow"
aria-hidden="true"
/>
<div
className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-purple-500/15 rounded-full blur-[100px] pointer-events-none animate-pulse-slow"
style={{ animationDelay: "2s" }}
aria-hidden="true"
/>
{/* Floating Icons */}
<div className="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
{[...Array(6)].map((_, i) => (
<motion.div
key={i}
initial={{ opacity: 0, scale: 0 }}
animate={{
opacity: [0.05, 0.15, 0.05],
scale: [1, 1.2, 1],
x: [0, Math.random() * 40 - 20, 0],
y: [0, Math.random() * 40 - 20, 0]
}}
transition={{
duration: 5 + Math.random() * 5,
repeat: Infinity,
delay: Math.random() * 5
}}
className="absolute"
style={{
top: `${Math.random() * 100}%`,
left: `${Math.random() * 100}%`,
}}
>
<div className="p-4 rounded-xl border border-border/20 bg-surface-elevated/10 backdrop-blur-sm">
<TerminalIcon size={24} className="text-accent/30" />
</div>
</motion.div>
))}
</div>
<div className="relative z-10 max-w-2xl w-full text-center">
{/* Error Code Container */}
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] }}
className="mb-12 relative"
>
{/* Main Icon */}
<div className="inline-flex items-center justify-center w-28 h-28 rounded-[2rem] bg-surface-elevated border border-border/50 shadow-2xl mb-8 relative group">
<div className="absolute inset-0 bg-accent/30 rounded-[2rem] blur-2xl group-hover:bg-accent/50 transition-all duration-500" />
<AlertTriangle size={56} className="text-accent relative z-10" />
</div>
<div className="relative">
<h1 className="font-display font-extrabold text-[120px] md:text-[180px] tracking-[ -0.05em] leading-none mb-4 select-none">
<span className="text-foreground -mr-4 md:-mr-8">4</span>
<span className="gradient-text drop-shadow-glow">0</span>
<span className="text-foreground -ml-4 md:-ml-8">4</span>
</h1>
<motion.div
animate={{ opacity: [0.4, 1, 0.4] }}
transition={{ duration: 2, repeat: Infinity }}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full h-full bg-accent/5 blur-[120px] pointer-events-none"
/>
</div>
</motion.div>
{/* Message */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
className="space-y-4"
>
<h2 className="text-3xl md:text-5xl font-display font-bold text-foreground tracking-tight">
Oops! You&apos;re <span className="text-accent italic">Lost</span>
</h2>
<p className="text-foreground-muted text-lg md:text-xl mb-12 max-w-lg mx-auto leading-relaxed">
The page you are looking for has vanished into the digital void.
Don&apos;t worry, we can find our way back together.
</p>
</motion.div>
{/* Actions */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.5 }}
className="flex flex-col sm:flex-row items-center justify-center gap-6 mt-12"
>
<Link
href="/"
className="group relative inline-flex items-center gap-3 px-10 py-5 bg-accent text-white font-bold rounded-2xl hover:bg-accent/90 transition-all hover:shadow-glow hover:-translate-y-1.5 duration-300 w-full sm:w-auto justify-center overflow-hidden"
>
<div className="absolute inset-0 w-3/4 h-full bg-white/20 -skew-x-[45deg] -translate-x-full group-hover:translate-x-[200%] transition-transform duration-1000" />
<Home size={20} className="relative z-10" />
<span className="relative z-10">Back to Home</span>
</Link>
<button
onClick={() => window.history.back()}
className="inline-flex items-center gap-3 px-10 py-5 bg-surface-elevated/50 backdrop-blur-md border border-border text-foreground font-semibold rounded-2xl hover:border-accent/60 hover:bg-surface-elevated/80 transition-all hover:-translate-y-1.5 duration-300 w-full sm:w-auto justify-center"
>
<MoveLeft size={20} className="text-accent" />
Return Back
</button>
</motion.div>
{/* Terminal Status */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1, delay: 1 }}
className="mt-20 flex items-center justify-center gap-4 text-foreground-subtle font-mono text-xs md:text-sm tracking-widest uppercase"
>
<div className="flex gap-2">
<div className="w-2 h-2 rounded-full bg-red-500/50" />
<div className="w-2 h-2 rounded-full bg-yellow-500/50" />
<div className="w-2 h-2 rounded-full bg-green-500/50" />
</div>
<span className="border-l border-border/30 pl-4">System Error: 404_PAGE_NOT_FOUND</span>
<span className="w-2 h-5 bg-accent animate-pulse" />
</motion.div>
</div>
{/* Noise Texture */}
<div className="noise pointer-events-none" aria-hidden="true" />
</div>
);
}

134
src/app/page.tsx Normal file
View File

@@ -0,0 +1,134 @@
import { HeroSection } from "@/components/sections/portfolio/HeroSection";
import { AboutSection } from "@/components/sections/portfolio/AboutSection";
import { TechStackSection } from "@/components/sections/portfolio/TechStackSection";
import { ProjectsSection } from "@/components/sections/portfolio/ProjectsSection";
import { ExperienceSection } from "@/components/sections/portfolio/ExperienceSection";
import { PrincipleSection } from "@/components/sections/portfolio/PrincipleSection";
import { ContactSection } from "@/components/sections/portfolio/ContactSection";
import { prisma } from "@/lib/prisma";
const fallbackProjects = [
{
id: "1",
title: "Enterprise ERP System",
description:
"Full-scale ERP system built with NestJS microservices, handling inventory, finance, and HR modules for 500+ concurrent users.",
imageUrl: null,
liveUrl: null,
githubUrl: "https://github.com",
techStack: ["NestJS", "Next.js", "PostgreSQL", "Redis", "Docker"],
featured: true,
order: 1,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "2",
title: "Real-time Analytics Dashboard",
description:
"Interactive analytics platform with real-time data visualization, WebSocket integration, and multi-tenant architecture.",
imageUrl: null,
liveUrl: null,
githubUrl: "https://github.com",
techStack: ["Next.js", "NestJS", "Prisma", "Chart.js", "PostgreSQL"],
featured: true,
order: 2,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "3",
title: "API Gateway & Auth Service",
description:
"Scalable API Gateway with JWT authentication, rate limiting, and microservice orchestration using Clean Architecture principles.",
imageUrl: null,
liveUrl: null,
githubUrl: "https://github.com",
techStack: ["NestJS", "Redis", "JWT", "Docker", "Kubernetes"],
featured: true,
order: 3,
createdAt: new Date(),
updatedAt: new Date(),
},
];
const fallbackExperiences = [
{
id: "1",
company: "Tech Corp Indonesia",
role: "Senior Fullstack Engineer",
startDate: new Date("2022-01-01"),
endDate: null,
current: true,
description:
"Leading development of enterprise-grade web applications with focus on scalability and clean architecture.",
highlights: [
"Architected microservices-based ERP system serving 500+ concurrent users",
"Reduced API response time by 60% through Redis caching & query optimization",
"Mentored 3 junior developers on NestJS patterns and SOLID principles",
],
techStack: ["Next.js", "NestJS", "PostgreSQL", "Redis", "Docker"],
order: 1,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "2",
company: "Startup Fintech",
role: "Fullstack Developer",
startDate: new Date("2020-03-01"),
endDate: new Date("2021-12-31"),
current: false,
description:
"Built core financial services platform from ground up with emphasis on security and performance.",
highlights: [
"Developed payment processing system handling Rp 50B+ monthly transactions",
"Built real-time notification system using WebSockets and Redis pub/sub",
"Implemented automated testing achieving 85%+ code coverage",
],
techStack: ["React", "Node.js", "Express", "MongoDB", "PostgreSQL"],
order: 2,
createdAt: new Date(),
updatedAt: new Date(),
},
];
async function getProjects() {
try {
return await prisma.project.findMany({
where: { featured: true },
orderBy: { order: "asc" },
});
} catch {
return fallbackProjects;
}
}
async function getExperiences() {
try {
return await prisma.experience.findMany({
orderBy: { order: "asc" },
});
} catch {
return fallbackExperiences;
}
}
export default async function HomePage() {
const [projects, experiences] = await Promise.all([
getProjects(),
getExperiences(),
]);
return (
<>
<HeroSection />
<AboutSection />
<TechStackSection />
<ProjectsSection projects={projects} />
<ExperienceSection experiences={experiences} />
<PrincipleSection />
<ContactSection />
</>
);
}

View File

@@ -0,0 +1,163 @@
"use client";
import { motion } from "framer-motion";
import { useEffect, useRef, useState } from "react";
type Step =
| { type: "command"; text: string }
| { type: "log"; text: string }
| { type: "progress"; text: string };
const steps: Step[] = [
{ type: "command", text: "git clone fikri-erp" },
{ type: "command", text: "cd fikri-erp" },
{ type: "command", text: "docker build -t fikri-erp ." },
{ type: "log", text: "building docker image..." },
{ type: "progress", text: "docker build" },
{ type: "log", text: "✓ docker image built successfully" },
{ type: "command", text: "docker-compose up -d" },
{ type: "log", text: "starting postgres container..." },
{ type: "log", text: "✓ postgres container running" },
{ type: "log", text: "starting api container..." },
{ type: "log", text: "✓ application deployed successfully" },
{ type: "log", text: "✓ ready on https://fikri-erp.com" },
];
export default function TerminalWindow() {
const [lines, setLines] = useState<string[]>([]);
const [typing, setTyping] = useState("");
const [stepIndex, setStepIndex] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const step = steps[stepIndex];
useEffect(() => {
if (!step) return;
if (step.type === "command") {
let i = 0;
const interval = setInterval(() => {
setTyping(step.text.slice(0, i + 1));
i++;
if (i === step.text.length) {
clearInterval(interval);
setTimeout(() => {
setLines((prev) => [...prev, `$ ${step.text}`]);
setTyping("");
setStepIndex((s) => s + 1);
}, 700);
}
}, 45);
return () => clearInterval(interval);
}
if (step.type === "log") {
const timeout = setTimeout(() => {
setLines((prev) => [...prev, step.text]);
setStepIndex((s) => s + 1);
}, 800);
return () => clearTimeout(timeout);
}
if (step.type === "progress") {
let progress = 0;
const interval = setInterval(() => {
progress += 10;
setTyping(
`${step.text} [${"█".repeat(progress / 10)}${" ".repeat(
10 - progress / 10
)}] ${progress}%`
);
if (progress === 100) {
clearInterval(interval);
setTimeout(() => {
setLines((prev) => [...prev, typing]);
setTyping("");
setStepIndex((s) => s + 1);
}, 400);
}
}, 120);
return () => clearInterval(interval);
}
}, [stepIndex]);
useEffect(() => {
containerRef.current?.scrollTo({
top: containerRef.current.scrollHeight,
behavior: "smooth",
});
}, [lines, typing]);
// loop animation
useEffect(() => {
if (stepIndex >= steps.length) {
const reset = setTimeout(() => {
setLines([]);
setTyping("");
setStepIndex(0);
}, 2500);
return () => clearTimeout(reset);
}
}, [stepIndex]);
return (
<div className="w-[320px] h-[200px] rounded-lg border border-border bg-surface-elevated shadow-xl overflow-hidden font-mono">
{/* window header */}
<div className="flex items-center px-2 py-1.5 border-b border-border bg-surface-elevated/70">
<div className="flex gap-1.5">
<div className="w-2 h-2 bg-red-500 rounded-full"/>
<div className="w-2 h-2 bg-yellow-500 rounded-full"/>
<div className="w-2 h-2 bg-green-500 rounded-full"/>
</div>
<span className="ml-2 text-xs text-foreground-muted">
fikri@dev-terminal
</span>
</div>
{/* tabs */}
<div className="flex gap-1.5 px-2 py-1 border-b border-border bg-surface-elevated/50 text-xs text-foreground-muted">
<div className="px-1.5 py-0.5 bg-surface-elevated rounded text-accent">bash</div>
<div className="px-1.5 py-0.5 hover:bg-surface-elevated rounded text-foreground-subtle">server</div>
<div className="px-1.5 py-0.5 hover:bg-surface-elevated rounded text-foreground-subtle">logs</div>
</div>
{/* terminal body */}
<div
ref={containerRef}
className="p-2 text-xs text-foreground h-[150px] overflow-hidden space-y-0.5"
>
{lines.map((line, i) => (
<div key={i}>{line}</div>
))}
{typing && (
<div className="flex">
<span>{typing}</span>
<motion.span
animate={{ opacity: [0, 1, 0] }}
transition={{ repeat: Infinity, duration: 1 }}
className="ml-1"
>
|
</motion.span>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,8 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import type { ThemeProviderProps } from "next-themes";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@@ -0,0 +1,143 @@
"use client";
import { useState } from "react";
import { motion } from "framer-motion";
import { Terminal, Lock, Mail, ArrowRight, Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
export function LoginForm() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError("");
try {
const res = await fetch("/api/dashboard/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error);
return;
}
router.push("/dashboard/cms");
} catch (error: unknown) {
if (error instanceof Error) {
setError(error.message);
}
} finally {
setIsLoading(false);
}
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: "easeOut" }}
className="w-full max-w-md relative z-10"
>
<div className="bg-surface/50 backdrop-blur-xl border border-border/50 rounded-2xl p-8 shadow-2xl relative overflow-hidden">
{/* Subtle top highlight to simulate glassmorphism lighting */}
<div className="absolute top-0 left-0 right-0 h-[1px] bg-gradient-to-r from-transparent via-accent/50 to-transparent" />
{/* Header */}
<div className="flex flex-col items-center mb-8 text-center">
<div className="w-12 h-12 rounded-xl bg-accent/10 border border-accent/20 flex items-center justify-center mb-4">
<Terminal size={24} className="text-accent" />
</div>
<h1 className="font-display text-2xl font-bold text-foreground mb-2">
Welcome Back
</h1>
<p className="text-foreground-muted text-sm">
Enter your credentials to access the admin panel
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="p-3 rounded-lg bg-destructive/10 border border-destructive/20 text-destructive text-sm text-center"
>
{error}
</motion.div>
)}
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground-muted pl-1">
Email Address
</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-foreground-muted group-focus-within:text-accent transition-colors">
<Mail size={18} />
</div>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@example.com"
className="w-full bg-background/50 border border-border rounded-xl py-2.5 pl-10 pr-4 text-sm text-foreground placeholder:text-foreground-muted/50 focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-all"
required
/>
</div>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between pl-1">
<label className="text-sm font-medium text-foreground-muted">
Password
</label>
</div>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-foreground-muted group-focus-within:text-accent transition-colors">
<Lock size={18} />
</div>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="w-full bg-background/50 border border-border rounded-xl py-2.5 pl-10 pr-4 text-sm text-foreground placeholder:text-foreground-muted/50 focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-all"
required
/>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full mt-6 flex items-center justify-center gap-2 bg-accent text-background rounded-xl py-3 text-sm font-medium hover:bg-accent/90 focus:ring-2 focus:ring-offset-2 focus:ring-offset-background focus:ring-accent focus:outline-none hover:shadow-glow transition-all disabled:opacity-70 disabled:cursor-not-allowed group"
>
{isLoading ? (
<Loader2 size={18} className="animate-spin" />
) : (
<>
Sign In
<ArrowRight
size={16}
className="group-hover:translate-x-1 transition-transform"
/>
</>
)}
</button>
</form>
</div>
{/* Footer info */}
<p className="text-center text-xs text-foreground-muted mt-6">
&copy; {new Date().getFullYear()} Fikri Maulana. All rights reserved.
</p>
</motion.div>
);
}

View File

@@ -0,0 +1,21 @@
"use client";
import { useEffect } from "react";
import { getVisitorId } from "@/lib/visitor";
export function AnalyticsTracker() {
useEffect(() => {
const visitorId = getVisitorId();
fetch("/api/track", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
visitorId,
path: window.location.pathname,
}),
});
}, []);
return null;
}

View File

@@ -0,0 +1,250 @@
"use client";
import { useRef, useState, useEffect } from "react";
import { motion, useInView } from "framer-motion";
import { Code2, Server, Database, GitBranch } from "lucide-react";
const skills = [
"Next.js",
"NestJS",
"TypeScript",
"React",
"Node.js",
"PostgreSQL",
"Prisma",
"TypeORM",
"Redis",
"Docker",
"REST API",
"Git",
"Microservices",
];
const stats = [
{ label: "Years Experience", value: "3+", icon: Code2 },
{ label: "System Built", value: "15+", icon: GitBranch },
{ label: "Modules Delivered", value: "15+", icon: Server },
{ label: "Production Deployments", value: "10+", icon: Database },
];
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.08, delayChildren: 0.1 },
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0, transition: { duration: 0.5, ease: "easeOut" } },
};
// Custom hook for counting animation
const useCountingAnimation = (target: number, isInView: boolean, duration: number = 2000, delay: number = 0) => {
const [count, setCount] = useState(0);
const [shouldStart, setShouldStart] = useState(false);
useEffect(() => {
if (!isInView) {
setCount(0);
setShouldStart(false);
return;
}
// Start counting after delay
const timer = setTimeout(() => {
setShouldStart(true);
}, delay);
return () => clearTimeout(timer);
}, [isInView, delay]);
useEffect(() => {
if (!shouldStart) {
setCount(0);
return;
}
const startTime = Date.now();
const endTime = startTime + duration;
const animate = () => {
const now = Date.now();
const progress = Math.min((now - startTime) / duration, 1);
// Easing function for smooth animation
const easeOutQuad = 1 - Math.pow(1 - progress, 2);
const currentCount = Math.floor(easeOutQuad * target);
setCount(currentCount);
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}, [shouldStart, target, duration]);
return count;
};
export function AboutSection() {
const ref = useRef(null);
const statsRef = useRef(null);
const quoteRef = useRef(null);
const isInView = useInView(ref, { once: true, margin: "-10%" });
const statsInView = useInView(statsRef, { once: true, margin: "-10%" });
const quoteInView = useInView(quoteRef, { once: true, margin: "-10%" });
// Extract numeric values and use counting animation
// Calculate delay: staggerChildren: 0.08, delayChildren: 0.1, item duration: 0.5
// Stats are in the right column, so they appear after left column items
const entranceDelay = 600; // Reduced delay - after entrance animation completes
const yearsCount = useCountingAnimation(3, statsInView, 1500, entranceDelay);
const systemsCount = useCountingAnimation(15, statsInView, 2000, entranceDelay + 100);
const modulesCount = useCountingAnimation(15, statsInView, 2000, entranceDelay + 200);
const deploymentsCount = useCountingAnimation(10, statsInView, 1800, entranceDelay + 300);
return (
<section id="about" className="py-5 lg:py-32 relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-b from-background via-surface/30 to-background" />
<div className="relative z-10 max-w-6xl mx-auto px-6">
<motion.div
ref={ref}
variants={containerVariants}
initial="hidden"
animate={isInView ? "visible" : "hidden"}
className="grid lg:grid-cols-2 gap-16 items-start"
>
{/* Left: Text content */}
<div>
<motion.div variants={itemVariants} className="mb-3">
<span className="text-accent font-mono text-sm tracking-widest uppercase">
About Me
</span>
</motion.div>
<motion.h2
variants={itemVariants}
className="font-display font-bold text-4xl md:text-5xl text-foreground mb-6 leading-tight"
>
Engineering Beyond
<br />
<span className="gradient-text-blue">Just Code</span>
</motion.h2>
<motion.div
variants={itemVariants}
className="space-y-4 text-foreground-muted leading-relaxed"
>
<p>
I'm a Fullstack Developer with 3+ years of experience building production-ready web applications using{" "}
<span className="text-foreground font-medium">Next.js</span> and{" "}
<span className="text-foreground font-medium">NestJS</span>.
My experience includes developing WhatsApp chatbot integrations,
building high-traffic lottery web applications, creating internal business dashboards,
and contributing to ERP-based POS systems.
</p>
<p>
My expertise spans scalable frontend architecture with Next.js,
Zustand, Tailwind CSS, and Framer Motion, as well as secure
backend systems using{" "}
<span className="text-foreground font-medium">NestJS</span>,{" "}
<span className="text-foreground font-medium">PostgreSQL</span>,
and <span className="text-foreground font-medium">MongoDB</span>
.
</p>
<p>
I focus on writing clean, maintainable code while ensuring
performance, validation, and system reliability. Every feature I
build is designed with scalability and long-term maintainability
in mind.
</p>
<p>
Whether it's architecting an internal dashboard from scratch,
optimizing complex database queries, or designing modular
backend systems I bring both execution and engineering
thinking to every project.
</p>
</motion.div>
{/* Skill badges */}
<motion.div
variants={containerVariants}
className="flex flex-wrap gap-2 mt-8"
>
{skills.map((skill) => (
<motion.span
key={skill}
variants={itemVariants}
className="px-3 py-1.5 text-xs font-mono font-medium bg-surface-elevated border border-border text-foreground-muted rounded-lg hover:border-accent/40 hover:text-accent hover:bg-accent/5 transition-all duration-200 cursor-default"
>
{skill}
</motion.span>
))}
</motion.div>
</div>
{/* Right: Stats grid */}
<motion.div
ref={statsRef}
variants={containerVariants}
initial="hidden"
animate={statsInView ? "visible" : "hidden"}
className="grid grid-cols-2 gap-4"
>
{stats.map((stat, index) => {
const Icon = stat.icon;
let displayValue = stat.value;
// Use animated values based on index
if (index === 0) displayValue = `${yearsCount}+`;
else if (index === 1) displayValue = `${systemsCount}+`;
else if (index === 2) displayValue = `${modulesCount}+`;
else if (index === 3) displayValue = `${deploymentsCount}+`;
return (
<motion.div
key={stat.label}
variants={itemVariants}
className="p-6 rounded-2xl bg-surface-elevated border border-border hover:border-accent/30 hover:shadow-glow transition-all duration-300 group"
>
<div className="w-10 h-10 rounded-xl bg-accent/10 border border-border flex items-center justify-center mb-4 group-hover:bg-accent/20 transition-colors">
<Icon size={18} className="text-accent" />
</div>
<p className="font-display font-bold text-4xl text-foreground mb-1">
{displayValue}
</p>
<p className="text-sm text-foreground-muted">{stat.label}</p>
</motion.div>
);
})}
{/* Quote card */}
<motion.div
ref={quoteRef}
variants={itemVariants}
className="col-span-2 p-6 rounded-2xl bg-gradient-to-br from-accent/10 to-purple-500/5 border border-border"
initial="hidden"
animate={quoteInView ? "visible" : "hidden"}
>
<p className="text-foreground-muted text-sm leading-relaxed italic">
"I focus on writing clean, maintainable code while thinking about how systems grow over time. I value good architecture, scalability, and building solutions that are easy for teams to maintain."
</p>
<p className="text-accent text-sm font-medium mt-3">
Engineering Philosophy
</p>
</motion.div>
</motion.div>
</motion.div>
</div>
</section>
);
}

View File

@@ -0,0 +1,267 @@
'use client'
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Send, CheckCircle2, Mail, MapPin, Clock } from 'lucide-react'
import type { ContactFormData } from '@/types'
const schema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Please enter a valid email'),
subject: z.string().min(4, 'Subject must be at least 4 characters'),
message: z.string().min(10, 'Message must be at least 10 characters'),
})
const contactInfo = [
{ icon: Mail, label: 'Email', value: 'hello@yourportfolio.dev' },
{ icon: MapPin, label: 'Location', value: 'Indonesia' },
{ icon: Clock, label: 'Response time', value: 'Within 24 hours' },
]
export function ContactSection() {
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('')
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<ContactFormData>({ resolver: zodResolver(schema) })
const onSubmit = async (data: ContactFormData) => {
setStatus('loading')
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
const json = await res.json()
if (json.success) {
setStatus('success')
reset()
} else {
setErrorMsg(json.error || 'Something went wrong')
setStatus('error')
}
} catch {
setErrorMsg('Network error. Please try again.')
setStatus('error')
}
}
return (
<section id="contact" className="py-32 relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-surface/20 to-background" />
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-[600px] h-[300px] bg-accent/6 rounded-full blur-[120px] pointer-events-none" />
<div className="relative z-10 max-w-6xl mx-auto px-6">
{/* Header */}
<div className="text-center mb-16">
<motion.span
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
className="text-accent font-mono text-sm tracking-widest uppercase"
>
Get in Touch
</motion.span>
<motion.h2
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.1 }}
className="font-display font-bold text-4xl md:text-5xl text-foreground mt-3"
>
Let's Build Something
<br />
<span className="gradient-text-blue">Together</span>
</motion.h2>
<motion.p
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
className="text-foreground-muted mt-4 max-w-xl mx-auto"
>
Have a project in mind? Let's talk about how I can help you build something
remarkable.
</motion.p>
</div>
<div className="grid lg:grid-cols-5 gap-8 items-start">
{/* Left: Contact info */}
<motion.div
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="lg:col-span-2 space-y-4"
>
{contactInfo.map((item) => {
const Icon = item.icon
return (
<div
key={item.label}
className="flex items-center gap-4 p-4 rounded-xl bg-surface-elevated border border-border"
>
<div className="w-10 h-10 rounded-xl bg-accent/10 border border-border flex items-center justify-center flex-shrink-0">
<Icon size={16} className="text-accent" />
</div>
<div>
<p className="text-xs text-foreground-muted font-mono">{item.label}</p>
<p className="text-sm text-foreground font-medium">{item.value}</p>
</div>
</div>
)
})}
<div className="p-5 rounded-xl bg-gradient-to-br from-accent/10 to-purple-500/5 border border-border mt-6">
<p className="text-sm text-foreground-muted leading-relaxed">
I'm currently open to{' '}
<span className="text-foreground font-medium">freelance projects</span>,{' '}
<span className="text-foreground font-medium">full-time positions</span>, and
interesting technical collaborations.
</p>
</div>
</motion.div>
{/* Right: Form */}
<motion.div
initial={{ opacity: 0, x: 20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.1 }}
className="lg:col-span-3"
>
<div className="rounded-2xl bg-surface-elevated border border-border p-8">
<AnimatePresence mode="wait">
{status === 'success' ? (
<motion.div
key="success"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="text-center py-12"
>
<div className="w-16 h-16 rounded-full bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center mx-auto mb-4">
<CheckCircle2 size={28} className="text-emerald-400" />
</div>
<h3 className="font-display font-semibold text-xl text-foreground mb-2">
Message Sent!
</h3>
<p className="text-foreground-muted text-sm">
Thank you for reaching out. I'll get back to you within 24 hours.
</p>
<button
onClick={() => setStatus('idle')}
className="mt-6 text-sm text-accent hover:text-accent/80 transition-colors"
>
Send another message
</button>
</motion.div>
) : (
<motion.form
key="form"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onSubmit={handleSubmit(onSubmit)}
className="space-y-5"
>
<div className="grid sm:grid-cols-2 gap-5">
<div>
<label className="block text-sm text-foreground-muted mb-2">
Name
</label>
<input
{...register('name')}
placeholder="Your Name"
className="w-full px-4 py-3 bg-surface border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20 transition-all text-sm"
/>
{errors.name && (
<p className="mt-1.5 text-xs text-red-400">{errors.name.message}</p>
)}
</div>
<div>
<label className="block text-sm text-foreground-muted mb-2">
Email
</label>
<input
{...register('email')}
type="email"
placeholder="you@email.com"
className="w-full px-4 py-3 bg-surface border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20 transition-all text-sm"
/>
{errors.email && (
<p className="mt-1.5 text-xs text-red-400">{errors.email.message}</p>
)}
</div>
</div>
<div>
<label className="block text-sm text-foreground-muted mb-2">
Subject
</label>
<input
{...register('subject')}
placeholder="Project inquiry / Collaboration / etc."
className="w-full px-4 py-3 bg-surface border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20 transition-all text-sm"
/>
{errors.subject && (
<p className="mt-1.5 text-xs text-red-400">{errors.subject.message}</p>
)}
</div>
<div>
<label className="block text-sm text-foreground-muted mb-2">
Message
</label>
<textarea
{...register('message')}
rows={5}
placeholder="Tell me about your project, goals, and timeline..."
className="w-full px-4 py-3 bg-surface border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20 transition-all text-sm resize-none"
/>
{errors.message && (
<p className="mt-1.5 text-xs text-red-400">{errors.message.message}</p>
)}
</div>
{status === 'error' && (
<p className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 px-4 py-3 rounded-xl">
{errorMsg}
</p>
)}
<button
type="submit"
disabled={status === 'loading'}
className="w-full flex items-center justify-center gap-2 px-6 py-3.5 bg-accent text-background font-semibold rounded-xl hover:bg-accent/90 transition-all hover:shadow-glow disabled:opacity-60 disabled:cursor-not-allowed"
>
{status === 'loading' ? (
<>
<div className="w-4 h-4 border-2 border-background/30 border-t-background rounded-full animate-spin" />
Sending...
</>
) : (
<>
<Send size={16} />
Send Message
</>
)}
</button>
</motion.form>
)}
</AnimatePresence>
</div>
</motion.div>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,266 @@
"use client";
import { useState, useEffect } from "react";
import { usePathname } from "next/navigation";
import Link from "next/link";
import { motion, AnimatePresence } from "framer-motion";
import {
Menu,
X,
LayoutDashboard,
LogOut,
MessageSquare,
Briefcase,
FileText,
FolderGit2,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { ThemeToggle } from "@/components/ui/ThemeToggle";
const dashboardLinks = [
{ label: "Overview", href: "/dashboard", icon: LayoutDashboard },
{ label: "Messages", href: "/dashboard/messages", icon: MessageSquare },
{ label: "Experience", href: "/dashboard/experience", icon: Briefcase },
{ label: "Blog", href: "/dashboard/blog", icon: FileText },
{ label: "Projects", href: "/dashboard/projects", icon: FolderGit2 },
];
export function DashboardSidebar() {
const pathname = usePathname();
const [isCollapsed, setIsCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// Hide sidebar entirely if user is on login page
if (pathname === "/dashboard/auth") return null;
if (!mounted) return null; // Prevent hydration mismatch
return (
<>
{/* Desktop Sidebar (Sticky) */}
<motion.aside
initial={false}
animate={{ width: isCollapsed ? 80 : 256 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
className="hidden md:flex flex-col sticky top-0 h-screen bg-background border-r border-border/50 shrink-0 z-40 group/sidebar"
>
<div className="flex items-center p-4 h-16 border-b border-border/50">
<Link
href="/dashboard"
className={cn(
"flex items-center gap-2 group w-full",
isCollapsed && "justify-center",
)}
>
<div className="w-8 h-8 shrink-0 rounded-md bg-primary/20 border border-primary/30 flex items-center justify-center group-hover:bg-primary/30 transition-colors">
<LayoutDashboard size={16} className="text-primary" />
</div>
{!isCollapsed && (
<span className="font-display font-semibold text-foreground text-sm tracking-wide whitespace-nowrap overflow-hidden">
Admin<span className="text-primary">Panel</span>
</span>
)}
</Link>
</div>
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="absolute -right-3.5 top-20 bg-background border border-border rounded-full p-1.5 hover:bg-surface-elevated text-foreground-muted hover:text-foreground z-50 opacity-0 group-hover/sidebar:opacity-100 transition-opacity"
aria-label="Toggle Sidebar"
>
{isCollapsed ? <ChevronRight size={14} /> : <ChevronLeft size={14} />}
</button>
<div className="flex-1 py-6 flex flex-col gap-2 px-3 overflow-y-auto overflow-x-hidden">
{dashboardLinks.map((link) => {
const Icon = link.icon;
const isActive =
link.href === "/dashboard"
? pathname === "/dashboard"
: pathname.startsWith(link.href);
return (
<Link
key={link.href}
href={link.href}
title={isCollapsed ? link.label : undefined}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors overflow-hidden group/link",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-foreground-muted hover:bg-surface-elevated hover:text-foreground",
isCollapsed && "justify-center px-0",
)}
>
<Icon
size={18}
className={cn(
"shrink-0",
isActive
? "text-primary"
: "text-foreground-muted group-hover/link:text-foreground",
)}
/>
{!isCollapsed && (
<span className="whitespace-nowrap">{link.label}</span>
)}
</Link>
);
})}
</div>
<div className="p-4 border-t border-border/50 flex flex-col gap-2 relative">
{!isCollapsed && (
<div className="flex items-center justify-between px-3 py-2">
<span className="text-sm text-foreground-muted font-medium">
Theme
</span>
<ThemeToggle />
</div>
)}
{isCollapsed && (
<div className="flex justify-center py-2">
<ThemeToggle />
</div>
)}
<button
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors w-full text-destructive hover:bg-destructive/10 hover:text-destructive overflow-hidden group/logout",
isCollapsed && "justify-center px-0",
)}
title={isCollapsed ? "Logout" : undefined}
>
<LogOut
size={18}
className="shrink-0 group-hover/logout:text-destructive text-destructive"
/>
{!isCollapsed && (
<span className="font-medium whitespace-nowrap">Logout</span>
)}
</button>
</div>
</motion.aside>
{/* Mobile Header (Fixed) */}
<div className="md:hidden fixed top-0 left-0 right-0 h-16 bg-background/90 backdrop-blur-xl border-b border-border/50 z-40 flex items-center justify-between px-4">
<Link href="/dashboard" className="flex items-center gap-2">
<div className="w-8 h-8 rounded-md bg-primary/20 border border-primary/30 flex items-center justify-center">
<LayoutDashboard size={14} className="text-primary" />
</div>
<span className="font-display font-semibold text-foreground text-sm tracking-wide">
Admin<span className="text-primary">Panel</span>
</span>
</Link>
<div className="flex items-center gap-2">
<ThemeToggle />
<button
onClick={() => setMobileOpen(true)}
className="p-2 -mr-2 rounded-lg hover:bg-surface-elevated transition-colors text-foreground"
aria-label="Open menu"
>
<Menu size={20} />
</button>
</div>
</div>
{/* Mobile Sidebar Off-Canvas */}
<AnimatePresence>
{mobileOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setMobileOpen(false)}
className="md:hidden fixed inset-0 bg-background/80 backdrop-blur-sm z-50 cursor-pointer"
/>
)}
</AnimatePresence>
<AnimatePresence>
{mobileOpen && (
<motion.div
initial={{ x: "-100%" }}
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "spring", bounce: 0, duration: 0.4 }}
className="md:hidden fixed top-0 left-0 bottom-0 w-[280px] bg-background border-r border-border z-50 flex flex-col shadow-2xl"
>
<div className="flex items-center justify-between p-4 h-16 border-b border-border/50">
<Link
href="/dashboard"
className="flex items-center gap-2"
onClick={() => setMobileOpen(false)}
>
<div className="w-8 h-8 rounded-md bg-primary/20 border border-primary/30 flex items-center justify-center">
<LayoutDashboard size={16} className="text-primary" />
</div>
<span className="font-display font-semibold text-foreground text-sm tracking-wide">
Admin<span className="text-primary">Panel</span>
</span>
</Link>
<button
onClick={() => setMobileOpen(false)}
className="p-2 rounded-lg hover:bg-surface-elevated transition-colors text-foreground-muted"
aria-label="Close menu"
>
<X size={20} />
</button>
</div>
<div className="flex-1 py-6 px-4 flex flex-col gap-2 overflow-y-auto">
<span className="text-xs font-semibold text-foreground-muted mb-2 px-3 uppercase tracking-wider">
Navigation
</span>
{dashboardLinks.map((link) => {
const Icon = link.icon;
const isActive =
link.href === "/dashboard"
? pathname === "/dashboard"
: pathname.startsWith(link.href);
return (
<Link
key={link.href}
href={link.href}
onClick={() => setMobileOpen(false)}
className={cn(
"flex items-center gap-3 px-3 py-3 rounded-lg transition-colors",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-foreground-muted hover:bg-surface-elevated hover:text-foreground",
)}
>
<Icon
size={18}
className={cn(
isActive ? "text-primary" : "text-foreground-muted",
)}
/>
<span>{link.label}</span>
</Link>
);
})}
</div>
<div className="p-4 border-t border-border/50 flex flex-col gap-2">
<button
className="flex items-center gap-3 px-3 py-3 rounded-lg transition-colors w-full text-destructive hover:bg-destructive/10"
onClick={() => setMobileOpen(false)}
>
<LogOut size={18} />
<span className="font-medium">Logout</span>
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</>
);
}

View File

@@ -0,0 +1,129 @@
'use client'
import { useRef } from 'react'
import { motion, useInView } from 'framer-motion'
import { Briefcase, Calendar, CheckCircle2 } from 'lucide-react'
import type { Experience } from '@/types'
interface ExperienceSectionProps {
experiences: Experience[]
}
function formatDate(date: Date): string {
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
year: 'numeric',
})
}
function ExperienceItem({ exp, index }: { exp: Experience; index: number }) {
const ref = useRef(null)
const isInView = useInView(ref, { once: true, margin: '-50px' })
return (
<motion.div
ref={ref}
initial={{ opacity: 0, x: -20 }}
animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.5, delay: index * 0.15, ease: 'easeOut' }}
className="relative pl-8 pb-12 last:pb-0"
>
{/* Timeline dot */}
<div className="absolute left-0 top-1 w-3 h-3 rounded-full bg-accent border-2 border-accent/40 shadow-glow z-10" />
{/* Content */}
<div className="bg-surface-elevated border border-border rounded-2xl p-6 hover:border-accent/30 hover:shadow-glow transition-all duration-300 overflow-hidden">
{/* Header */}
<div className="flex flex-wrap items-start justify-between gap-4 mb-4">
<div>
<div className="flex items-center gap-2 mb-1">
<Briefcase size={14} className="text-accent" />
<h3 className="font-display font-semibold text-foreground text-lg">
{exp.role}
</h3>
</div>
<p className="text-accent font-medium text-sm">{exp.company}</p>
</div>
<div className="flex items-center gap-1.5 text-xs text-foreground-muted font-mono bg-surface border border-border px-3 py-1.5 rounded-lg">
<Calendar size={11} />
{formatDate(exp.startDate)} {exp.current ? 'Present' : exp.endDate ? formatDate(exp.endDate) : ''}
</div>
</div>
<p className="text-foreground-muted text-sm leading-relaxed mb-5">
{exp.description}
</p>
{/* Highlights */}
<ul className="space-y-2 mb-5">
{exp.highlights.map((h) => (
<li key={h} className="flex items-start gap-2.5 text-sm text-foreground-muted">
<CheckCircle2 size={14} className="text-accent mt-0.5 flex-shrink-0" />
<span>{h}</span>
</li>
))}
</ul>
{/* Tech stack */}
<div className="flex flex-wrap gap-1.5">
{exp.techStack.map((tech) => (
<span
key={tech}
className="px-2.5 py-1 text-xs font-mono bg-surface border border-border-subtle text-foreground-muted rounded-md"
>
{tech}
</span>
))}
</div>
</div>
</motion.div>
)
}
export function ExperienceSection({ experiences }: ExperienceSectionProps) {
return (
<section id="experience" className="py-16 sm:py-20 lg:py-32 relative">
<div className="max-w-4xl mx-auto px-4 sm:px-6">
{/* Header */}
<div className="text-center mb-16">
<motion.span
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
className="text-accent font-mono text-sm tracking-widest uppercase"
>
Career
</motion.span>
<motion.h2
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.1 }}
className="font-display font-bold text-4xl md:text-5xl text-foreground mt-3"
>
Experience
</motion.h2>
</div>
{/* Timeline */}
<div className="relative">
{/* Vertical line */}
<motion.div
initial={{ scaleY: 0 }}
whileInView={{ scaleY: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.8, ease: 'easeOut' }}
className="absolute left-1.5 top-0 bottom-0 w-px bg-gradient-to-b from-accent via-accent/50 to-transparent origin-top"
/>
<div>
{experiences.map((exp, i) => (
<ExperienceItem key={exp.id} exp={exp} index={i} />
))}
</div>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,79 @@
"use client";
import { Github, Linkedin, Mail, Terminal } from "lucide-react";
import { usePathname } from "next/navigation";
import { NavLinks, ContactUrls } from "@/lib/constant";
export function Footer() {
const pathname = usePathname();
if (pathname?.startsWith("/dashboard")) return null;
return (
<footer className="border-t border-border bg-surface/30">
<div className="max-w-6xl mx-auto px-6 py-12">
<div className="flex flex-col md:flex-row items-center justify-between gap-8">
{/* Logo */}
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-md bg-accent/20 border border-accent/30 flex items-center justify-center">
<Terminal size={14} className="text-accent" />
</div>
<span className="font-display font-semibold text-foreground text-sm">
fikri<span className="text-accent">.</span>maulana
</span>
</div>
{/* Nav links */}
<nav className="flex items-center gap-6">
{NavLinks.map((link) => (
<a
key={link.href}
href={link.href}
className="text-sm text-foreground-muted hover:text-foreground transition-colors"
>
{link.label}
</a>
))}
</nav>
{/* Social */}
<div className="flex items-center gap-3">
<a
href={ContactUrls.github}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg text-foreground-muted hover:text-foreground hover:bg-surface-elevated transition-all"
aria-label="GitHub"
>
<Github size={18} />
</a>
<a
href={ContactUrls.linkedin}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg text-foreground-muted hover:text-foreground hover:bg-surface-elevated transition-all"
aria-label="LinkedIn"
>
<Linkedin size={18} />
</a>
<a
href={ContactUrls.email}
className="p-2 rounded-lg text-foreground-muted hover:text-foreground hover:bg-surface-elevated transition-all"
aria-label="Email"
>
<Mail size={18} />
</a>
</div>
</div>
<div className="mt-8 pt-8 border-t border-border/50 flex flex-col sm:flex-row items-center justify-between gap-4">
<p className="text-xs text-foreground-subtle font-mono">
© {new Date().getFullYear()} Fikri Maulana. Built with Next.js &
TailwindCSS.
</p>
<p className="text-xs text-foreground-subtle">
Designed with precision. Engineered for scale.
</p>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,384 @@
"use client";
import { motion } from "framer-motion";
import { ArrowRight, Github, Linkedin, Mail, ChevronDown, MessageCircle, Zap } from "lucide-react";
import dynamic from "next/dynamic";
import { HeroSection as HeroData } from "@/lib/constant";
import { useEffect, useState } from "react";
import TerminalWindow from "@/components/TerminalWindow";
// Dynamic import for Lottie animation
const LottieAnimation = dynamic(
() => import("lottie-react").then((mod) => mod.default),
{
ssr: false,
loading: () => (
<div className="w-20 h-20 animate-pulse bg-accent/20 rounded-xl" />
),
}
);
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.12,
delayChildren: 0.2,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 24 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.5, ease: "easeOut" },
},
};
export function HeroSection() {
const [loadingAnimation, setLoadingAnimation] = useState(null);
const [isDark, setIsDark] = useState(false);
const [showScrollIndicator, setShowScrollIndicator] = useState(true);
useEffect(() => {
fetch("/lottie/loading.json")
.then((res) => res.json())
.then((data) => setLoadingAnimation(data))
.catch((err) =>
console.error("Failed to load Lottie animation:", err)
);
// Check dark mode
const checkDarkMode = () => {
setIsDark(document.documentElement.classList.contains('dark'));
};
checkDarkMode();
// Listen for theme changes
const observer = new MutationObserver(checkDarkMode);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
return () => observer.disconnect();
}, []);
useEffect(() => {
const handleScroll = () => {
const scrollTop = window.scrollY;
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
// Hide scroll indicator when user is at the bottom (within 50px of bottom)
if (scrollTop + windowHeight >= documentHeight - 50) {
setShowScrollIndicator(false);
} else {
setShowScrollIndicator(true);
}
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return (
<section
id="hero"
aria-labelledby="hero-title"
className="relative min-h-screen flex flex-col items-center justify-center overflow-hidden"
>
{/* Background effects */}
<div
className="absolute inset-0 bg-grid opacity-100"
aria-hidden="true"
/>
<div
className="absolute inset-0 bg-gradient-to-b from-background via-background/95 to-background"
aria-hidden="true"
/>
{/* Mobile photo background */}
<div
className="absolute inset-0 lg:hidden opacity-10"
style={{
backgroundImage: `url(${HeroData.image})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat'
}}
aria-hidden="true"
/>
{/* Blur orbs */}
<div
className="absolute top-1/4 left-1/4 w-96 h-96 bg-accent/8 rounded-full blur-[120px] pointer-events-none"
aria-hidden="true"
/>
<div
className="absolute bottom-1/3 right-1/4 w-80 h-80 bg-purple-500/6 rounded-full blur-[100px] pointer-events-none"
aria-hidden="true"
/>
<div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-accent/4 rounded-full blur-[150px] pointer-events-none"
aria-hidden="true"
/>
<div className="relative z-10 max-w-7xl mx-auto px-6 lg:px-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center min-h-[70vh] py-20 lg:py-0 lg:gap-16">
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="text-center lg:text-left flex flex-col items-center lg:items-start"
>
{/* Status badge */}
<motion.div variants={itemVariants} className="mb-8">
<p
role="status"
aria-live="polite"
className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-border bg-accent/10 text-accent text-sm font-medium"
>
<span
className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse-slow"
aria-hidden="true"
/>
{HeroData.badge || "Available for opportunities"}
</p>
</motion.div>
{/* Lottie animation */}
<motion.div
variants={itemVariants}
className="-mt-8 block lg:hidden overflow-hidden rounded-md"
>
{loadingAnimation && (
<LottieAnimation
animationData={loadingAnimation}
loop
autoplay
className="w-[300px] h-[200px] object-cover"
/>
)}
</motion.div>
{/* Main headline */}
<motion.h1
id="hero-title"
variants={itemVariants}
className="font-display font-bold text-5xl sm:text-6xl md:text-7xl lg:text-8xl leading-[1.05] tracking-tight mb-6"
>
<span className="text-foreground">
{HeroData.headline || "Building Systems"}
</span>
<br />
<span className="gradient-text">
{HeroData.headline2 || "That Scale."}
</span>
</motion.h1>
{/* Tagline */}
<motion.p
variants={itemVariants}
className="text-lg md:text-xl text-foreground-muted mb-4 font-light leading-relaxed max-w-2xl"
>
{HeroData.tagline1 ||
"Senior Fullstack Engineer — Next.js & NestJS Specialist"}
</motion.p>
<motion.p
variants={itemVariants}
className="text-base text-foreground-subtle mb-10 leading-relaxed max-w-xl"
>
{HeroData.tagline2 ||
"I architect and ship production-grade web applications with a focus on clean architecture, performance, and developer experience."}
</motion.p>
{/* CTA Buttons */}
<motion.nav
aria-label="Primary Actions"
variants={itemVariants}
className="flex flex-wrap items-center justify-center lg:justify-start gap-4 mb-14"
>
<a
href="#projects"
className="group inline-flex items-center gap-2 px-6 py-3 bg-accent text-white font-semibold rounded-xl hover:bg-accent/90 transition-all hover:shadow-glow hover:-translate-y-0.5 duration-200"
>
View Projects
<ArrowRight
size={16}
className="group-hover:translate-x-1 transition-transform"
aria-hidden="true"
/>
</a>
<a
href="#contact"
className="inline-flex items-center gap-2 px-6 py-3 bg-surface-elevated border border-border text-foreground font-medium rounded-xl hover:border-accent/40 hover:bg-surface-elevated/80 transition-all hover:-translate-y-0.5 duration-200"
>
<Mail size={16} className="text-accent" aria-hidden="true" />
Contact Me
</a>
</motion.nav>
{/* Social links */}
<motion.nav
aria-label="Social Media Profiles"
variants={itemVariants}
className="flex items-center justify-center lg:justify-start gap-4"
>
<ul className="flex items-center gap-4 m-0 p-0 list-none">
<li>
<a
href="https://github.com/Dzuuul"
target="_blank"
rel="noopener noreferrer"
className="block p-2.5 rounded-xl border border-border hover:border-accent/40 hover:bg-surface-elevated text-foreground-muted hover:text-foreground transition-all hover:-translate-y-0.5 duration-200"
aria-label="GitHub Profile"
>
<Github size={18} aria-hidden="true" />
</a>
</li>
<li>
<a
href="https://www.linkedin.com/in/dzulfikrimaulana"
target="_blank"
rel="noopener noreferrer"
className="block p-2.5 rounded-xl border border-border hover:border-accent/40 hover:bg-surface-elevated text-foreground-muted hover:text-foreground transition-all hover:-translate-y-0.5 duration-200"
aria-label="LinkedIn Profile"
>
<Linkedin size={18} aria-hidden="true" />
</a>
</li>
<li>
<a
href="https://wa.me/6285163616363?text=Hi%20Fikri,%0D%0AI%20am%20[Name]%20from%20[Company/Organization%20Name].%0D%0AI%20recently%20explored%20your%20portfolio%20and%20was%20impressed%20by%20your%20work%20as%20a%20Fullstack%20Developer.%0D%0AI%20would%20like%20to%20discuss%20a%20potential%20opportunity%20with%20you,%20whether%20it%20be%20for%20a%20career%20role%20within%20our%20team%20or%20a%20professional%20collaboration%20on%20an%20upcoming%20project.%0D%0AAre%20you%20available%20for%20a%20brief%20introductory%20call%20or%20a%20chat%20sometime%20this%20week%20to%20discuss%20this%20further?%0D%0A%0D%0ABest%20regards,%0D%0A[Name]%0D%0A[LinkedIn%20Profile/Contact%20Info]"
target="_blank"
rel="noopener noreferrer"
className="block p-2.5 rounded-xl border border-border hover:border-accent/40 hover:bg-surface-elevated text-foreground-muted hover:text-foreground transition-all hover:-translate-y-0.5 duration-200"
aria-label="WhatsApp Contact"
>
<MessageCircle size={18} aria-hidden="true" />
</a>
</li>
</ul>
<div className="w-px h-5 bg-border mx-1" aria-hidden="true" />
<span className="text-foreground-subtle text-sm font-mono">
3+ yrs experience
</span>
</motion.nav>
</motion.div>
{/* Right Section: Photo - Desktop Only */}
<motion.div
variants={itemVariants}
initial="hidden"
animate="visible"
className="relative hidden lg:flex justify-end"
>
<motion.div
className="relative group max-w-xs sm:max-w-md w-full aspect-square"
whileHover={{ scale: 1.02 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
{/* Window frame */}
<div className="relative h-full w-full rounded-lg border border-border bg-surface-elevated shadow-2xl overflow-hidden">
{/* Window header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-surface-elevated/70">
<div className="flex gap-2">
<div className="w-3 h-3 bg-red-500 rounded-full" />
<div className="w-3 h-3 bg-yellow-500 rounded-full" />
<div className="w-3 h-3 bg-green-500 rounded-full" />
</div>
<span className="text-xs text-foreground-muted">
portfolio.jpg
</span>
</div>
{/* Image content */}
<div className="flex-1 flex items-center justify-center bg-surface">
<img
src={HeroData.image}
alt="Professional portrait"
className="max-w-full max-h-full object-contain group-hover:scale-105 transition-transform duration-700"
/>
</div>
</div>
{/* Floating card decor */}
<div className="absolute -bottom-8 -left-8 rounded-xl bg-surface-elevated border border-border p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-accent/10 text-accent">
<Zap
size={24}
strokeWidth={2}
aria-hidden="true"
/>
</div>
<div>
<p className="text-lg font-semibold text-foreground">10+ Projects</p>
<p className="text-sm text-foreground-muted">Completed successfully</p>
</div>
</div>
</div>
{/* Floating card decor */}
<div className="absolute -top-12 -right-8 rounded-xl bg-surface-elevated border border-border p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-accent/10 text-accent">
<Zap
size={24}
strokeWidth={2}
aria-hidden="true"
/>
</div>
<div>
<p className="text-lg font-semibold text-foreground">Production Systems</p>
<p className="text-sm text-foreground-muted">Built & Maintained</p>
</div>
</div>
</div>
{/* Terminal Window */}
<div className="absolute -bottom-[45px] -right-[100px]">
<TerminalWindow />
</div>
</motion.div>
</motion.div>
</div>
</div>
{/* Scroll indicator */}
{showScrollIndicator && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1.5, duration: 0.5 }}
className="fixed -bottom-3 left-1/2 transform -translate-x-1/2 z-50"
aria-hidden="true"
>
{/* Rounded rectangle container with content inside */}
<div className="w-32 h-16 rounded-t-2xl border border-border bg-surface-elevated/80 backdrop-blur-sm flex flex-col items-center justify-center -gap-1 shadow-lg">
<span className="text-foreground-subtle text-xs font-mono tracking-widest uppercase">
Scroll Down
</span>
<motion.div
animate={{ y: [0, 4, 0] }}
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
className="flex items-center justify-center"
>
<ChevronDown size={16} className="text-accent" />
</motion.div>
</div>
</motion.div>
)}
</section>
);
}

View File

@@ -0,0 +1,120 @@
"use client";
import { useState, useEffect } from "react";
import { usePathname } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import { Menu, X, Terminal } from "lucide-react";
import { cn } from "@/lib/utils";
import { ThemeToggle } from "@/components/ui/ThemeToggle";
import { NavLinks } from "@/lib/constant";
export function Navbar() {
const pathname = usePathname();
const [scrolled, setScrolled] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
useEffect(() => {
const handleScroll = () => setScrolled(window.scrollY > 20);
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
if (pathname?.startsWith("/dashboard")) return null;
return (
<>
<motion.header
initial={{ y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5, ease: "easeOut" }}
className={cn(
"fixed top-0 left-0 right-0 z-50 transition-all duration-300",
scrolled
? "bg-background/90 backdrop-blur-xl border-b border-border"
: "bg-transparent border-border",
)}
>
<nav className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
{/* Logo */}
<a href="#" className="flex items-center gap-2 group">
<div className="w-7 h-7 rounded-md bg-accent/20 border border-border flex items-center justify-center group-hover:bg-accent/30 transition-colors">
<Terminal size={14} className="text-accent" />
</div>
<span className="font-display font-semibold text-foreground text-sm tracking-wide">
fikri<span className="text-accent">.</span>maulana
</span>
</a>
{/* Desktop nav */}
<div className="hidden md:flex items-center gap-1">
{NavLinks.map((link) => (
<a
key={link.href}
href={link.href}
className="px-4 py-2 text-sm text-foreground-muted hover:text-foreground transition-colors rounded-lg hover:bg-surface-elevated"
>
{link.label}
</a>
))}
</div>
{/* CTA + Theme Toggle */}
<div className="hidden md:flex items-center gap-2">
<ThemeToggle />
<a
href="#contact"
className="px-4 py-2 text-sm font-medium bg-accent text-white rounded-lg hover:bg-accent/90 transition-all hover:shadow-glow"
>
Download CV
</a>
</div>
{/* Mobile toggle */}
<div className="md:hidden flex items-center gap-2">
<ThemeToggle />
<button
onClick={() => setMobileOpen(!mobileOpen)}
className="p-2 rounded-lg hover:bg-surface-elevated transition-colors"
aria-label="Toggle menu"
>
{mobileOpen ? <X size={20} /> : <Menu size={20} />}
</button>
</div>
</nav>
</motion.header>
{/* Mobile menu */}
<AnimatePresence>
{mobileOpen && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
className="fixed top-16 left-0 right-0 z-40 bg-background/95 backdrop-blur-xl border-b border-border"
>
<div className="w-full px-6 py-4 flex flex-col gap-1">
{NavLinks.map((link) => (
<a
key={link.href}
href={link.href}
onClick={() => setMobileOpen(false)}
className="px-4 py-3 text-foreground-muted hover:text-foreground rounded-lg hover:bg-surface-elevated transition-colors"
>
{link.label}
</a>
))}
<a
href="#contact"
onClick={() => setMobileOpen(false)}
className="mt-2 px-4 py-3 text-center font-medium bg-accent text-background rounded-lg"
>
Download CV
</a>
</div>
</motion.div>
)}
</AnimatePresence>
</>
);
}

View File

@@ -0,0 +1,251 @@
"use client";
import { useRef, useState, useEffect } from "react";
import { motion, useInView } from "framer-motion";
import {
Layers,
ShieldCheck,
Zap,
RefreshCcw,
GitBranch,
Box,
} from "lucide-react";
import { useTheme } from "next-themes";
import SyntaxHighlighter from "react-syntax-highlighter";
import {
atomOneDark,
atomOneLight,
} from "react-syntax-highlighter/dist/esm/styles/hljs";
const principles = [
{
icon: Layers,
title: "Clean Architecture Mindset",
description:
"I structure applications with clear separation of concerns so business logic stays maintainable and easy to evolve as the project grows.",
tag: "Architecture",
},
{
icon: Box,
title: "Modular Code Structure",
description:
"I organize features into well-defined modules to keep the codebase scalable and easier for teams to understand and maintain.",
tag: "Scalability",
},
{
icon: GitBranch,
title: "Abstraction & Reusability",
description:
"I prefer abstractions and reusable patterns to keep the codebase flexible and easier to extend in future iterations.",
tag: "Code Design",
},
{
icon: ShieldCheck,
title: "SOLID Principles",
description:
"I apply SOLID principles to write clean, understandable code where each component has a clear responsibility.",
tag: "Code Quality",
},
{
icon: Zap,
title: "Performance Awareness",
description:
"I consider performance early by applying efficient queries, pagination, and thoughtful data handling in web applications.",
tag: "Performance",
},
{
icon: RefreshCcw,
title: "Maintainable Development",
description:
"I focus on writing maintainable code, using version control effectively, and building systems that teams can work on confidently.",
tag: "Workflow",
},
];
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.08, delayChildren: 0.1 },
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0, transition: { duration: 0.5, ease: "easeOut" } },
};
export function PrincipleSection() {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: "-80px" });
const { resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const isDark = resolvedTheme === "dark";
const codeSnippet = `// ✅ Application Layer — Pure Business Logic
@Injectable()
export class UserService {
constructor(
private readonly userRepository: IUserRepository, // Interface, not implementation
private readonly eventEmitter: IEventEmitter,
) {}
async createUser(dto: CreateUserDto): Promise<UserEntity> {
const exists = await this.userRepository.findByEmail(dto.email)
if (exists) throw new ConflictException('Email already registered')
const user = UserEntity.create(dto) // Domain entity handles business rules
await this.userRepository.save(user)
await this.eventEmitter.emit('user.created', user)
return user
}
}
// ✅ Infrastructure Layer — Implementation Detail
@Injectable()
export class PrismaUserRepository implements IUserRepository {
constructor(private prisma: PrismaService) {}
async findByEmail(email: string) { /* ... */ }
async save(user: UserEntity) { /* ... */ }
}`;
return (
<section id="principles" className="py-16 sm:py-20 lg:py-32 relative overflow-hidden">
{/* Background */}
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-surface/30 to-transparent" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[400px] bg-accent/4 rounded-full blur-[150px] pointer-events-none" />
<div className="relative z-10 max-w-6xl mx-auto px-6">
{/* Header */}
<div className="text-center mb-12 sm:mb-16">
<motion.span
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
className="text-accent font-mono text-xs sm:text-sm tracking-widest uppercase"
>
System Design
</motion.span>
<motion.h2
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.1 }}
className="font-display font-bold text-3xl sm:text-4xl md:text-5xl text-foreground mt-3"
>
Engineering
<span className="gradient-text-blue"> Principles</span>
</motion.h2>
<motion.p
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
className="text-foreground-muted mt-4 max-w-2xl mx-auto leading-relaxed text-sm sm:text-base px-4"
>
I aim to build systems that are clean, maintainable, and ready to grow as products evolve.
These principles guide how I structure applications and approach technical decisions.
</motion.p>
</div>
{/* Principles Grid */}
<motion.div
ref={ref}
variants={containerVariants}
initial="hidden"
animate={isInView ? "visible" : "hidden"}
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5"
>
{principles.map((p) => {
const Icon = p.icon;
return (
<motion.div
key={p.title}
variants={itemVariants}
className="group p-4 sm:p-6 rounded-2xl bg-surface-elevated border border-border hover:border-accent/30 hover:shadow-glow transition-all duration-300"
>
<div className="flex items-start justify-between gap-4 mb-4">
<div className="w-10 h-10 rounded-xl bg-accent/10 border border-border flex items-center justify-center group-hover:bg-accent/20 transition-colors">
<Icon size={18} className="text-accent" />
</div>
<span className="text-xs font-mono text-foreground-subtle bg-surface border border-border-subtle px-2.5 py-1 rounded-lg">
{p.tag}
</span>
</div>
<h3 className="font-display font-semibold text-foreground text-sm sm:text-base mb-3 leading-snug">
{p.title}
</h3>
<p className="text-xs sm:text-sm text-foreground-muted leading-relaxed">
{p.description}
</p>
</motion.div>
);
})}
</motion.div>
{/* Code snippet preview */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.3 }}
className="mt-8 sm:mt-10 rounded-2xl bg-surface-elevated border border-border overflow-hidden"
>
{/* macOS-style title bar */}
<div className="flex items-center gap-2 sm:gap-3 px-3 sm:px-5 py-3 sm:py-3.5 border-b border-border bg-surface">
<div className="flex gap-1.5">
<div className="w-3 h-3 rounded-full bg-red-500/70" />
<div className="w-3 h-3 rounded-full bg-yellow-500/70" />
<div className="w-3 h-3 rounded-full bg-green-500/70" />
</div>
<span className="text-xs font-mono text-foreground-muted ml-1 truncate">
user.service.ts
</span>
<span className="hidden sm:inline ml-auto text-xs font-mono text-foreground-subtle opacity-60">
Clean Architecture Example
</span>
</div>
{/* Syntax-highlighted code */}
{mounted ? (
<SyntaxHighlighter
language="typescript"
style={isDark ? atomOneDark : atomOneLight}
showLineNumbers={false}
wrapLongLines={true}
customStyle={{
margin: 0,
padding: "1rem 1.5rem",
fontSize: "0.6875rem",
lineHeight: "1.6",
background: "transparent",
overflowX: "auto",
}}
lineNumberStyle={{
color: isDark ? "#3d4451" : "#c4c9d4",
paddingRight: "1.5rem",
minWidth: "2.5rem",
userSelect: "none",
}}
>
{codeSnippet}
</SyntaxHighlighter>
) : (
<div
className="p-6 overflow-x-auto text-[0.75rem] font-mono leading-[1.7] opacity-0 text-transparent"
aria-hidden="true"
>
<pre>{codeSnippet}</pre>
</div>
)}
</motion.div>
</div>
</section>
);
}

View File

@@ -0,0 +1,48 @@
import { ProjectCard } from '@/components/ui/ProjectCard'
import type { Project } from '@/types'
interface ProjectsSectionProps {
projects: Project[]
}
export function ProjectsSection({ projects }: ProjectsSectionProps) {
return (
<section id="projects" className="py-32 relative">
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-surface/20 to-transparent" />
<div className="relative z-10 max-w-6xl mx-auto px-6">
{/* Header */}
<div className="text-center mb-16">
<span className="text-accent font-mono text-sm tracking-widest uppercase">
Work
</span>
<h2 className="font-display font-bold text-4xl md:text-5xl text-foreground mt-3">
Featured Projects
</h2>
<p className="text-foreground-muted mt-4 max-w-xl mx-auto">
Production-grade systems I've built from concept to deployment
</p>
</div>
{/* Grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{projects.map((project, index) => (
<ProjectCard key={project.id} project={project} index={index} />
))}
</div>
{/* View more */}
<div className="text-center mt-12">
<a
href="https://github.com"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-6 py-3 border border-border hover:border-accent/40 text-foreground-muted hover:text-foreground rounded-xl transition-all hover:-translate-y-0.5 duration-200"
>
View All on GitHub
</a>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,176 @@
"use client";
import { useRef } from "react";
import { motion, useInView } from "framer-motion";
import {
SiNextdotjs,
SiReact,
SiTypescript,
SiTailwindcss,
SiFramer,
SiNestjs,
SiNodedotjs,
SiExpress,
SiPostgresql,
SiMongodb,
SiRedis,
SiPrisma,
SiDocker,
SiGit,
SiGithub,
SiLinux,
SiTypeorm,
SiRedbull,
} from "react-icons/si";
// Note: If react-icons is not installed, replace with text-based badges
// npm install react-icons
const categories = [
{
label: "Frontend",
color: "from-blue-500/20 to-cyan-500/10",
borderColor: "border-blue-500/20",
iconColor: "text-blue-400",
tech: [
{ name: "Next.js", Icon: SiNextdotjs },
{ name: "React", Icon: SiReact },
{ name: "TypeScript", Icon: SiTypescript },
{ name: "Tailwind CSS", Icon: SiTailwindcss },
{ name: "Framer Motion", Icon: SiFramer },
],
},
{
label: "Backend",
color: "from-emerald-500/20 to-teal-500/10",
borderColor: "border-emerald-500/20",
iconColor: "text-emerald-400",
tech: [
{ name: "NestJS", Icon: SiNestjs },
{ name: "Node.js", Icon: SiNodedotjs },
{ name: "Express", Icon: SiExpress },
{ name: "BullMQ", Icon: SiRedbull },
],
},
{
label: "Database",
color: "from-orange-500/20 to-amber-500/10",
borderColor: "border-orange-500/20",
iconColor: "text-orange-400",
tech: [
{ name: "PostgreSQL", Icon: SiPostgresql },
{ name: "MongoDB", Icon: SiMongodb },
{ name: "Redis", Icon: SiRedis },
{ name: "Prisma ORM", Icon: SiPrisma },
{ name: "TypeORM", Icon: SiTypeorm },
],
},
{
label: "DevOps",
color: "from-purple-500/20 to-violet-500/10",
borderColor: "border-purple-500/20",
iconColor: "text-purple-400",
tech: [
{ name: "Docker", Icon: SiDocker },
{ name: "Git", Icon: SiGit },
{ name: "GitHub", Icon: SiGithub },
{ name: "Linux", Icon: SiLinux },
],
},
];
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.1, delayChildren: 0.1 },
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0, transition: { duration: 0.5, ease: "easeOut" } },
};
export function TechStackSection() {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: "-100px" });
return (
<section id="stack" className="py-5 lg:py-32 relative">
<div className="max-w-6xl mx-auto px-6">
{/* Header */}
<div className="text-center mb-16">
<motion.span
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
className="text-accent font-mono text-sm tracking-widest uppercase"
>
Technology
</motion.span>
<motion.h2
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.1 }}
className="font-display font-bold text-4xl md:text-5xl text-foreground mt-3"
>
My Tech Stack
</motion.h2>
<motion.p
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
className="text-foreground-muted mt-4 max-w-xl mx-auto"
>
Battle-tested tools and frameworks I use to build production systems
</motion.p>
</div>
{/* Grid */}
<motion.div
ref={ref}
variants={containerVariants}
initial="hidden"
animate={isInView ? "visible" : "hidden"}
className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6"
>
{categories.map((category) => (
<motion.div
key={category.label}
variants={itemVariants}
className={`p-6 rounded-2xl bg-gradient-to-br ${category.color} border ${category.borderColor} hover:shadow-glow transition-all duration-300 group`}
>
<h3
className={`font-display font-semibold text-sm tracking-widest uppercase mb-5 ${category.iconColor}`}
>
{category.label}
</h3>
<div className="space-y-3">
{category.tech.map((item) => {
const Icon = item.Icon;
return (
<div
key={item.name}
className="flex items-center gap-3 p-2.5 rounded-xl bg-background/30 hover:bg-background/60 transition-all duration-200 group/item cursor-default"
>
<Icon
size={18}
className={`${category.iconColor} group-hover/item:scale-110 transition-transform flex-shrink-0`}
/>
<span className="text-sm text-foreground-muted group-hover/item:text-foreground transition-colors font-medium">
{item.name}
</span>
</div>
);
})}
</div>
</motion.div>
))}
</motion.div>
</div>
</section>
);
}

View File

@@ -0,0 +1,117 @@
"use client";
import { motion } from "framer-motion";
import { ExternalLink, Github, ArrowUpRight } from "lucide-react";
import type { Project } from "@/types";
interface ProjectCardProps {
project: Project;
index: number;
}
// Deterministic pseudo-random number seeded by a string — avoids SSR/client hydration mismatch
function seededRandom(seed: string): number {
let hash = 0;
for (let i = 0; i < seed.length; i++) {
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
}
return (hash % 1000) / 1000;
}
export function ProjectCard({ project, index }: ProjectCardProps) {
return (
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1, ease: "easeOut" }}
whileHover={{ y: -4 }}
className="group relative rounded-2xl bg-surface-elevated border border-border overflow-hidden hover:border-accent/30 hover:shadow-card-hover transition-all duration-300"
>
{/* Project image / placeholder */}
<div className="relative h-48 bg-gradient-to-br from-surface to-background overflow-hidden">
{project.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={project.imageUrl}
alt={project.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<div className="grid grid-cols-3 gap-1 opacity-20">
{Array.from({ length: 9 }).map((_, i) => (
<div
key={i}
className="w-12 h-8 rounded-sm bg-accent"
style={{
opacity: seededRandom(`${project.id}-${i}`) * 0.8 + 0.2,
}}
/>
))}
</div>
<div className="absolute inset-0 bg-gradient-to-br from-accent/10 via-transparent to-purple-500/10" />
</div>
)}
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-surface-elevated via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
{/* Action links on hover */}
<div className="absolute top-4 right-4 flex gap-2 opacity-0 group-hover:opacity-100 transition-all duration-300 translate-y-1 group-hover:translate-y-0">
{project.githubUrl && (
<a
href={project.githubUrl}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg bg-background/80 backdrop-blur-sm border border-border hover:border-accent/40 text-foreground-muted hover:text-foreground transition-colors"
aria-label="GitHub repository"
>
<Github size={14} />
</a>
)}
{project.liveUrl && (
<a
href={project.liveUrl}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg bg-background/80 backdrop-blur-sm border border-border hover:border-accent/40 text-foreground-muted hover:text-foreground transition-colors"
aria-label="Live demo"
>
<ExternalLink size={14} />
</a>
)}
</div>
</div>
{/* Content */}
<div className="p-6">
<div className="flex items-start justify-between gap-4 mb-3">
<h3 className="font-display font-semibold text-lg text-foreground group-hover:text-accent transition-colors leading-tight">
{project.title}
</h3>
<ArrowUpRight
size={16}
className="text-foreground-subtle opacity-0 group-hover:opacity-100 flex-shrink-0 mt-1 transition-opacity"
/>
</div>
<p className="text-sm text-foreground-muted leading-relaxed mb-5 line-clamp-2">
{project.description}
</p>
{/* Tech stack tags */}
<div className="flex flex-wrap gap-1.5">
{project.techStack.map((tech) => (
<span
key={tech}
className="px-2.5 py-1 text-xs font-mono text-foreground-muted bg-surface border border-border-subtle rounded-md"
>
{tech}
</span>
))}
</div>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,58 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { Moon, Sun } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
// Avoid hydration mismatch — only render after mount
useEffect(() => setMounted(true), []);
if (!mounted) {
return (
<div className="w-9 h-9 rounded-lg bg-surface-elevated border border-border" />
);
}
const isDark = resolvedTheme === "dark";
return (
<motion.button
whileTap={{ scale: 0.9 }}
onClick={() => setTheme(isDark ? "light" : "dark")}
className="relative w-9 h-9 rounded-lg border border-border bg-surface-elevated hover:border-accent/40 hover:bg-surface transition-all duration-200 flex items-center justify-center overflow-hidden"
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
title={isDark ? "Switch to light mode" : "Switch to dark mode"}
>
<AnimatePresence mode="wait" initial={false}>
{isDark ? (
<motion.span
key="moon"
initial={{ opacity: 0, rotate: -90, scale: 0.6 }}
animate={{ opacity: 1, rotate: 0, scale: 1 }}
exit={{ opacity: 0, rotate: 90, scale: 0.6 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="absolute"
>
<Moon size={15} className="text-foreground-muted" />
</motion.span>
) : (
<motion.span
key="sun"
initial={{ opacity: 0, rotate: 90, scale: 0.6 }}
animate={{ opacity: 1, rotate: 0, scale: 1 }}
exit={{ opacity: 0, rotate: -90, scale: 0.6 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="absolute"
>
<Sun size={15} className="text-foreground-muted" />
</motion.span>
)}
</AnimatePresence>
</motion.button>
);
}

38
src/lib/constant.ts Normal file
View File

@@ -0,0 +1,38 @@
export const NavLinks = [
{ label: "About", href: "#about" },
{ label: "Stack", href: "#stack" },
{ label: "Projects", href: "#projects" },
{ label: "Experience", href: "#experience" },
{ label: "Contact", href: "#contact" },
];
export const ContactUrls = {
github: "https://github.com/Dzuuul",
linkedin: "https://www.linkedin.com/in/dzulfikrimaulana",
email: "mailto:developer@dzulfikri.com?subject=Inquiry:%20Fullstack%20Developer%20Opportunity%20-%20[Company/Project%20Name]&body=Hi%20Fikri,%0D%0A%0D%0AI%20am%20[Name]%20from%20[Company/Organization%20Name].%0D%0A%0D%0AI%20recently%20explored%20your%20portfolio%20and%20was%20impressed%20by%20your%20work%20as%20a%20Fullstack%20Developer.%20I%20would%20like%20to%20discuss%20a%20potential%20opportunity%20with%20you,%20whether%20it%20be%20for%20a%20career%20role%20within%20our%20team%20or%20a%20professional%20collaboration%20on%20an%20upcoming%20project.%0D%0A%0D%0AAre%20you%20available%20for%20a%20brief%20introductory%20call%20or%20a%20chat%20sometime%20this%20week%20to%20discuss%20this%20further?%0D%0A%0D%0ABest%20regards,%0D%0A[Name]%0D%0A[LinkedIn%20Profile/Contact%20Info]"
}
export const HeroSection = {
badge: "Available for opportunities",
headline: "Fullstack",
headline2: "Developer",
tagline1: "Specializing in Next.js & NestJS",
tagline2:
"Building scalable web applications with Next.js and NestJS, focused on performance, clean architecture, and real-world systems.",
image: "/images/hero-portrait.png",
};
export const AboutSection = {
title: "About Me",
description: "",
};
export const ArchitectureSection = {
title: "Architecture",
description: "",
};
export const ContactSection = {
title: "Contact Me",
description: "",
};

23
src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,23 @@
import { PrismaClient } from "@prisma/client";
import { Pool } from "pg";
import { PrismaPg } from "@prisma/adapter-pg";
const connectionString = `${process.env.DATABASE_URL}`;
const pool = new Pool({ connectionString });
const adapter = new PrismaPg(pool);
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
adapter,
log:
process.env.NODE_ENV === "development"
? ["query", "error", "warn"]
: ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

23
src/lib/s3.ts Normal file
View File

@@ -0,0 +1,23 @@
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
const s3 = new S3Client({
region: "auto",
endpoint: process.env.R2_ENDPOINT,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY!,
secretAccessKey: process.env.R2_SECRET_KEY!,
},
});
export async function uploadFile(file: File) {
const buffer = Buffer.from(await file.arrayBuffer());
await s3.send(
new PutObjectCommand({
Bucket: process.env.R2_BUCKET_NAME,
Key: file.name,
Body: buffer,
ContentType: file.type,
}),
);
}

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

10
src/lib/visitor.ts Normal file
View File

@@ -0,0 +1,10 @@
export function getVisitorId() {
let visitorId = localStorage.getItem("visitor_id");
if (!visitorId) {
visitorId = crypto.randomUUID();
localStorage.setItem("visitor_id", visitorId);
}
return visitorId;
}

25
src/proxy.ts Normal file
View File

@@ -0,0 +1,25 @@
import { NextResponse, type NextRequest } from "next/server";
export function proxy(request: NextRequest) {
const token = request.cookies.get("token")?.value;
const { pathname } = request.nextUrl;
const isAuthPage = pathname.startsWith("/dashboard/auth");
const isProtectedPage = pathname.startsWith("/dashboard") && !isAuthPage;
// user belum login, mau buka dashboard → redirect ke login
if (!token && isProtectedPage) {
return NextResponse.redirect(new URL("/dashboard/auth", request.url));
}
// user sudah login, tapi buka login → redirect ke dashboard
if (token && isAuthPage) {
return NextResponse.redirect(new URL("/dashboard/overview", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*"],
};

41
src/types/index.ts Normal file
View File

@@ -0,0 +1,41 @@
export interface Project {
id: string
title: string
description: string
imageUrl?: string | null
liveUrl?: string | null
githubUrl?: string | null
techStack: string[]
featured: boolean
order: number
createdAt: Date
updatedAt: Date
}
export interface Experience {
id: string
company: string
role: string
startDate: Date
endDate?: Date | null
current: boolean
description: string
highlights: string[]
techStack: string[]
order: number
createdAt: Date
updatedAt: Date
}
export interface ContactFormData {
name: string
email: string
subject: string
message: string
}
export interface ContactFormState {
success: boolean
error?: string
message?: string
}