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

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

11
.env.example Normal file
View File

@@ -0,0 +1,11 @@
DATABASE_URL="postgresql://user:pass@localhost:5432/db_name?schema=public"
JWT_SECRET=
R2_TOKEN=
R2_ACCESS_KEY=
R2_SECRET_KEY=
R2_ENDPOINT=
R2_BUCKET_NAME=
IMAGE_URL=

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
.next
.env

112
README.md Normal file
View File

@@ -0,0 +1,112 @@
# Portfolio — Senior Fullstack Engineer
A modern, production-grade portfolio website built with Next.js 15, TailwindCSS, Framer Motion, and Prisma.
## Tech Stack
- **Frontend**: Next.js 15 (App Router), React 19, TypeScript
- **Styling**: TailwindCSS + custom design tokens
- **Animations**: Framer Motion
- **ORM**: Prisma (PostgreSQL)
- **Validation**: Zod + React Hook Form
- **Icons**: Lucide React + React Icons
## Project Structure
```
src/
├── app/
│ ├── api/
│ │ └── contact/ # Contact form API route
│ ├── globals.css
│ ├── layout.tsx # Root layout with SEO metadata
│ └── page.tsx # Home page (Server Component)
├── components/
│ ├── sections/ # Full-page section components
│ │ ├── Navbar.tsx
│ │ ├── HeroSection.tsx
│ │ ├── AboutSection.tsx
│ │ ├── TechStackSection.tsx
│ │ ├── ProjectsSection.tsx
│ │ ├── ExperienceSection.tsx
│ │ ├── ArchitectureSection.tsx
│ │ ├── ContactSection.tsx
│ │ └── Footer.tsx
│ └── ui/ # Reusable UI components
│ └── ProjectCard.tsx
├── lib/
│ ├── prisma.ts # Prisma client singleton
│ └── utils.ts # cn() utility
└── types/
└── index.ts # TypeScript types
prisma/
├── schema.prisma # Database schema
└── seed.ts # Seed data
```
## Getting Started
### 1. Install dependencies
```bash
npm install
# Also install react-icons for tech stack icons
npm install react-icons
```
### 2. Setup environment
```bash
cp .env.example .env
# Edit .env with your DATABASE_URL
```
### 3. Setup database
```bash
npm run db:generate # Generate Prisma client
npm run db:push # Push schema to database
npx ts-node prisma/seed.ts # Seed with sample data
```
### 4. Run development server
```bash
npm run dev
```
Open [http://localhost:3000](http://localhost:3000)
## Customization
### Personal Information
Update these files with your information:
- `src/app/layout.tsx` — SEO metadata (name, description, URL)
- `src/components/sections/HeroSection.tsx` — Hero text & social links
- `src/components/sections/AboutSection.tsx` — Bio, stats, skills
- `src/components/sections/Footer.tsx` — Social links, email
- `src/components/sections/ContactSection.tsx` — Contact info
### Database Content
After running seed, use Prisma Studio to manage content:
```bash
npm run db:studio
```
Or update `prisma/seed.ts` with your actual projects and experience data.
## Architecture Philosophy
This portfolio demonstrates the same architectural principles I apply in all projects:
- **Clean folder structure** — scalable and maintainable
- **Server Components** — data fetching at the server level with graceful fallbacks
- **Type safety** — full TypeScript throughout
- **Separation of concerns** — UI components separate from data fetching
- **Graceful degradation** — fallback data when DB is not connected
## Deployment
Deploy to Vercel (recommended):
1. Push to GitHub
2. Import to Vercel
3. Add `DATABASE_URL` environment variable
4. Deploy
For PostgreSQL, use [Supabase](https://supabase.com) or [Neon](https://neon.tech) for free hosted PostgreSQL.

23
components.json Normal file
View File

@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

6
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

9
next.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
turbopack: {
root: __dirname,
},
};
export default nextConfig;

76
package.json Normal file
View File

@@ -0,0 +1,76 @@
{
"name": "portfolio",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:studio": "prisma studio"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1001.0",
"@hookform/resolvers": "^3.9.1",
"@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2",
"@tailwindcss/cli": "^4.2.1",
"@tailwindcss/postcss": "^4.2.1",
"@types/jsonwebtoken": "^9.0.10",
"@vercel/analytics": "^1.6.1",
"bcrypt": "^6.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^11.15.0",
"install": "^0.13.0",
"jose": "^6.1.3",
"jsonwebtoken": "^9.0.3",
"lottie-react": "^2.4.1",
"lucide-react": "^0.468.0",
"next": "16.2.0-canary.72",
"next-themes": "^0.4.6",
"nodemailer": "^6.9.16",
"pg": "^8.19.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-hook-form": "^7.54.2",
"react-icons": "^5.5.0",
"react-syntax-highlighter": "^16.1.1",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/node": "^22",
"@types/pg": "^8.18.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/react-syntax-highlighter": "^15.5.13",
"autoprefixer": "^10.0.1",
"dotenv": "^17.3.1",
"eslint": "^9",
"eslint-config-next": "16.2.0-canary.72",
"postcss": "^8",
"prisma": "^7.4.2",
"tailwindcss": "^3.4.17",
"typescript": "^5"
},
"pnpm": {
"overrides": {
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3"
},
"onlyBuiltDependencies": [
"@parcel/watcher",
"@prisma/client",
"@prisma/engines",
"bcrypt",
"prisma",
"sharp",
"unrs-resolver"
]
}
}

