feat: implement initial fullstack portfolio application including dashboard, CMS, and analytics features.
This commit is contained in:
19
src/app/api/cron/publish/route.ts
Normal file
19
src/app/api/cron/publish/route.ts
Normal 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 });
|
||||
}
|
||||
38
src/app/api/dashboard/auth/login/route.ts
Normal file
38
src/app/api/dashboard/auth/login/route.ts
Normal 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 });
|
||||
}
|
||||
7
src/app/api/dashboard/auth/logout/route.ts
Normal file
7
src/app/api/dashboard/auth/logout/route.ts
Normal 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" });
|
||||
}
|
||||
14
src/app/api/dashboard/cms/analytics/track/route.ts
Normal file
14
src/app/api/dashboard/cms/analytics/track/route.ts
Normal 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 });
|
||||
}
|
||||
41
src/app/api/dashboard/cms/contact/route.ts
Normal file
41
src/app/api/dashboard/cms/contact/route.ts
Normal 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
0
src/app/blog/page.tsx
Normal file
13
src/app/dashboard/auth/page.tsx
Normal file
13
src/app/dashboard/auth/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/app/dashboard/layout.tsx
Normal file
16
src/app/dashboard/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
src/app/dashboard/overview/page.tsx
Normal file
7
src/app/dashboard/overview/page.tsx
Normal 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
193
src/app/globals.css
Normal 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
119
src/app/layout.tsx
Normal 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
161
src/app/not-found.tsx
Normal 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'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'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
134
src/app/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
163
src/components/TerminalWindow.tsx
Normal file
163
src/components/TerminalWindow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
src/components/ThemeProvider.tsx
Normal file
8
src/components/ThemeProvider.tsx
Normal 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>;
|
||||
}
|
||||
143
src/components/dashboard/auth/LoginForm.tsx
Normal file
143
src/components/dashboard/auth/LoginForm.tsx
Normal 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">
|
||||
© {new Date().getFullYear()} Fikri Maulana. All rights reserved.
|
||||
</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
250
src/components/sections/portfolio/AboutSection.tsx
Normal file
250
src/components/sections/portfolio/AboutSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
267
src/components/sections/portfolio/ContactSection.tsx
Normal file
267
src/components/sections/portfolio/ContactSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
266
src/components/sections/portfolio/DashboardSidebar.tsx
Normal file
266
src/components/sections/portfolio/DashboardSidebar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
129
src/components/sections/portfolio/ExperienceSection.tsx
Normal file
129
src/components/sections/portfolio/ExperienceSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
79
src/components/sections/portfolio/Footer.tsx
Normal file
79
src/components/sections/portfolio/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
384
src/components/sections/portfolio/HeroSection.tsx
Normal file
384
src/components/sections/portfolio/HeroSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
src/components/sections/portfolio/Navbar.tsx
Normal file
120
src/components/sections/portfolio/Navbar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
251
src/components/sections/portfolio/PrincipleSection.tsx
Normal file
251
src/components/sections/portfolio/PrincipleSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/components/sections/portfolio/ProjectsSection.tsx
Normal file
48
src/components/sections/portfolio/ProjectsSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
176
src/components/sections/portfolio/TechStackSection.tsx
Normal file
176
src/components/sections/portfolio/TechStackSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
src/components/ui/ProjectCard.tsx
Normal file
117
src/components/ui/ProjectCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
src/components/ui/ThemeToggle.tsx
Normal file
58
src/components/ui/ThemeToggle.tsx
Normal 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
38
src/lib/constant.ts
Normal 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
23
src/lib/prisma.ts
Normal 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
23
src/lib/s3.ts
Normal 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
6
src/lib/utils.ts
Normal 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
10
src/lib/visitor.ts
Normal 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
25
src/proxy.ts
Normal 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
41
src/types/index.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user