7071
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

8
prisma.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
datasource: {
url: env("DATABASE_URL"),
},
});

107
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,107 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
model User {
id String @id @default(cuid())
email String @unique
name String
password String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Session {
id String @id @default(cuid())
visitorId String
startedAt DateTime @default(now())
lastSeenAt DateTime @updatedAt
pageViews PageView[]
}
model PageView {
id String @id @default(cuid())
sessionId String
path String
createdAt DateTime @default(now())
session Session @relation(fields: [sessionId], references: [id])
}
model Project {
id String @id @default(cuid())
title String
description String
imageUrl String?
liveUrl String?
githubUrl String?
techStack String[] // Array of tech names
featured Boolean @default(false)
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Experience {
id String @id @default(cuid())
company String
role String
startDate DateTime
endDate DateTime?
current Boolean @default(false)
description String
highlights String[]
techStack String[]
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ContactMessage {
id String @id @default(cuid())
name String
email String
subject String
message String
read Boolean @default(false)
createdAt DateTime @default(now())
}
enum BlogStatus {
DRAFT
SCHEDULED
PUBLISHED
}
model Blog {
id String @id @default(cuid())
title String
slug String @unique
content String
excerpt String?
status BlogStatus @default(DRAFT)
scheduledAt DateTime?
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tags Tag[]
@@index([status])
@@index([scheduledAt])
}
model Tag {
id String @id @default(cuid())
name String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
blogs Blog[]
}

93
prisma/seed.ts Normal file
View File

@@ -0,0 +1,93 @@
// prisma/seed.ts
import { prisma } from "../src/lib/prisma";
async function main() {
// Seed Projects
await prisma.project.createMany({
data: [
{
title: "Enterprise ERP System",
description:
"Full-scale ERP system built with NestJS microservices, handling inventory, finance, and HR modules for 500+ concurrent users.",
imageUrl: "/projects/erp.png",
liveUrl: "https://demo.example.com",
githubUrl: "https://github.com/username/erp-system",
techStack: ["NestJS", "Next.js", "PostgreSQL", "Redis", "Docker"],
featured: true,
order: 1,
},
{
title: "Real-time Analytics Dashboard",
description:
"Interactive analytics platform with real-time data visualization, WebSocket integration, and multi-tenant architecture.",
imageUrl: "/projects/dashboard.png",
liveUrl: "https://analytics.example.com",
githubUrl: "https://github.com/username/analytics",
techStack: ["Next.js", "NestJS", "Prisma", "Chart.js", "PostgreSQL"],
featured: true,
order: 2,
},
{
title: "API Gateway & Auth Service",
description:
"Scalable API Gateway with JWT authentication, rate limiting, and microservice orchestration using Clean Architecture principles.",
imageUrl: "/projects/gateway.png",
githubUrl: "https://github.com/username/api-gateway",
techStack: ["NestJS", "Redis", "JWT", "Docker", "Kubernetes"],
featured: true,
order: 3,
},
],
});
// Seed Experiences
await prisma.experience.createMany({
data: [
{
company: "Tech Corp Indonesia",
role: "Senior Fullstack Engineer",
startDate: new Date("2022-01-01"),
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",
"Implemented CI/CD pipeline cutting deployment time from 2 hours to 15 minutes",
],
techStack: [
"Next.js",
"NestJS",
"PostgreSQL",
"Redis",
"Docker",
"AWS",
],
order: 1,
},
{
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,
},
],
});
console.log("✅ Seed completed");
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());

BIN
public/images/clip.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

BIN
public/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

1
public/lottie/cmd.json Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

25
src/proxy.ts Normal file
View File

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

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

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

121
tailwind.config.ts Normal file
View File

@@ -0,0 +1,121 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: "class",
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: 'var(--color-background)',
foreground: 'var(--color-foreground)',
card: {
DEFAULT: 'var(--color-surface-elevated)',
foreground: 'var(--color-foreground)'
},
popover: {
DEFAULT: 'var(--color-surface-elevated)',
foreground: 'var(--color-foreground)'
},
primary: {
DEFAULT: 'var(--color-accent)',
foreground: 'var(--color-foreground)'
},
secondary: {
DEFAULT: 'var(--color-border)',
foreground: 'var(--color-foreground)'
},
muted: {
DEFAULT: 'var(--color-muted)',
foreground: 'var(--color-foreground-muted)'
},
accent: {
DEFAULT: 'var(--color-accent)',
foreground: 'var(--color-foreground)'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
border: 'var(--color-border)',
input: 'var(--color-border)',
ring: 'var(--color-accent)',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))'
},
// Custom colors for your existing components
surface: 'var(--color-surface)',
'surface-elevated': 'var(--color-surface-elevated)',
'border-subtle': 'var(--color-border-subtle)',
'accent-glow': 'var(--color-accent-glow)',
'accent-dim': 'var(--color-accent-dim)',
subtle: 'var(--color-subtle)',
'foreground-muted': 'var(--color-foreground-muted)',
'foreground-subtle': 'var(--color-foreground-subtle)',
},
fontFamily: {
sans: [
'var(--font-sans)',
'system-ui',
'sans-serif'
],
mono: [
'var(--font-mono)',
'monospace'
],
display: [
'var(--font-display)',
'system-ui',
'sans-serif'
]
},
animation: {
'gradient-x': 'gradient-x 15s ease infinite',
'pulse-slow': 'pulse 4s cubic-bezier(0.4, 0, 0.6, 1) infinite',
float: 'float 6s ease-in-out infinite'
},
keyframes: {
'gradient-x': {
'0%, 100%': {
'background-position': '0% 50%'
},
'50%': {
'background-position': '100% 50%'
}
},
float: {
'0%, 100%': {
transform: 'translateY(0px)'
},
'50%': {
transform: 'translateY(-20px)'
}
}
},
backgroundImage: {
'grid-pattern': '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.5)\'%3e%3cpath d=\'M0 .5H31.5V32\'/%3e%3c/svg%3e")'
},
boxShadow: {
glow: '0 0 30px var(--color-accent-glow)',
'glow-lg': '0 0 60px var(--color-accent-glow)',
card: '0 4px 24px rgb(0 0 0 / 0.4)',
'card-hover': '0 8px 48px rgb(0 0 0 / 0.6)'
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
}
}
},
plugins: [require("tailwindcss-animate")],
};
export default config;

41
tsconfig.json Normal file
View File

@@ -0,0 +1,41 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
},
"target": "ES2017"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

8
vercel.json Normal file
View File

@@ -0,0 +1,8 @@
{
"cron": [
{
"path": "/api/cron/publish",
"schedule": "*/5 * * * *"
}
]
}