feat: implement initial fullstack portfolio application including dashboard, CMS, and analytics features.
This commit is contained in:
11
.env.example
Normal file
11
.env.example
Normal 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
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.env
|
||||||
112
README.md
Normal file
112
README.md
Normal 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
23
components.json
Normal 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
6
next-env.d.ts
vendored
Normal 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
9
next.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
turbopack: {
|
||||||
|
root: __dirname,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
76
package.json
Normal file
76
package.json
Normal 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
7071
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
8
prisma.config.ts
Normal file
8
prisma.config.ts
Normal 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
107
prisma/schema.prisma
Normal 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
93
prisma/seed.ts
Normal 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
BIN
public/images/clip.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 281 KiB |
BIN
public/images/hero-portrait-ori.png
Normal file
BIN
public/images/hero-portrait-ori.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
BIN
public/images/hero-portrait.png
Normal file
BIN
public/images/hero-portrait.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
BIN
public/images/logo.png
Normal file
BIN
public/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 194 KiB |
1
public/lottie/cmd.json
Normal file
1
public/lottie/cmd.json
Normal file
File diff suppressed because one or more lines are too long
1
public/lottie/computer.json
Normal file
1
public/lottie/computer.json
Normal file
File diff suppressed because one or more lines are too long
1
public/lottie/developer.json
Normal file
1
public/lottie/developer.json
Normal file
File diff suppressed because one or more lines are too long
1
public/lottie/loading.json
Normal file
1
public/lottie/loading.json
Normal file
File diff suppressed because one or more lines are too long
19
src/app/api/cron/publish/route.ts
Normal file
19
src/app/api/cron/publish/route.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
await prisma.blog.updateMany({
|
||||||
|
where: {
|
||||||
|
status: "SCHEDULED",
|
||||||
|
scheduledAt: { lte: now },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: "PUBLISHED",
|
||||||
|
publishedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
38
src/app/api/dashboard/auth/login/route.ts
Normal file
38
src/app/api/dashboard/auth/login/route.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import bcrypt from "bcrypt";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { email, password } = await req.json();
|
||||||
|
|
||||||
|
console.log("API LOGIN", email, password);
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidPassword = await bcrypt.compare(password, user.password);
|
||||||
|
|
||||||
|
if (!isValidPassword) {
|
||||||
|
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET!, {
|
||||||
|
expiresIn: "1h",
|
||||||
|
});
|
||||||
|
|
||||||
|
(await cookies()).set("token", token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: "strict",
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
7
src/app/api/dashboard/auth/logout/route.ts
Normal file
7
src/app/api/dashboard/auth/logout/route.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
(await cookies()).delete("token");
|
||||||
|
return NextResponse.json({ success: true, message: "Logout success" });
|
||||||
|
}
|
||||||
14
src/app/api/dashboard/cms/analytics/track/route.ts
Normal file
14
src/app/api/dashboard/cms/analytics/track/route.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { visitorId, path } = await req.json();
|
||||||
|
|
||||||
|
await prisma.pageView.create({
|
||||||
|
data: {
|
||||||
|
visitorId,
|
||||||
|
path,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
41
src/app/api/dashboard/cms/contact/route.ts
Normal file
41
src/app/api/dashboard/cms/contact/route.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const contactSchema = z.object({
|
||||||
|
name: z.string().min(2, 'Name must be at least 2 characters'),
|
||||||
|
email: z.string().email('Invalid email address'),
|
||||||
|
subject: z.string().min(4, 'Subject must be at least 4 characters'),
|
||||||
|
message: z.string().min(10, 'Message must be at least 10 characters'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const parsed = contactSchema.safeParse(body)
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: parsed.error.errors[0].message },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, email, subject, message } = parsed.data
|
||||||
|
|
||||||
|
await prisma.contactMessage.create({
|
||||||
|
data: { name, email, subject, message },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Message received! I will get back to you soon.',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Contact form error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Failed to send message. Please try again.' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
0
src/app/blog/page.tsx
Normal file
0
src/app/blog/page.tsx
Normal file
13
src/app/dashboard/auth/page.tsx
Normal file
13
src/app/dashboard/auth/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { LoginForm } from "@/components/dashboard/auth/LoginForm";
|
||||||
|
|
||||||
|
export default function AuthPage() {
|
||||||
|
return (
|
||||||
|
<section className="min-h-[100vh] flex items-center justify-center p-4 relative overflow-hidden bg-background">
|
||||||
|
{/* Background radial gradients for premium depth */}
|
||||||
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] bg-accent/5 rounded-full blur-[120px] pointer-events-none" />
|
||||||
|
<div className="absolute bottom-0 right-0 w-[500px] h-[500px] bg-primary/5 rounded-full blur-[100px] pointer-events-none" />
|
||||||
|
|
||||||
|
<LoginForm />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/app/dashboard/layout.tsx
Normal file
16
src/app/dashboard/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { DashboardSidebar } from "@/components/sections/portfolio/DashboardSidebar";
|
||||||
|
|
||||||
|
export default function DashboardLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-background">
|
||||||
|
<DashboardSidebar />
|
||||||
|
<div className="flex-1 flex flex-col min-w-0 md:pt-0 pt-16">
|
||||||
|
<main className="flex-1">{children}</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
src/app/dashboard/overview/page.tsx
Normal file
7
src/app/dashboard/overview/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export default function CMSPage() {
|
||||||
|
return (
|
||||||
|
<section className="min-h-[100vh] flex items-center justify-center p-4 relative overflow-hidden bg-background">
|
||||||
|
<h1>CMS</h1>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
193
src/app/globals.css
Normal file
193
src/app/globals.css
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 248 250 252;
|
||||||
|
--foreground: 15 23 42;
|
||||||
|
--card: 255 255 255;
|
||||||
|
--card-foreground: 15 23 42;
|
||||||
|
--popover: 255 255 255;
|
||||||
|
--popover-foreground: 15 23 42;
|
||||||
|
--primary: 99 102 241;
|
||||||
|
--primary-foreground: 255 255 255;
|
||||||
|
--secondary: 34 211 238;
|
||||||
|
--secondary-foreground: 248 250 252;
|
||||||
|
--muted: 241 245 249;
|
||||||
|
--muted-foreground: 71 85 105;
|
||||||
|
--accent: 99 102 241;
|
||||||
|
--accent-foreground: 15 23 42;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 34 211 238;
|
||||||
|
--input: 34 211 238;
|
||||||
|
--ring: 99 102 241;
|
||||||
|
--chart-1: 12 76% 61%;
|
||||||
|
--chart-2: 173 58% 39%;
|
||||||
|
--chart-3: 197 37% 24%;
|
||||||
|
--chart-4: 43 74% 66%;
|
||||||
|
--chart-5: 27 87% 67%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
.dark {
|
||||||
|
--background: 2 6 23;
|
||||||
|
--foreground: 248 250 252;
|
||||||
|
--card: 15 23 42;
|
||||||
|
--card-foreground: 248 250 252;
|
||||||
|
--popover: 15 23 42;
|
||||||
|
--popover-foreground: 248 250 252;
|
||||||
|
--primary: 99 102 241;
|
||||||
|
--primary-foreground: 255 255 255;
|
||||||
|
--secondary: 71 85 105;
|
||||||
|
--secondary-foreground: 248 250 252;
|
||||||
|
--muted: 248 250 252;
|
||||||
|
--muted-foreground: 248 250 252;
|
||||||
|
--accent: 99 102 241;
|
||||||
|
--accent-foreground: 2 6 23;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 71 85 105;
|
||||||
|
--input: 71 85 105;
|
||||||
|
--ring: 99 102 241;
|
||||||
|
--chart-1: 220 70% 50%;
|
||||||
|
--chart-2: 160 60% 45%;
|
||||||
|
--chart-3: 30 80% 55%;
|
||||||
|
--chart-4: 280 65% 60%;
|
||||||
|
--chart-5: 340 75% 55%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────── */
|
||||||
|
/* DARK THEME (default) */
|
||||||
|
/* ─────────────────────────────────────────── */
|
||||||
|
html,
|
||||||
|
:root,
|
||||||
|
.dark {
|
||||||
|
--color-background: #020617;
|
||||||
|
--color-surface: #020617;
|
||||||
|
--color-surface-elevated: #0F172A;
|
||||||
|
--color-border: #475569;
|
||||||
|
--color-border-subtle: #47556980;
|
||||||
|
--color-accent: #6366F1;
|
||||||
|
--color-accent-glow: #6366F140;
|
||||||
|
--color-accent-dim: #6366F1;
|
||||||
|
--color-muted: #F8FAFC;
|
||||||
|
--color-subtle: #F8FAFC;
|
||||||
|
--color-foreground: #F8FAFC;
|
||||||
|
--color-foreground-muted: #F8FAFC;
|
||||||
|
--color-foreground-subtle: #F8FAFC;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────── */
|
||||||
|
/* LIGHT THEME */
|
||||||
|
/* ─────────────────────────────────────────── */
|
||||||
|
:root.light,
|
||||||
|
.light {
|
||||||
|
--color-background: #F8FAFC;
|
||||||
|
--color-surface: #FFFFFF;
|
||||||
|
--color-surface-elevated: #F1F5F9;
|
||||||
|
--color-border: #22D3EE;
|
||||||
|
--color-border-subtle: #22D3EE60;
|
||||||
|
--color-accent: #6366F1;
|
||||||
|
--color-accent-glow: #6366F120;
|
||||||
|
--color-accent-dim: #6366F1;
|
||||||
|
--color-muted: #64748B;
|
||||||
|
--color-subtle: #475569;
|
||||||
|
--color-foreground: #0F172A;
|
||||||
|
--color-foreground-muted: #475569;
|
||||||
|
--color-foreground-subtle: #64748B;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
scroll-padding-top: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--color-background);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
overflow-x: hidden;
|
||||||
|
transition:
|
||||||
|
background-color 0.3s ease,
|
||||||
|
color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--color-background);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection */
|
||||||
|
::selection {
|
||||||
|
background: var(--color-accent-glow);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid background utility */
|
||||||
|
.bg-grid {
|
||||||
|
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' width='32' height='32' fill='none' stroke='rgb(71 85 105 / 0.4)'%3e%3cpath d='M0 .5H31.5V32'/%3e%3c/svg%3e");
|
||||||
|
}
|
||||||
|
|
||||||
|
.light .bg-grid {
|
||||||
|
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' width='32' height='32' fill='none' stroke='rgb(2 6 23 / 0.3)'%3e%3cpath d='M0 .5H31.5V32'/%3e%3c/svg%3e");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient text utility */
|
||||||
|
.gradient-text {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--color-foreground) 0%,
|
||||||
|
var(--color-accent) 50%,
|
||||||
|
#22D3EE 100%
|
||||||
|
);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-text-blue {
|
||||||
|
background: linear-gradient(135deg, var(--color-accent-dim) 0%, var(--color-accent) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Noise texture overlay */
|
||||||
|
.noise::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0.03;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glow effect */
|
||||||
|
.glow-blue {
|
||||||
|
box-shadow: 0 0 30px var(--color-accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timeline */
|
||||||
|
.timeline-line {
|
||||||
|
background: linear-gradient(to bottom, var(--color-accent), transparent);
|
||||||
|
}
|
||||||
119
src/app/layout.tsx
Normal file
119
src/app/layout.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Syne, DM_Sans, DM_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import { Navbar } from "@/components/sections/portfolio/Navbar";
|
||||||
|
import { Footer } from "@/components/sections/portfolio/Footer";
|
||||||
|
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||||
|
import { Analytics } from "@vercel/analytics/react";
|
||||||
|
|
||||||
|
const syne = Syne({
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "500", "600", "700", "800"],
|
||||||
|
variable: "--font-display",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
|
const dmSans = DM_Sans({
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["300", "400", "500", "600"],
|
||||||
|
style: ["normal", "italic"],
|
||||||
|
variable: "--font-sans",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
|
const dmMono = DM_Mono({
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["300", "400", "500"],
|
||||||
|
variable: "--font-mono",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
metadataBase: new URL(
|
||||||
|
process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
|
||||||
|
),
|
||||||
|
title: {
|
||||||
|
default: "Fikri — Fullstack Developer",
|
||||||
|
template: "%s | Fikri",
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
"Fullstack Developer with 3+ years of experience building scalable and production-ready web applications using Next.js, NestJS, and PostgreSQL. Focused on clean architecture, performance, and maintainable systems.",
|
||||||
|
keywords: [
|
||||||
|
"Fikri",
|
||||||
|
"Fullstack Developer",
|
||||||
|
"Next.js Developer",
|
||||||
|
"NestJS Developer",
|
||||||
|
"TypeScript",
|
||||||
|
"React",
|
||||||
|
"Node.js",
|
||||||
|
"PostgreSQL",
|
||||||
|
"Prisma",
|
||||||
|
"Clean Architecture",
|
||||||
|
"Web Application Development",
|
||||||
|
],
|
||||||
|
authors: [{ name: "Fikri" }],
|
||||||
|
creator: "Fikri",
|
||||||
|
openGraph: {
|
||||||
|
type: "website",
|
||||||
|
locale: "en_US",
|
||||||
|
url: "https://yourportfolio.dev", // ganti dengan domain kamu
|
||||||
|
siteName: "Fikri — Fullstack Developer",
|
||||||
|
title: "Fikri — Fullstack Developer",
|
||||||
|
description:
|
||||||
|
"Fullstack Developer specializing in Next.js & NestJS with 3+ years of experience building scalable and production-ready web applications.",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: "/og-image.png",
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: "Fikri — Fullstack Developer",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: "Fikri — Fullstack Developer",
|
||||||
|
description:
|
||||||
|
"Next.js & NestJS Specialist | Clean Architecture | Scalable Systems",
|
||||||
|
images: ["/og-image.png"],
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
googleBot: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
"max-video-preview": -1,
|
||||||
|
"max-image-preview": "large",
|
||||||
|
"max-snippet": -1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html
|
||||||
|
lang="en"
|
||||||
|
className={`${syne.variable} ${dmSans.variable} ${dmMono.variable} dark`}
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
|
<body className="bg-background text-foreground antialiased">
|
||||||
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="dark"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange={false}
|
||||||
|
>
|
||||||
|
<Navbar />
|
||||||
|
<main>{children}</main>
|
||||||
|
<Footer />
|
||||||
|
<Analytics />
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
src/app/not-found.tsx
Normal file
161
src/app/not-found.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Home, MoveLeft, Terminal as TerminalIcon, AlertTriangle } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative min-h-[calc(100vh-80px)] flex flex-col items-center justify-center overflow-hidden px-6">
|
||||||
|
{/* Background effects */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-grid opacity-20"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-gradient-to-b from-background via-background/95 to-background"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Blur orbs */}
|
||||||
|
<div
|
||||||
|
className="absolute top-1/4 left-1/4 w-96 h-96 bg-accent/20 rounded-full blur-[120px] pointer-events-none animate-pulse-slow"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-purple-500/15 rounded-full blur-[100px] pointer-events-none animate-pulse-slow"
|
||||||
|
style={{ animationDelay: "2s" }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Floating Icons */}
|
||||||
|
<div className="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||||
|
{[...Array(6)].map((_, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
initial={{ opacity: 0, scale: 0 }}
|
||||||
|
animate={{
|
||||||
|
opacity: [0.05, 0.15, 0.05],
|
||||||
|
scale: [1, 1.2, 1],
|
||||||
|
x: [0, Math.random() * 40 - 20, 0],
|
||||||
|
y: [0, Math.random() * 40 - 20, 0]
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 5 + Math.random() * 5,
|
||||||
|
repeat: Infinity,
|
||||||
|
delay: Math.random() * 5
|
||||||
|
}}
|
||||||
|
className="absolute"
|
||||||
|
style={{
|
||||||
|
top: `${Math.random() * 100}%`,
|
||||||
|
left: `${Math.random() * 100}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="p-4 rounded-xl border border-border/20 bg-surface-elevated/10 backdrop-blur-sm">
|
||||||
|
<TerminalIcon size={24} className="text-accent/30" />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 max-w-2xl w-full text-center">
|
||||||
|
{/* Error Code Container */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="mb-12 relative"
|
||||||
|
>
|
||||||
|
{/* Main Icon */}
|
||||||
|
<div className="inline-flex items-center justify-center w-28 h-28 rounded-[2rem] bg-surface-elevated border border-border/50 shadow-2xl mb-8 relative group">
|
||||||
|
<div className="absolute inset-0 bg-accent/30 rounded-[2rem] blur-2xl group-hover:bg-accent/50 transition-all duration-500" />
|
||||||
|
<AlertTriangle size={56} className="text-accent relative z-10" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<h1 className="font-display font-extrabold text-[120px] md:text-[180px] tracking-[ -0.05em] leading-none mb-4 select-none">
|
||||||
|
<span className="text-foreground -mr-4 md:-mr-8">4</span>
|
||||||
|
<span className="gradient-text drop-shadow-glow">0</span>
|
||||||
|
<span className="text-foreground -ml-4 md:-ml-8">4</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
animate={{ opacity: [0.4, 1, 0.4] }}
|
||||||
|
transition={{ duration: 2, repeat: Infinity }}
|
||||||
|
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full h-full bg-accent/5 blur-[120px] pointer-events-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.3 }}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<h2 className="text-3xl md:text-5xl font-display font-bold text-foreground tracking-tight">
|
||||||
|
Oops! You're <span className="text-accent italic">Lost</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-foreground-muted text-lg md:text-xl mb-12 max-w-lg mx-auto leading-relaxed">
|
||||||
|
The page you are looking for has vanished into the digital void.
|
||||||
|
Don't worry, we can find our way back together.
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.5 }}
|
||||||
|
className="flex flex-col sm:flex-row items-center justify-center gap-6 mt-12"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="group relative inline-flex items-center gap-3 px-10 py-5 bg-accent text-white font-bold rounded-2xl hover:bg-accent/90 transition-all hover:shadow-glow hover:-translate-y-1.5 duration-300 w-full sm:w-auto justify-center overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 w-3/4 h-full bg-white/20 -skew-x-[45deg] -translate-x-full group-hover:translate-x-[200%] transition-transform duration-1000" />
|
||||||
|
<Home size={20} className="relative z-10" />
|
||||||
|
<span className="relative z-10">Back to Home</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => window.history.back()}
|
||||||
|
className="inline-flex items-center gap-3 px-10 py-5 bg-surface-elevated/50 backdrop-blur-md border border-border text-foreground font-semibold rounded-2xl hover:border-accent/60 hover:bg-surface-elevated/80 transition-all hover:-translate-y-1.5 duration-300 w-full sm:w-auto justify-center"
|
||||||
|
>
|
||||||
|
<MoveLeft size={20} className="text-accent" />
|
||||||
|
Return Back
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Terminal Status */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 1, delay: 1 }}
|
||||||
|
className="mt-20 flex items-center justify-center gap-4 text-foreground-subtle font-mono text-xs md:text-sm tracking-widest uppercase"
|
||||||
|
>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-red-500/50" />
|
||||||
|
<div className="w-2 h-2 rounded-full bg-yellow-500/50" />
|
||||||
|
<div className="w-2 h-2 rounded-full bg-green-500/50" />
|
||||||
|
</div>
|
||||||
|
<span className="border-l border-border/30 pl-4">System Error: 404_PAGE_NOT_FOUND</span>
|
||||||
|
<span className="w-2 h-5 bg-accent animate-pulse" />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Noise Texture */}
|
||||||
|
<div className="noise pointer-events-none" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
src/app/page.tsx
Normal file
134
src/app/page.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { HeroSection } from "@/components/sections/portfolio/HeroSection";
|
||||||
|
import { AboutSection } from "@/components/sections/portfolio/AboutSection";
|
||||||
|
import { TechStackSection } from "@/components/sections/portfolio/TechStackSection";
|
||||||
|
import { ProjectsSection } from "@/components/sections/portfolio/ProjectsSection";
|
||||||
|
import { ExperienceSection } from "@/components/sections/portfolio/ExperienceSection";
|
||||||
|
import { PrincipleSection } from "@/components/sections/portfolio/PrincipleSection";
|
||||||
|
import { ContactSection } from "@/components/sections/portfolio/ContactSection";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
const fallbackProjects = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
title: "Enterprise ERP System",
|
||||||
|
description:
|
||||||
|
"Full-scale ERP system built with NestJS microservices, handling inventory, finance, and HR modules for 500+ concurrent users.",
|
||||||
|
imageUrl: null,
|
||||||
|
liveUrl: null,
|
||||||
|
githubUrl: "https://github.com",
|
||||||
|
techStack: ["NestJS", "Next.js", "PostgreSQL", "Redis", "Docker"],
|
||||||
|
featured: true,
|
||||||
|
order: 1,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
title: "Real-time Analytics Dashboard",
|
||||||
|
description:
|
||||||
|
"Interactive analytics platform with real-time data visualization, WebSocket integration, and multi-tenant architecture.",
|
||||||
|
imageUrl: null,
|
||||||
|
liveUrl: null,
|
||||||
|
githubUrl: "https://github.com",
|
||||||
|
techStack: ["Next.js", "NestJS", "Prisma", "Chart.js", "PostgreSQL"],
|
||||||
|
featured: true,
|
||||||
|
order: 2,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
title: "API Gateway & Auth Service",
|
||||||
|
description:
|
||||||
|
"Scalable API Gateway with JWT authentication, rate limiting, and microservice orchestration using Clean Architecture principles.",
|
||||||
|
imageUrl: null,
|
||||||
|
liveUrl: null,
|
||||||
|
githubUrl: "https://github.com",
|
||||||
|
techStack: ["NestJS", "Redis", "JWT", "Docker", "Kubernetes"],
|
||||||
|
featured: true,
|
||||||
|
order: 3,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const fallbackExperiences = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
company: "Tech Corp Indonesia",
|
||||||
|
role: "Senior Fullstack Engineer",
|
||||||
|
startDate: new Date("2022-01-01"),
|
||||||
|
endDate: null,
|
||||||
|
current: true,
|
||||||
|
description:
|
||||||
|
"Leading development of enterprise-grade web applications with focus on scalability and clean architecture.",
|
||||||
|
highlights: [
|
||||||
|
"Architected microservices-based ERP system serving 500+ concurrent users",
|
||||||
|
"Reduced API response time by 60% through Redis caching & query optimization",
|
||||||
|
"Mentored 3 junior developers on NestJS patterns and SOLID principles",
|
||||||
|
],
|
||||||
|
techStack: ["Next.js", "NestJS", "PostgreSQL", "Redis", "Docker"],
|
||||||
|
order: 1,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
company: "Startup Fintech",
|
||||||
|
role: "Fullstack Developer",
|
||||||
|
startDate: new Date("2020-03-01"),
|
||||||
|
endDate: new Date("2021-12-31"),
|
||||||
|
current: false,
|
||||||
|
description:
|
||||||
|
"Built core financial services platform from ground up with emphasis on security and performance.",
|
||||||
|
highlights: [
|
||||||
|
"Developed payment processing system handling Rp 50B+ monthly transactions",
|
||||||
|
"Built real-time notification system using WebSockets and Redis pub/sub",
|
||||||
|
"Implemented automated testing achieving 85%+ code coverage",
|
||||||
|
],
|
||||||
|
techStack: ["React", "Node.js", "Express", "MongoDB", "PostgreSQL"],
|
||||||
|
order: 2,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function getProjects() {
|
||||||
|
try {
|
||||||
|
return await prisma.project.findMany({
|
||||||
|
where: { featured: true },
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return fallbackProjects;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getExperiences() {
|
||||||
|
try {
|
||||||
|
return await prisma.experience.findMany({
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return fallbackExperiences;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function HomePage() {
|
||||||
|
const [projects, experiences] = await Promise.all([
|
||||||
|
getProjects(),
|
||||||
|
getExperiences(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HeroSection />
|
||||||
|
<AboutSection />
|
||||||
|
<TechStackSection />
|
||||||
|
<ProjectsSection projects={projects} />
|
||||||
|
<ExperienceSection experiences={experiences} />
|
||||||
|
<PrincipleSection />
|
||||||
|
<ContactSection />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
src/components/TerminalWindow.tsx
Normal file
163
src/components/TerminalWindow.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
type Step =
|
||||||
|
| { type: "command"; text: string }
|
||||||
|
| { type: "log"; text: string }
|
||||||
|
| { type: "progress"; text: string };
|
||||||
|
|
||||||
|
const steps: Step[] = [
|
||||||
|
{ type: "command", text: "git clone fikri-erp" },
|
||||||
|
{ type: "command", text: "cd fikri-erp" },
|
||||||
|
{ type: "command", text: "docker build -t fikri-erp ." },
|
||||||
|
{ type: "log", text: "building docker image..." },
|
||||||
|
{ type: "progress", text: "docker build" },
|
||||||
|
{ type: "log", text: "✓ docker image built successfully" },
|
||||||
|
{ type: "command", text: "docker-compose up -d" },
|
||||||
|
{ type: "log", text: "starting postgres container..." },
|
||||||
|
{ type: "log", text: "✓ postgres container running" },
|
||||||
|
{ type: "log", text: "starting api container..." },
|
||||||
|
{ type: "log", text: "✓ application deployed successfully" },
|
||||||
|
{ type: "log", text: "✓ ready on https://fikri-erp.com" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TerminalWindow() {
|
||||||
|
const [lines, setLines] = useState<string[]>([]);
|
||||||
|
const [typing, setTyping] = useState("");
|
||||||
|
const [stepIndex, setStepIndex] = useState(0);
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const step = steps[stepIndex];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!step) return;
|
||||||
|
|
||||||
|
if (step.type === "command") {
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setTyping(step.text.slice(0, i + 1));
|
||||||
|
i++;
|
||||||
|
|
||||||
|
if (i === step.text.length) {
|
||||||
|
clearInterval(interval);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setLines((prev) => [...prev, `$ ${step.text}`]);
|
||||||
|
setTyping("");
|
||||||
|
setStepIndex((s) => s + 1);
|
||||||
|
}, 700);
|
||||||
|
}
|
||||||
|
}, 45);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.type === "log") {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setLines((prev) => [...prev, step.text]);
|
||||||
|
setStepIndex((s) => s + 1);
|
||||||
|
}, 800);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.type === "progress") {
|
||||||
|
let progress = 0;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
progress += 10;
|
||||||
|
|
||||||
|
setTyping(
|
||||||
|
`${step.text} [${"█".repeat(progress / 10)}${" ".repeat(
|
||||||
|
10 - progress / 10
|
||||||
|
)}] ${progress}%`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (progress === 100) {
|
||||||
|
clearInterval(interval);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setLines((prev) => [...prev, typing]);
|
||||||
|
setTyping("");
|
||||||
|
setStepIndex((s) => s + 1);
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
}, 120);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [stepIndex]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
containerRef.current?.scrollTo({
|
||||||
|
top: containerRef.current.scrollHeight,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}, [lines, typing]);
|
||||||
|
|
||||||
|
// loop animation
|
||||||
|
useEffect(() => {
|
||||||
|
if (stepIndex >= steps.length) {
|
||||||
|
const reset = setTimeout(() => {
|
||||||
|
setLines([]);
|
||||||
|
setTyping("");
|
||||||
|
setStepIndex(0);
|
||||||
|
}, 2500);
|
||||||
|
|
||||||
|
return () => clearTimeout(reset);
|
||||||
|
}
|
||||||
|
}, [stepIndex]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-[320px] h-[200px] rounded-lg border border-border bg-surface-elevated shadow-xl overflow-hidden font-mono">
|
||||||
|
|
||||||
|
{/* window header */}
|
||||||
|
<div className="flex items-center px-2 py-1.5 border-b border-border bg-surface-elevated/70">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<div className="w-2 h-2 bg-red-500 rounded-full"/>
|
||||||
|
<div className="w-2 h-2 bg-yellow-500 rounded-full"/>
|
||||||
|
<div className="w-2 h-2 bg-green-500 rounded-full"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="ml-2 text-xs text-foreground-muted">
|
||||||
|
fikri@dev-terminal
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* tabs */}
|
||||||
|
<div className="flex gap-1.5 px-2 py-1 border-b border-border bg-surface-elevated/50 text-xs text-foreground-muted">
|
||||||
|
<div className="px-1.5 py-0.5 bg-surface-elevated rounded text-accent">bash</div>
|
||||||
|
<div className="px-1.5 py-0.5 hover:bg-surface-elevated rounded text-foreground-subtle">server</div>
|
||||||
|
<div className="px-1.5 py-0.5 hover:bg-surface-elevated rounded text-foreground-subtle">logs</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* terminal body */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="p-2 text-xs text-foreground h-[150px] overflow-hidden space-y-0.5"
|
||||||
|
>
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<div key={i}>{line}</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{typing && (
|
||||||
|
<div className="flex">
|
||||||
|
<span>{typing}</span>
|
||||||
|
|
||||||
|
<motion.span
|
||||||
|
animate={{ opacity: [0, 1, 0] }}
|
||||||
|
transition={{ repeat: Infinity, duration: 1 }}
|
||||||
|
className="ml-1"
|
||||||
|
>
|
||||||
|
|
|
||||||
|
</motion.span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
src/components/ThemeProvider.tsx
Normal file
8
src/components/ThemeProvider.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||||
|
import type { ThemeProviderProps } from "next-themes";
|
||||||
|
|
||||||
|
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
|
}
|
||||||
143
src/components/dashboard/auth/LoginForm.tsx
Normal file
143
src/components/dashboard/auth/LoginForm.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Terminal, Lock, Mail, ArrowRight, Loader2 } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export function LoginForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/dashboard/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push("/dashboard/cms");
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
setError(error.message);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||||
|
className="w-full max-w-md relative z-10"
|
||||||
|
>
|
||||||
|
<div className="bg-surface/50 backdrop-blur-xl border border-border/50 rounded-2xl p-8 shadow-2xl relative overflow-hidden">
|
||||||
|
{/* Subtle top highlight to simulate glassmorphism lighting */}
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-[1px] bg-gradient-to-r from-transparent via-accent/50 to-transparent" />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col items-center mb-8 text-center">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-accent/10 border border-accent/20 flex items-center justify-center mb-4">
|
||||||
|
<Terminal size={24} className="text-accent" />
|
||||||
|
</div>
|
||||||
|
<h1 className="font-display text-2xl font-bold text-foreground mb-2">
|
||||||
|
Welcome Back
|
||||||
|
</h1>
|
||||||
|
<p className="text-foreground-muted text-sm">
|
||||||
|
Enter your credentials to access the admin panel
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="p-3 rounded-lg bg-destructive/10 border border-destructive/20 text-destructive text-sm text-center"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-foreground-muted pl-1">
|
||||||
|
Email Address
|
||||||
|
</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-foreground-muted group-focus-within:text-accent transition-colors">
|
||||||
|
<Mail size={18} />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="admin@example.com"
|
||||||
|
className="w-full bg-background/50 border border-border rounded-xl py-2.5 pl-10 pr-4 text-sm text-foreground placeholder:text-foreground-muted/50 focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-all"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between pl-1">
|
||||||
|
<label className="text-sm font-medium text-foreground-muted">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="relative group">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-foreground-muted group-focus-within:text-accent transition-colors">
|
||||||
|
<Lock size={18} />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="w-full bg-background/50 border border-border rounded-xl py-2.5 pl-10 pr-4 text-sm text-foreground placeholder:text-foreground-muted/50 focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-all"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full mt-6 flex items-center justify-center gap-2 bg-accent text-background rounded-xl py-3 text-sm font-medium hover:bg-accent/90 focus:ring-2 focus:ring-offset-2 focus:ring-offset-background focus:ring-accent focus:outline-none hover:shadow-glow transition-all disabled:opacity-70 disabled:cursor-not-allowed group"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Sign In
|
||||||
|
<ArrowRight
|
||||||
|
size={16}
|
||||||
|
className="group-hover:translate-x-1 transition-transform"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer info */}
|
||||||
|
<p className="text-center text-xs text-foreground-muted mt-6">
|
||||||
|
© {new Date().getFullYear()} Fikri Maulana. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { getVisitorId } from "@/lib/visitor";
|
||||||
|
|
||||||
|
export function AnalyticsTracker() {
|
||||||
|
useEffect(() => {
|
||||||
|
const visitorId = getVisitorId();
|
||||||
|
|
||||||
|
fetch("/api/track", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
visitorId,
|
||||||
|
path: window.location.pathname,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
250
src/components/sections/portfolio/AboutSection.tsx
Normal file
250
src/components/sections/portfolio/AboutSection.tsx
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState, useEffect } from "react";
|
||||||
|
import { motion, useInView } from "framer-motion";
|
||||||
|
import { Code2, Server, Database, GitBranch } from "lucide-react";
|
||||||
|
|
||||||
|
const skills = [
|
||||||
|
"Next.js",
|
||||||
|
"NestJS",
|
||||||
|
"TypeScript",
|
||||||
|
"React",
|
||||||
|
"Node.js",
|
||||||
|
"PostgreSQL",
|
||||||
|
"Prisma",
|
||||||
|
"TypeORM",
|
||||||
|
"Redis",
|
||||||
|
"Docker",
|
||||||
|
"REST API",
|
||||||
|
"Git",
|
||||||
|
"Microservices",
|
||||||
|
];
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ label: "Years Experience", value: "3+", icon: Code2 },
|
||||||
|
{ label: "System Built", value: "15+", icon: GitBranch },
|
||||||
|
{ label: "Modules Delivered", value: "15+", icon: Server },
|
||||||
|
{ label: "Production Deployments", value: "10+", icon: Database },
|
||||||
|
];
|
||||||
|
|
||||||
|
const containerVariants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: { staggerChildren: 0.08, delayChildren: 0.1 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemVariants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: { opacity: 1, y: 0, transition: { duration: 0.5, ease: "easeOut" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Custom hook for counting animation
|
||||||
|
const useCountingAnimation = (target: number, isInView: boolean, duration: number = 2000, delay: number = 0) => {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
const [shouldStart, setShouldStart] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isInView) {
|
||||||
|
setCount(0);
|
||||||
|
setShouldStart(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start counting after delay
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setShouldStart(true);
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [isInView, delay]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldStart) {
|
||||||
|
setCount(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const endTime = startTime + duration;
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const progress = Math.min((now - startTime) / duration, 1);
|
||||||
|
|
||||||
|
// Easing function for smooth animation
|
||||||
|
const easeOutQuad = 1 - Math.pow(1 - progress, 2);
|
||||||
|
const currentCount = Math.floor(easeOutQuad * target);
|
||||||
|
|
||||||
|
setCount(currentCount);
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
}, [shouldStart, target, duration]);
|
||||||
|
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AboutSection() {
|
||||||
|
const ref = useRef(null);
|
||||||
|
const statsRef = useRef(null);
|
||||||
|
const quoteRef = useRef(null);
|
||||||
|
const isInView = useInView(ref, { once: true, margin: "-10%" });
|
||||||
|
const statsInView = useInView(statsRef, { once: true, margin: "-10%" });
|
||||||
|
const quoteInView = useInView(quoteRef, { once: true, margin: "-10%" });
|
||||||
|
|
||||||
|
// Extract numeric values and use counting animation
|
||||||
|
// Calculate delay: staggerChildren: 0.08, delayChildren: 0.1, item duration: 0.5
|
||||||
|
// Stats are in the right column, so they appear after left column items
|
||||||
|
const entranceDelay = 600; // Reduced delay - after entrance animation completes
|
||||||
|
const yearsCount = useCountingAnimation(3, statsInView, 1500, entranceDelay);
|
||||||
|
const systemsCount = useCountingAnimation(15, statsInView, 2000, entranceDelay + 100);
|
||||||
|
const modulesCount = useCountingAnimation(15, statsInView, 2000, entranceDelay + 200);
|
||||||
|
const deploymentsCount = useCountingAnimation(10, statsInView, 1800, entranceDelay + 300);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="about" className="py-5 lg:py-32 relative overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-background via-surface/30 to-background" />
|
||||||
|
|
||||||
|
<div className="relative z-10 max-w-6xl mx-auto px-6">
|
||||||
|
<motion.div
|
||||||
|
ref={ref}
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate={isInView ? "visible" : "hidden"}
|
||||||
|
className="grid lg:grid-cols-2 gap-16 items-start"
|
||||||
|
>
|
||||||
|
{/* Left: Text content */}
|
||||||
|
<div>
|
||||||
|
<motion.div variants={itemVariants} className="mb-3">
|
||||||
|
<span className="text-accent font-mono text-sm tracking-widest uppercase">
|
||||||
|
About Me
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.h2
|
||||||
|
variants={itemVariants}
|
||||||
|
className="font-display font-bold text-4xl md:text-5xl text-foreground mb-6 leading-tight"
|
||||||
|
>
|
||||||
|
Engineering Beyond
|
||||||
|
<br />
|
||||||
|
<span className="gradient-text-blue">Just Code</span>
|
||||||
|
</motion.h2>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
variants={itemVariants}
|
||||||
|
className="space-y-4 text-foreground-muted leading-relaxed"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
I'm a Fullstack Developer with 3+ years of experience building production-ready web applications using{" "}
|
||||||
|
<span className="text-foreground font-medium">Next.js</span> and{" "}
|
||||||
|
<span className="text-foreground font-medium">NestJS</span>.
|
||||||
|
My experience includes developing WhatsApp chatbot integrations,
|
||||||
|
building high-traffic lottery web applications, creating internal business dashboards,
|
||||||
|
and contributing to ERP-based POS systems.
|
||||||
|
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
My expertise spans scalable frontend architecture with Next.js,
|
||||||
|
Zustand, Tailwind CSS, and Framer Motion, as well as secure
|
||||||
|
backend systems using{" "}
|
||||||
|
<span className="text-foreground font-medium">NestJS</span>,{" "}
|
||||||
|
<span className="text-foreground font-medium">PostgreSQL</span>,
|
||||||
|
and <span className="text-foreground font-medium">MongoDB</span>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
I focus on writing clean, maintainable code while ensuring
|
||||||
|
performance, validation, and system reliability. Every feature I
|
||||||
|
build is designed with scalability and long-term maintainability
|
||||||
|
in mind.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Whether it's architecting an internal dashboard from scratch,
|
||||||
|
optimizing complex database queries, or designing modular
|
||||||
|
backend systems — I bring both execution and engineering
|
||||||
|
thinking to every project.
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Skill badges */}
|
||||||
|
<motion.div
|
||||||
|
variants={containerVariants}
|
||||||
|
className="flex flex-wrap gap-2 mt-8"
|
||||||
|
>
|
||||||
|
{skills.map((skill) => (
|
||||||
|
<motion.span
|
||||||
|
key={skill}
|
||||||
|
variants={itemVariants}
|
||||||
|
className="px-3 py-1.5 text-xs font-mono font-medium bg-surface-elevated border border-border text-foreground-muted rounded-lg hover:border-accent/40 hover:text-accent hover:bg-accent/5 transition-all duration-200 cursor-default"
|
||||||
|
>
|
||||||
|
{skill}
|
||||||
|
</motion.span>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Stats grid */}
|
||||||
|
<motion.div
|
||||||
|
ref={statsRef}
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate={statsInView ? "visible" : "hidden"}
|
||||||
|
className="grid grid-cols-2 gap-4"
|
||||||
|
>
|
||||||
|
{stats.map((stat, index) => {
|
||||||
|
const Icon = stat.icon;
|
||||||
|
let displayValue = stat.value;
|
||||||
|
|
||||||
|
// Use animated values based on index
|
||||||
|
if (index === 0) displayValue = `${yearsCount}+`;
|
||||||
|
else if (index === 1) displayValue = `${systemsCount}+`;
|
||||||
|
else if (index === 2) displayValue = `${modulesCount}+`;
|
||||||
|
else if (index === 3) displayValue = `${deploymentsCount}+`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={stat.label}
|
||||||
|
variants={itemVariants}
|
||||||
|
className="p-6 rounded-2xl bg-surface-elevated border border-border hover:border-accent/30 hover:shadow-glow transition-all duration-300 group"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-accent/10 border border-border flex items-center justify-center mb-4 group-hover:bg-accent/20 transition-colors">
|
||||||
|
<Icon size={18} className="text-accent" />
|
||||||
|
</div>
|
||||||
|
<p className="font-display font-bold text-4xl text-foreground mb-1">
|
||||||
|
{displayValue}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-foreground-muted">{stat.label}</p>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Quote card */}
|
||||||
|
<motion.div
|
||||||
|
ref={quoteRef}
|
||||||
|
variants={itemVariants}
|
||||||
|
className="col-span-2 p-6 rounded-2xl bg-gradient-to-br from-accent/10 to-purple-500/5 border border-border"
|
||||||
|
initial="hidden"
|
||||||
|
animate={quoteInView ? "visible" : "hidden"}
|
||||||
|
>
|
||||||
|
<p className="text-foreground-muted text-sm leading-relaxed italic">
|
||||||
|
"I focus on writing clean, maintainable code while thinking about how systems grow over time. I value good architecture, scalability, and building solutions that are easy for teams to maintain."
|
||||||
|
</p>
|
||||||
|
<p className="text-accent text-sm font-medium mt-3">
|
||||||
|
— Engineering Philosophy
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
267
src/components/sections/portfolio/ContactSection.tsx
Normal file
267
src/components/sections/portfolio/ContactSection.tsx
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { Send, CheckCircle2, Mail, MapPin, Clock } from 'lucide-react'
|
||||||
|
import type { ContactFormData } from '@/types'
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().min(2, 'Name must be at least 2 characters'),
|
||||||
|
email: z.string().email('Please enter a valid email'),
|
||||||
|
subject: z.string().min(4, 'Subject must be at least 4 characters'),
|
||||||
|
message: z.string().min(10, 'Message must be at least 10 characters'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const contactInfo = [
|
||||||
|
{ icon: Mail, label: 'Email', value: 'hello@yourportfolio.dev' },
|
||||||
|
{ icon: MapPin, label: 'Location', value: 'Indonesia' },
|
||||||
|
{ icon: Clock, label: 'Response time', value: 'Within 24 hours' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function ContactSection() {
|
||||||
|
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
|
||||||
|
const [errorMsg, setErrorMsg] = useState('')
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<ContactFormData>({ resolver: zodResolver(schema) })
|
||||||
|
|
||||||
|
const onSubmit = async (data: ContactFormData) => {
|
||||||
|
setStatus('loading')
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/contact', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
const json = await res.json()
|
||||||
|
if (json.success) {
|
||||||
|
setStatus('success')
|
||||||
|
reset()
|
||||||
|
} else {
|
||||||
|
setErrorMsg(json.error || 'Something went wrong')
|
||||||
|
setStatus('error')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setErrorMsg('Network error. Please try again.')
|
||||||
|
setStatus('error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="contact" className="py-32 relative overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-surface/20 to-background" />
|
||||||
|
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-[600px] h-[300px] bg-accent/6 rounded-full blur-[120px] pointer-events-none" />
|
||||||
|
|
||||||
|
<div className="relative z-10 max-w-6xl mx-auto px-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<motion.span
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
whileInView={{ opacity: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
className="text-accent font-mono text-sm tracking-widest uppercase"
|
||||||
|
>
|
||||||
|
Get in Touch
|
||||||
|
</motion.span>
|
||||||
|
<motion.h2
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.1 }}
|
||||||
|
className="font-display font-bold text-4xl md:text-5xl text-foreground mt-3"
|
||||||
|
>
|
||||||
|
Let's Build Something
|
||||||
|
<br />
|
||||||
|
<span className="gradient-text-blue">Together</span>
|
||||||
|
</motion.h2>
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.2 }}
|
||||||
|
className="text-foreground-muted mt-4 max-w-xl mx-auto"
|
||||||
|
>
|
||||||
|
Have a project in mind? Let's talk about how I can help you build something
|
||||||
|
remarkable.
|
||||||
|
</motion.p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-5 gap-8 items-start">
|
||||||
|
{/* Left: Contact info */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
className="lg:col-span-2 space-y-4"
|
||||||
|
>
|
||||||
|
{contactInfo.map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.label}
|
||||||
|
className="flex items-center gap-4 p-4 rounded-xl bg-surface-elevated border border-border"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-accent/10 border border-border flex items-center justify-center flex-shrink-0">
|
||||||
|
<Icon size={16} className="text-accent" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-foreground-muted font-mono">{item.label}</p>
|
||||||
|
<p className="text-sm text-foreground font-medium">{item.value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="p-5 rounded-xl bg-gradient-to-br from-accent/10 to-purple-500/5 border border-border mt-6">
|
||||||
|
<p className="text-sm text-foreground-muted leading-relaxed">
|
||||||
|
I'm currently open to{' '}
|
||||||
|
<span className="text-foreground font-medium">freelance projects</span>,{' '}
|
||||||
|
<span className="text-foreground font-medium">full-time positions</span>, and
|
||||||
|
interesting technical collaborations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Right: Form */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.1 }}
|
||||||
|
className="lg:col-span-3"
|
||||||
|
>
|
||||||
|
<div className="rounded-2xl bg-surface-elevated border border-border p-8">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{status === 'success' ? (
|
||||||
|
<motion.div
|
||||||
|
key="success"
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
className="text-center py-12"
|
||||||
|
>
|
||||||
|
<div className="w-16 h-16 rounded-full bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<CheckCircle2 size={28} className="text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-display font-semibold text-xl text-foreground mb-2">
|
||||||
|
Message Sent!
|
||||||
|
</h3>
|
||||||
|
<p className="text-foreground-muted text-sm">
|
||||||
|
Thank you for reaching out. I'll get back to you within 24 hours.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setStatus('idle')}
|
||||||
|
className="mt-6 text-sm text-accent hover:text-accent/80 transition-colors"
|
||||||
|
>
|
||||||
|
Send another message
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.form
|
||||||
|
key="form"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="space-y-5"
|
||||||
|
>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-5">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-foreground-muted mb-2">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('name')}
|
||||||
|
placeholder="Your Name"
|
||||||
|
className="w-full px-4 py-3 bg-surface border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20 transition-all text-sm"
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="mt-1.5 text-xs text-red-400">{errors.name.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-foreground-muted mb-2">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('email')}
|
||||||
|
type="email"
|
||||||
|
placeholder="you@email.com"
|
||||||
|
className="w-full px-4 py-3 bg-surface border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20 transition-all text-sm"
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="mt-1.5 text-xs text-red-400">{errors.email.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-foreground-muted mb-2">
|
||||||
|
Subject
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('subject')}
|
||||||
|
placeholder="Project inquiry / Collaboration / etc."
|
||||||
|
className="w-full px-4 py-3 bg-surface border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20 transition-all text-sm"
|
||||||
|
/>
|
||||||
|
{errors.subject && (
|
||||||
|
<p className="mt-1.5 text-xs text-red-400">{errors.subject.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-foreground-muted mb-2">
|
||||||
|
Message
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
{...register('message')}
|
||||||
|
rows={5}
|
||||||
|
placeholder="Tell me about your project, goals, and timeline..."
|
||||||
|
className="w-full px-4 py-3 bg-surface border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20 transition-all text-sm resize-none"
|
||||||
|
/>
|
||||||
|
{errors.message && (
|
||||||
|
<p className="mt-1.5 text-xs text-red-400">{errors.message.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<p className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 px-4 py-3 rounded-xl">
|
||||||
|
{errorMsg}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={status === 'loading'}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-6 py-3.5 bg-accent text-background font-semibold rounded-xl hover:bg-accent/90 transition-all hover:shadow-glow disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{status === 'loading' ? (
|
||||||
|
<>
|
||||||
|
<div className="w-4 h-4 border-2 border-background/30 border-t-background rounded-full animate-spin" />
|
||||||
|
Sending...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send size={16} />
|
||||||
|
Send Message
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</motion.form>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
266
src/components/sections/portfolio/DashboardSidebar.tsx
Normal file
266
src/components/sections/portfolio/DashboardSidebar.tsx
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Menu,
|
||||||
|
X,
|
||||||
|
LayoutDashboard,
|
||||||
|
LogOut,
|
||||||
|
MessageSquare,
|
||||||
|
Briefcase,
|
||||||
|
FileText,
|
||||||
|
FolderGit2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ThemeToggle } from "@/components/ui/ThemeToggle";
|
||||||
|
|
||||||
|
const dashboardLinks = [
|
||||||
|
{ label: "Overview", href: "/dashboard", icon: LayoutDashboard },
|
||||||
|
{ label: "Messages", href: "/dashboard/messages", icon: MessageSquare },
|
||||||
|
{ label: "Experience", href: "/dashboard/experience", icon: Briefcase },
|
||||||
|
{ label: "Blog", href: "/dashboard/blog", icon: FileText },
|
||||||
|
{ label: "Projects", href: "/dashboard/projects", icon: FolderGit2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DashboardSidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Hide sidebar entirely if user is on login page
|
||||||
|
if (pathname === "/dashboard/auth") return null;
|
||||||
|
|
||||||
|
if (!mounted) return null; // Prevent hydration mismatch
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Desktop Sidebar (Sticky) */}
|
||||||
|
<motion.aside
|
||||||
|
initial={false}
|
||||||
|
animate={{ width: isCollapsed ? 80 : 256 }}
|
||||||
|
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||||
|
className="hidden md:flex flex-col sticky top-0 h-screen bg-background border-r border-border/50 shrink-0 z-40 group/sidebar"
|
||||||
|
>
|
||||||
|
<div className="flex items-center p-4 h-16 border-b border-border/50">
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 group w-full",
|
||||||
|
isCollapsed && "justify-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 shrink-0 rounded-md bg-primary/20 border border-primary/30 flex items-center justify-center group-hover:bg-primary/30 transition-colors">
|
||||||
|
<LayoutDashboard size={16} className="text-primary" />
|
||||||
|
</div>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<span className="font-display font-semibold text-foreground text-sm tracking-wide whitespace-nowrap overflow-hidden">
|
||||||
|
Admin<span className="text-primary">Panel</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||||
|
className="absolute -right-3.5 top-20 bg-background border border-border rounded-full p-1.5 hover:bg-surface-elevated text-foreground-muted hover:text-foreground z-50 opacity-0 group-hover/sidebar:opacity-100 transition-opacity"
|
||||||
|
aria-label="Toggle Sidebar"
|
||||||
|
>
|
||||||
|
{isCollapsed ? <ChevronRight size={14} /> : <ChevronLeft size={14} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex-1 py-6 flex flex-col gap-2 px-3 overflow-y-auto overflow-x-hidden">
|
||||||
|
{dashboardLinks.map((link) => {
|
||||||
|
const Icon = link.icon;
|
||||||
|
const isActive =
|
||||||
|
link.href === "/dashboard"
|
||||||
|
? pathname === "/dashboard"
|
||||||
|
: pathname.startsWith(link.href);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
title={isCollapsed ? link.label : undefined}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors overflow-hidden group/link",
|
||||||
|
isActive
|
||||||
|
? "bg-primary/10 text-primary font-medium"
|
||||||
|
: "text-foreground-muted hover:bg-surface-elevated hover:text-foreground",
|
||||||
|
isCollapsed && "justify-center px-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size={18}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0",
|
||||||
|
isActive
|
||||||
|
? "text-primary"
|
||||||
|
: "text-foreground-muted group-hover/link:text-foreground",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<span className="whitespace-nowrap">{link.label}</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-border/50 flex flex-col gap-2 relative">
|
||||||
|
{!isCollapsed && (
|
||||||
|
<div className="flex items-center justify-between px-3 py-2">
|
||||||
|
<span className="text-sm text-foreground-muted font-medium">
|
||||||
|
Theme
|
||||||
|
</span>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isCollapsed && (
|
||||||
|
<div className="flex justify-center py-2">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors w-full text-destructive hover:bg-destructive/10 hover:text-destructive overflow-hidden group/logout",
|
||||||
|
isCollapsed && "justify-center px-0",
|
||||||
|
)}
|
||||||
|
title={isCollapsed ? "Logout" : undefined}
|
||||||
|
>
|
||||||
|
<LogOut
|
||||||
|
size={18}
|
||||||
|
className="shrink-0 group-hover/logout:text-destructive text-destructive"
|
||||||
|
/>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<span className="font-medium whitespace-nowrap">Logout</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.aside>
|
||||||
|
|
||||||
|
{/* Mobile Header (Fixed) */}
|
||||||
|
<div className="md:hidden fixed top-0 left-0 right-0 h-16 bg-background/90 backdrop-blur-xl border-b border-border/50 z-40 flex items-center justify-between px-4">
|
||||||
|
<Link href="/dashboard" className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-md bg-primary/20 border border-primary/30 flex items-center justify-center">
|
||||||
|
<LayoutDashboard size={14} className="text-primary" />
|
||||||
|
</div>
|
||||||
|
<span className="font-display font-semibold text-foreground text-sm tracking-wide">
|
||||||
|
Admin<span className="text-primary">Panel</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ThemeToggle />
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileOpen(true)}
|
||||||
|
className="p-2 -mr-2 rounded-lg hover:bg-surface-elevated transition-colors text-foreground"
|
||||||
|
aria-label="Open menu"
|
||||||
|
>
|
||||||
|
<Menu size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Sidebar Off-Canvas */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{mobileOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className="md:hidden fixed inset-0 bg-background/80 backdrop-blur-sm z-50 cursor-pointer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{mobileOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ x: "-100%" }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: "-100%" }}
|
||||||
|
transition={{ type: "spring", bounce: 0, duration: 0.4 }}
|
||||||
|
className="md:hidden fixed top-0 left-0 bottom-0 w-[280px] bg-background border-r border-border z-50 flex flex-col shadow-2xl"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-4 h-16 border-b border-border/50">
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 rounded-md bg-primary/20 border border-primary/30 flex items-center justify-center">
|
||||||
|
<LayoutDashboard size={16} className="text-primary" />
|
||||||
|
</div>
|
||||||
|
<span className="font-display font-semibold text-foreground text-sm tracking-wide">
|
||||||
|
Admin<span className="text-primary">Panel</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className="p-2 rounded-lg hover:bg-surface-elevated transition-colors text-foreground-muted"
|
||||||
|
aria-label="Close menu"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 py-6 px-4 flex flex-col gap-2 overflow-y-auto">
|
||||||
|
<span className="text-xs font-semibold text-foreground-muted mb-2 px-3 uppercase tracking-wider">
|
||||||
|
Navigation
|
||||||
|
</span>
|
||||||
|
{dashboardLinks.map((link) => {
|
||||||
|
const Icon = link.icon;
|
||||||
|
const isActive =
|
||||||
|
link.href === "/dashboard"
|
||||||
|
? pathname === "/dashboard"
|
||||||
|
: pathname.startsWith(link.href);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 px-3 py-3 rounded-lg transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-primary/10 text-primary font-medium"
|
||||||
|
: "text-foreground-muted hover:bg-surface-elevated hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size={18}
|
||||||
|
className={cn(
|
||||||
|
isActive ? "text-primary" : "text-foreground-muted",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span>{link.label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-border/50 flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-3 px-3 py-3 rounded-lg transition-colors w-full text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
>
|
||||||
|
<LogOut size={18} />
|
||||||
|
<span className="font-medium">Logout</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
src/components/sections/portfolio/ExperienceSection.tsx
Normal file
129
src/components/sections/portfolio/ExperienceSection.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRef } from 'react'
|
||||||
|
import { motion, useInView } from 'framer-motion'
|
||||||
|
import { Briefcase, Calendar, CheckCircle2 } from 'lucide-react'
|
||||||
|
import type { Experience } from '@/types'
|
||||||
|
|
||||||
|
interface ExperienceSectionProps {
|
||||||
|
experiences: Experience[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date): string {
|
||||||
|
return new Date(date).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExperienceItem({ exp, index }: { exp: Experience; index: number }) {
|
||||||
|
const ref = useRef(null)
|
||||||
|
const isInView = useInView(ref, { once: true, margin: '-50px' })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
ref={ref}
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||||
|
transition={{ duration: 0.5, delay: index * 0.15, ease: 'easeOut' }}
|
||||||
|
className="relative pl-8 pb-12 last:pb-0"
|
||||||
|
>
|
||||||
|
{/* Timeline dot */}
|
||||||
|
<div className="absolute left-0 top-1 w-3 h-3 rounded-full bg-accent border-2 border-accent/40 shadow-glow z-10" />
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="bg-surface-elevated border border-border rounded-2xl p-6 hover:border-accent/30 hover:shadow-glow transition-all duration-300 overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Briefcase size={14} className="text-accent" />
|
||||||
|
<h3 className="font-display font-semibold text-foreground text-lg">
|
||||||
|
{exp.role}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-accent font-medium text-sm">{exp.company}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-foreground-muted font-mono bg-surface border border-border px-3 py-1.5 rounded-lg">
|
||||||
|
<Calendar size={11} />
|
||||||
|
{formatDate(exp.startDate)} — {exp.current ? 'Present' : exp.endDate ? formatDate(exp.endDate) : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-foreground-muted text-sm leading-relaxed mb-5">
|
||||||
|
{exp.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Highlights */}
|
||||||
|
<ul className="space-y-2 mb-5">
|
||||||
|
{exp.highlights.map((h) => (
|
||||||
|
<li key={h} className="flex items-start gap-2.5 text-sm text-foreground-muted">
|
||||||
|
<CheckCircle2 size={14} className="text-accent mt-0.5 flex-shrink-0" />
|
||||||
|
<span>{h}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* Tech stack */}
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{exp.techStack.map((tech) => (
|
||||||
|
<span
|
||||||
|
key={tech}
|
||||||
|
className="px-2.5 py-1 text-xs font-mono bg-surface border border-border-subtle text-foreground-muted rounded-md"
|
||||||
|
>
|
||||||
|
{tech}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExperienceSection({ experiences }: ExperienceSectionProps) {
|
||||||
|
return (
|
||||||
|
<section id="experience" className="py-16 sm:py-20 lg:py-32 relative">
|
||||||
|
<div className="max-w-4xl mx-auto px-4 sm:px-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<motion.span
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
whileInView={{ opacity: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
className="text-accent font-mono text-sm tracking-widest uppercase"
|
||||||
|
>
|
||||||
|
Career
|
||||||
|
</motion.span>
|
||||||
|
<motion.h2
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.1 }}
|
||||||
|
className="font-display font-bold text-4xl md:text-5xl text-foreground mt-3"
|
||||||
|
>
|
||||||
|
Experience
|
||||||
|
</motion.h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
<div className="relative">
|
||||||
|
{/* Vertical line */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ scaleY: 0 }}
|
||||||
|
whileInView={{ scaleY: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.8, ease: 'easeOut' }}
|
||||||
|
className="absolute left-1.5 top-0 bottom-0 w-px bg-gradient-to-b from-accent via-accent/50 to-transparent origin-top"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{experiences.map((exp, i) => (
|
||||||
|
<ExperienceItem key={exp.id} exp={exp} index={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
79
src/components/sections/portfolio/Footer.tsx
Normal file
79
src/components/sections/portfolio/Footer.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
import { Github, Linkedin, Mail, Terminal } from "lucide-react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { NavLinks, ContactUrls } from "@/lib/constant";
|
||||||
|
|
||||||
|
export function Footer() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
if (pathname?.startsWith("/dashboard")) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="border-t border-border bg-surface/30">
|
||||||
|
<div className="max-w-6xl mx-auto px-6 py-12">
|
||||||
|
<div className="flex flex-col md:flex-row items-center justify-between gap-8">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-7 h-7 rounded-md bg-accent/20 border border-accent/30 flex items-center justify-center">
|
||||||
|
<Terminal size={14} className="text-accent" />
|
||||||
|
</div>
|
||||||
|
<span className="font-display font-semibold text-foreground text-sm">
|
||||||
|
fikri<span className="text-accent">.</span>maulana
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav links */}
|
||||||
|
<nav className="flex items-center gap-6">
|
||||||
|
{NavLinks.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
className="text-sm text-foreground-muted hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Social */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<a
|
||||||
|
href={ContactUrls.github}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="p-2 rounded-lg text-foreground-muted hover:text-foreground hover:bg-surface-elevated transition-all"
|
||||||
|
aria-label="GitHub"
|
||||||
|
>
|
||||||
|
<Github size={18} />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={ContactUrls.linkedin}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="p-2 rounded-lg text-foreground-muted hover:text-foreground hover:bg-surface-elevated transition-all"
|
||||||
|
aria-label="LinkedIn"
|
||||||
|
>
|
||||||
|
<Linkedin size={18} />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={ContactUrls.email}
|
||||||
|
className="p-2 rounded-lg text-foreground-muted hover:text-foreground hover:bg-surface-elevated transition-all"
|
||||||
|
aria-label="Email"
|
||||||
|
>
|
||||||
|
<Mail size={18} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 pt-8 border-t border-border/50 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<p className="text-xs text-foreground-subtle font-mono">
|
||||||
|
© {new Date().getFullYear()} Fikri Maulana. Built with Next.js &
|
||||||
|
TailwindCSS.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-foreground-subtle">
|
||||||
|
Designed with precision. Engineered for scale.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
384
src/components/sections/portfolio/HeroSection.tsx
Normal file
384
src/components/sections/portfolio/HeroSection.tsx
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { ArrowRight, Github, Linkedin, Mail, ChevronDown, MessageCircle, Zap } from "lucide-react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { HeroSection as HeroData } from "@/lib/constant";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import TerminalWindow from "@/components/TerminalWindow";
|
||||||
|
|
||||||
|
// Dynamic import for Lottie animation
|
||||||
|
const LottieAnimation = dynamic(
|
||||||
|
() => import("lottie-react").then((mod) => mod.default),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="w-20 h-20 animate-pulse bg-accent/20 rounded-xl" />
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const containerVariants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.12,
|
||||||
|
delayChildren: 0.2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemVariants = {
|
||||||
|
hidden: { opacity: 0, y: 24 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: { duration: 0.5, ease: "easeOut" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HeroSection() {
|
||||||
|
const [loadingAnimation, setLoadingAnimation] = useState(null);
|
||||||
|
const [isDark, setIsDark] = useState(false);
|
||||||
|
const [showScrollIndicator, setShowScrollIndicator] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
|
||||||
|
fetch("/lottie/loading.json")
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => setLoadingAnimation(data))
|
||||||
|
.catch((err) =>
|
||||||
|
console.error("Failed to load Lottie animation:", err)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check dark mode
|
||||||
|
const checkDarkMode = () => {
|
||||||
|
setIsDark(document.documentElement.classList.contains('dark'));
|
||||||
|
};
|
||||||
|
|
||||||
|
checkDarkMode();
|
||||||
|
|
||||||
|
// Listen for theme changes
|
||||||
|
const observer = new MutationObserver(checkDarkMode);
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class']
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
const scrollTop = window.scrollY;
|
||||||
|
const windowHeight = window.innerHeight;
|
||||||
|
const documentHeight = document.documentElement.scrollHeight;
|
||||||
|
|
||||||
|
// Hide scroll indicator when user is at the bottom (within 50px of bottom)
|
||||||
|
if (scrollTop + windowHeight >= documentHeight - 50) {
|
||||||
|
setShowScrollIndicator(false);
|
||||||
|
} else {
|
||||||
|
setShowScrollIndicator(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', handleScroll);
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id="hero"
|
||||||
|
aria-labelledby="hero-title"
|
||||||
|
className="relative min-h-screen flex flex-col items-center justify-center overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Background effects */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-grid opacity-100"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-gradient-to-b from-background via-background/95 to-background"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Mobile photo background */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 lg:hidden opacity-10"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${HeroData.image})`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
backgroundRepeat: 'no-repeat'
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Blur orbs */}
|
||||||
|
<div
|
||||||
|
className="absolute top-1/4 left-1/4 w-96 h-96 bg-accent/8 rounded-full blur-[120px] pointer-events-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute bottom-1/3 right-1/4 w-80 h-80 bg-purple-500/6 rounded-full blur-[100px] pointer-events-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-accent/4 rounded-full blur-[150px] pointer-events-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative z-10 max-w-7xl mx-auto px-6 lg:px-8">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center min-h-[70vh] py-20 lg:py-0 lg:gap-16">
|
||||||
|
<motion.div
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
className="text-center lg:text-left flex flex-col items-center lg:items-start"
|
||||||
|
>
|
||||||
|
{/* Status badge */}
|
||||||
|
<motion.div variants={itemVariants} className="mb-8">
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-border bg-accent/10 text-accent text-sm font-medium"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse-slow"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{HeroData.badge || "Available for opportunities"}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Lottie animation */}
|
||||||
|
<motion.div
|
||||||
|
variants={itemVariants}
|
||||||
|
className="-mt-8 block lg:hidden overflow-hidden rounded-md"
|
||||||
|
>
|
||||||
|
{loadingAnimation && (
|
||||||
|
<LottieAnimation
|
||||||
|
animationData={loadingAnimation}
|
||||||
|
loop
|
||||||
|
autoplay
|
||||||
|
className="w-[300px] h-[200px] object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Main headline */}
|
||||||
|
<motion.h1
|
||||||
|
id="hero-title"
|
||||||
|
variants={itemVariants}
|
||||||
|
className="font-display font-bold text-5xl sm:text-6xl md:text-7xl lg:text-8xl leading-[1.05] tracking-tight mb-6"
|
||||||
|
>
|
||||||
|
<span className="text-foreground">
|
||||||
|
{HeroData.headline || "Building Systems"}
|
||||||
|
</span>
|
||||||
|
<br />
|
||||||
|
<span className="gradient-text">
|
||||||
|
{HeroData.headline2 || "That Scale."}
|
||||||
|
</span>
|
||||||
|
</motion.h1>
|
||||||
|
|
||||||
|
{/* Tagline */}
|
||||||
|
<motion.p
|
||||||
|
variants={itemVariants}
|
||||||
|
className="text-lg md:text-xl text-foreground-muted mb-4 font-light leading-relaxed max-w-2xl"
|
||||||
|
>
|
||||||
|
{HeroData.tagline1 ||
|
||||||
|
"Senior Fullstack Engineer — Next.js & NestJS Specialist"}
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
<motion.p
|
||||||
|
variants={itemVariants}
|
||||||
|
className="text-base text-foreground-subtle mb-10 leading-relaxed max-w-xl"
|
||||||
|
>
|
||||||
|
{HeroData.tagline2 ||
|
||||||
|
"I architect and ship production-grade web applications with a focus on clean architecture, performance, and developer experience."}
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
{/* CTA Buttons */}
|
||||||
|
<motion.nav
|
||||||
|
aria-label="Primary Actions"
|
||||||
|
variants={itemVariants}
|
||||||
|
className="flex flex-wrap items-center justify-center lg:justify-start gap-4 mb-14"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="#projects"
|
||||||
|
className="group inline-flex items-center gap-2 px-6 py-3 bg-accent text-white font-semibold rounded-xl hover:bg-accent/90 transition-all hover:shadow-glow hover:-translate-y-0.5 duration-200"
|
||||||
|
>
|
||||||
|
View Projects
|
||||||
|
<ArrowRight
|
||||||
|
size={16}
|
||||||
|
className="group-hover:translate-x-1 transition-transform"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#contact"
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-3 bg-surface-elevated border border-border text-foreground font-medium rounded-xl hover:border-accent/40 hover:bg-surface-elevated/80 transition-all hover:-translate-y-0.5 duration-200"
|
||||||
|
>
|
||||||
|
<Mail size={16} className="text-accent" aria-hidden="true" />
|
||||||
|
Contact Me
|
||||||
|
</a>
|
||||||
|
</motion.nav>
|
||||||
|
|
||||||
|
{/* Social links */}
|
||||||
|
<motion.nav
|
||||||
|
aria-label="Social Media Profiles"
|
||||||
|
variants={itemVariants}
|
||||||
|
className="flex items-center justify-center lg:justify-start gap-4"
|
||||||
|
>
|
||||||
|
<ul className="flex items-center gap-4 m-0 p-0 list-none">
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://github.com/Dzuuul"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="block p-2.5 rounded-xl border border-border hover:border-accent/40 hover:bg-surface-elevated text-foreground-muted hover:text-foreground transition-all hover:-translate-y-0.5 duration-200"
|
||||||
|
aria-label="GitHub Profile"
|
||||||
|
>
|
||||||
|
<Github size={18} aria-hidden="true" />
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://www.linkedin.com/in/dzulfikrimaulana"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="block p-2.5 rounded-xl border border-border hover:border-accent/40 hover:bg-surface-elevated text-foreground-muted hover:text-foreground transition-all hover:-translate-y-0.5 duration-200"
|
||||||
|
aria-label="LinkedIn Profile"
|
||||||
|
>
|
||||||
|
<Linkedin size={18} aria-hidden="true" />
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://wa.me/6285163616363?text=Hi%20Fikri,%0D%0AI%20am%20[Name]%20from%20[Company/Organization%20Name].%0D%0AI%20recently%20explored%20your%20portfolio%20and%20was%20impressed%20by%20your%20work%20as%20a%20Fullstack%20Developer.%0D%0AI%20would%20like%20to%20discuss%20a%20potential%20opportunity%20with%20you,%20whether%20it%20be%20for%20a%20career%20role%20within%20our%20team%20or%20a%20professional%20collaboration%20on%20an%20upcoming%20project.%0D%0AAre%20you%20available%20for%20a%20brief%20introductory%20call%20or%20a%20chat%20sometime%20this%20week%20to%20discuss%20this%20further?%0D%0A%0D%0ABest%20regards,%0D%0A[Name]%0D%0A[LinkedIn%20Profile/Contact%20Info]"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="block p-2.5 rounded-xl border border-border hover:border-accent/40 hover:bg-surface-elevated text-foreground-muted hover:text-foreground transition-all hover:-translate-y-0.5 duration-200"
|
||||||
|
aria-label="WhatsApp Contact"
|
||||||
|
>
|
||||||
|
<MessageCircle size={18} aria-hidden="true" />
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="w-px h-5 bg-border mx-1" aria-hidden="true" />
|
||||||
|
|
||||||
|
<span className="text-foreground-subtle text-sm font-mono">
|
||||||
|
3+ yrs experience
|
||||||
|
</span>
|
||||||
|
</motion.nav>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Right Section: Photo - Desktop Only */}
|
||||||
|
<motion.div
|
||||||
|
variants={itemVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
className="relative hidden lg:flex justify-end"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="relative group max-w-xs sm:max-w-md w-full aspect-square"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
{/* Window frame */}
|
||||||
|
<div className="relative h-full w-full rounded-lg border border-border bg-surface-elevated shadow-2xl overflow-hidden">
|
||||||
|
|
||||||
|
{/* Window header */}
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-surface-elevated/70">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="w-3 h-3 bg-red-500 rounded-full" />
|
||||||
|
<div className="w-3 h-3 bg-yellow-500 rounded-full" />
|
||||||
|
<div className="w-3 h-3 bg-green-500 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-foreground-muted">
|
||||||
|
portfolio.jpg
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image content */}
|
||||||
|
<div className="flex-1 flex items-center justify-center bg-surface">
|
||||||
|
<img
|
||||||
|
src={HeroData.image}
|
||||||
|
alt="Professional portrait"
|
||||||
|
className="max-w-full max-h-full object-contain group-hover:scale-105 transition-transform duration-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating card decor */}
|
||||||
|
<div className="absolute -bottom-8 -left-8 rounded-xl bg-surface-elevated border border-border p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-accent/10 text-accent">
|
||||||
|
<Zap
|
||||||
|
size={24}
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-semibold text-foreground">10+ Projects</p>
|
||||||
|
<p className="text-sm text-foreground-muted">Completed successfully</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating card decor */}
|
||||||
|
<div className="absolute -top-12 -right-8 rounded-xl bg-surface-elevated border border-border p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-accent/10 text-accent">
|
||||||
|
<Zap
|
||||||
|
size={24}
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-semibold text-foreground">Production Systems</p>
|
||||||
|
<p className="text-sm text-foreground-muted">Built & Maintained</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Terminal Window */}
|
||||||
|
<div className="absolute -bottom-[45px] -right-[100px]">
|
||||||
|
<TerminalWindow />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll indicator */}
|
||||||
|
{showScrollIndicator && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 1.5, duration: 0.5 }}
|
||||||
|
className="fixed -bottom-3 left-1/2 transform -translate-x-1/2 z-50"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{/* Rounded rectangle container with content inside */}
|
||||||
|
<div className="w-32 h-16 rounded-t-2xl border border-border bg-surface-elevated/80 backdrop-blur-sm flex flex-col items-center justify-center -gap-1 shadow-lg">
|
||||||
|
<span className="text-foreground-subtle text-xs font-mono tracking-widest uppercase">
|
||||||
|
Scroll Down
|
||||||
|
</span>
|
||||||
|
<motion.div
|
||||||
|
animate={{ y: [0, 4, 0] }}
|
||||||
|
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
|
||||||
|
className="flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<ChevronDown size={16} className="text-accent" />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
src/components/sections/portfolio/Navbar.tsx
Normal file
120
src/components/sections/portfolio/Navbar.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { Menu, X, Terminal } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ThemeToggle } from "@/components/ui/ThemeToggle";
|
||||||
|
import { NavLinks } from "@/lib/constant";
|
||||||
|
|
||||||
|
export function Navbar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [scrolled, setScrolled] = useState(false);
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => setScrolled(window.scrollY > 20);
|
||||||
|
window.addEventListener("scroll", handleScroll);
|
||||||
|
return () => window.removeEventListener("scroll", handleScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (pathname?.startsWith("/dashboard")) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<motion.header
|
||||||
|
initial={{ y: -20, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||||
|
className={cn(
|
||||||
|
"fixed top-0 left-0 right-0 z-50 transition-all duration-300",
|
||||||
|
scrolled
|
||||||
|
? "bg-background/90 backdrop-blur-xl border-b border-border"
|
||||||
|
: "bg-transparent border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<nav className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
|
||||||
|
{/* Logo */}
|
||||||
|
<a href="#" className="flex items-center gap-2 group">
|
||||||
|
<div className="w-7 h-7 rounded-md bg-accent/20 border border-border flex items-center justify-center group-hover:bg-accent/30 transition-colors">
|
||||||
|
<Terminal size={14} className="text-accent" />
|
||||||
|
</div>
|
||||||
|
<span className="font-display font-semibold text-foreground text-sm tracking-wide">
|
||||||
|
fikri<span className="text-accent">.</span>maulana
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* Desktop nav */}
|
||||||
|
<div className="hidden md:flex items-center gap-1">
|
||||||
|
{NavLinks.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
className="px-4 py-2 text-sm text-foreground-muted hover:text-foreground transition-colors rounded-lg hover:bg-surface-elevated"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CTA + Theme Toggle */}
|
||||||
|
<div className="hidden md:flex items-center gap-2">
|
||||||
|
<ThemeToggle />
|
||||||
|
<a
|
||||||
|
href="#contact"
|
||||||
|
className="px-4 py-2 text-sm font-medium bg-accent text-white rounded-lg hover:bg-accent/90 transition-all hover:shadow-glow"
|
||||||
|
>
|
||||||
|
Download CV
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile toggle */}
|
||||||
|
<div className="md:hidden flex items-center gap-2">
|
||||||
|
<ThemeToggle />
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileOpen(!mobileOpen)}
|
||||||
|
className="p-2 rounded-lg hover:bg-surface-elevated transition-colors"
|
||||||
|
aria-label="Toggle menu"
|
||||||
|
>
|
||||||
|
{mobileOpen ? <X size={20} /> : <Menu size={20} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</motion.header>
|
||||||
|
|
||||||
|
{/* Mobile menu */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{mobileOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="fixed top-16 left-0 right-0 z-40 bg-background/95 backdrop-blur-xl border-b border-border"
|
||||||
|
>
|
||||||
|
<div className="w-full px-6 py-4 flex flex-col gap-1">
|
||||||
|
{NavLinks.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className="px-4 py-3 text-foreground-muted hover:text-foreground rounded-lg hover:bg-surface-elevated transition-colors"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
<a
|
||||||
|
href="#contact"
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className="mt-2 px-4 py-3 text-center font-medium bg-accent text-background rounded-lg"
|
||||||
|
>
|
||||||
|
Download CV
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
251
src/components/sections/portfolio/PrincipleSection.tsx
Normal file
251
src/components/sections/portfolio/PrincipleSection.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState, useEffect } from "react";
|
||||||
|
import { motion, useInView } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Layers,
|
||||||
|
ShieldCheck,
|
||||||
|
Zap,
|
||||||
|
RefreshCcw,
|
||||||
|
GitBranch,
|
||||||
|
Box,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import SyntaxHighlighter from "react-syntax-highlighter";
|
||||||
|
import {
|
||||||
|
atomOneDark,
|
||||||
|
atomOneLight,
|
||||||
|
} from "react-syntax-highlighter/dist/esm/styles/hljs";
|
||||||
|
|
||||||
|
const principles = [
|
||||||
|
{
|
||||||
|
icon: Layers,
|
||||||
|
title: "Clean Architecture Mindset",
|
||||||
|
description:
|
||||||
|
"I structure applications with clear separation of concerns so business logic stays maintainable and easy to evolve as the project grows.",
|
||||||
|
tag: "Architecture",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Box,
|
||||||
|
title: "Modular Code Structure",
|
||||||
|
description:
|
||||||
|
"I organize features into well-defined modules to keep the codebase scalable and easier for teams to understand and maintain.",
|
||||||
|
tag: "Scalability",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: GitBranch,
|
||||||
|
title: "Abstraction & Reusability",
|
||||||
|
description:
|
||||||
|
"I prefer abstractions and reusable patterns to keep the codebase flexible and easier to extend in future iterations.",
|
||||||
|
tag: "Code Design",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: ShieldCheck,
|
||||||
|
title: "SOLID Principles",
|
||||||
|
description:
|
||||||
|
"I apply SOLID principles to write clean, understandable code where each component has a clear responsibility.",
|
||||||
|
tag: "Code Quality",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Zap,
|
||||||
|
title: "Performance Awareness",
|
||||||
|
description:
|
||||||
|
"I consider performance early by applying efficient queries, pagination, and thoughtful data handling in web applications.",
|
||||||
|
tag: "Performance",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: RefreshCcw,
|
||||||
|
title: "Maintainable Development",
|
||||||
|
description:
|
||||||
|
"I focus on writing maintainable code, using version control effectively, and building systems that teams can work on confidently.",
|
||||||
|
tag: "Workflow",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const containerVariants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: { staggerChildren: 0.08, delayChildren: 0.1 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemVariants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: { opacity: 1, y: 0, transition: { duration: 0.5, ease: "easeOut" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PrincipleSection() {
|
||||||
|
const ref = useRef(null);
|
||||||
|
const isInView = useInView(ref, { once: true, margin: "-80px" });
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isDark = resolvedTheme === "dark";
|
||||||
|
|
||||||
|
const codeSnippet = `// ✅ Application Layer — Pure Business Logic
|
||||||
|
@Injectable()
|
||||||
|
export class UserService {
|
||||||
|
constructor(
|
||||||
|
private readonly userRepository: IUserRepository, // Interface, not implementation
|
||||||
|
private readonly eventEmitter: IEventEmitter,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createUser(dto: CreateUserDto): Promise<UserEntity> {
|
||||||
|
const exists = await this.userRepository.findByEmail(dto.email)
|
||||||
|
if (exists) throw new ConflictException('Email already registered')
|
||||||
|
|
||||||
|
const user = UserEntity.create(dto) // Domain entity handles business rules
|
||||||
|
await this.userRepository.save(user)
|
||||||
|
await this.eventEmitter.emit('user.created', user)
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Infrastructure Layer — Implementation Detail
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaUserRepository implements IUserRepository {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
async findByEmail(email: string) { /* ... */ }
|
||||||
|
async save(user: UserEntity) { /* ... */ }
|
||||||
|
}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="principles" className="py-16 sm:py-20 lg:py-32 relative overflow-hidden">
|
||||||
|
{/* Background */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-surface/30 to-transparent" />
|
||||||
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[400px] bg-accent/4 rounded-full blur-[150px] pointer-events-none" />
|
||||||
|
|
||||||
|
<div className="relative z-10 max-w-6xl mx-auto px-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-12 sm:mb-16">
|
||||||
|
<motion.span
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
whileInView={{ opacity: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
className="text-accent font-mono text-xs sm:text-sm tracking-widest uppercase"
|
||||||
|
>
|
||||||
|
System Design
|
||||||
|
</motion.span>
|
||||||
|
<motion.h2
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.1 }}
|
||||||
|
className="font-display font-bold text-3xl sm:text-4xl md:text-5xl text-foreground mt-3"
|
||||||
|
>
|
||||||
|
Engineering
|
||||||
|
<span className="gradient-text-blue"> Principles</span>
|
||||||
|
</motion.h2>
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.2 }}
|
||||||
|
className="text-foreground-muted mt-4 max-w-2xl mx-auto leading-relaxed text-sm sm:text-base px-4"
|
||||||
|
>
|
||||||
|
I aim to build systems that are clean, maintainable, and ready to grow as products evolve.
|
||||||
|
These principles guide how I structure applications and approach technical decisions.
|
||||||
|
</motion.p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Principles Grid */}
|
||||||
|
<motion.div
|
||||||
|
ref={ref}
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate={isInView ? "visible" : "hidden"}
|
||||||
|
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5"
|
||||||
|
>
|
||||||
|
{principles.map((p) => {
|
||||||
|
const Icon = p.icon;
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={p.title}
|
||||||
|
variants={itemVariants}
|
||||||
|
className="group p-4 sm:p-6 rounded-2xl bg-surface-elevated border border-border hover:border-accent/30 hover:shadow-glow transition-all duration-300"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4 mb-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-accent/10 border border-border flex items-center justify-center group-hover:bg-accent/20 transition-colors">
|
||||||
|
<Icon size={18} className="text-accent" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-mono text-foreground-subtle bg-surface border border-border-subtle px-2.5 py-1 rounded-lg">
|
||||||
|
{p.tag}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-display font-semibold text-foreground text-sm sm:text-base mb-3 leading-snug">
|
||||||
|
{p.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs sm:text-sm text-foreground-muted leading-relaxed">
|
||||||
|
{p.description}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Code snippet preview */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.3 }}
|
||||||
|
className="mt-8 sm:mt-10 rounded-2xl bg-surface-elevated border border-border overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* macOS-style title bar */}
|
||||||
|
<div className="flex items-center gap-2 sm:gap-3 px-3 sm:px-5 py-3 sm:py-3.5 border-b border-border bg-surface">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-red-500/70" />
|
||||||
|
<div className="w-3 h-3 rounded-full bg-yellow-500/70" />
|
||||||
|
<div className="w-3 h-3 rounded-full bg-green-500/70" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-mono text-foreground-muted ml-1 truncate">
|
||||||
|
user.service.ts
|
||||||
|
</span>
|
||||||
|
<span className="hidden sm:inline ml-auto text-xs font-mono text-foreground-subtle opacity-60">
|
||||||
|
Clean Architecture Example
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Syntax-highlighted code */}
|
||||||
|
{mounted ? (
|
||||||
|
<SyntaxHighlighter
|
||||||
|
language="typescript"
|
||||||
|
style={isDark ? atomOneDark : atomOneLight}
|
||||||
|
showLineNumbers={false}
|
||||||
|
wrapLongLines={true}
|
||||||
|
customStyle={{
|
||||||
|
margin: 0,
|
||||||
|
padding: "1rem 1.5rem",
|
||||||
|
fontSize: "0.6875rem",
|
||||||
|
lineHeight: "1.6",
|
||||||
|
background: "transparent",
|
||||||
|
overflowX: "auto",
|
||||||
|
}}
|
||||||
|
lineNumberStyle={{
|
||||||
|
color: isDark ? "#3d4451" : "#c4c9d4",
|
||||||
|
paddingRight: "1.5rem",
|
||||||
|
minWidth: "2.5rem",
|
||||||
|
userSelect: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{codeSnippet}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="p-6 overflow-x-auto text-[0.75rem] font-mono leading-[1.7] opacity-0 text-transparent"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<pre>{codeSnippet}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/components/sections/portfolio/ProjectsSection.tsx
Normal file
48
src/components/sections/portfolio/ProjectsSection.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { ProjectCard } from '@/components/ui/ProjectCard'
|
||||||
|
import type { Project } from '@/types'
|
||||||
|
|
||||||
|
interface ProjectsSectionProps {
|
||||||
|
projects: Project[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectsSection({ projects }: ProjectsSectionProps) {
|
||||||
|
return (
|
||||||
|
<section id="projects" className="py-32 relative">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-surface/20 to-transparent" />
|
||||||
|
|
||||||
|
<div className="relative z-10 max-w-6xl mx-auto px-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<span className="text-accent font-mono text-sm tracking-widest uppercase">
|
||||||
|
Work
|
||||||
|
</span>
|
||||||
|
<h2 className="font-display font-bold text-4xl md:text-5xl text-foreground mt-3">
|
||||||
|
Featured Projects
|
||||||
|
</h2>
|
||||||
|
<p className="text-foreground-muted mt-4 max-w-xl mx-auto">
|
||||||
|
Production-grade systems I've built — from concept to deployment
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{projects.map((project, index) => (
|
||||||
|
<ProjectCard key={project.id} project={project} index={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View more */}
|
||||||
|
<div className="text-center mt-12">
|
||||||
|
<a
|
||||||
|
href="https://github.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-3 border border-border hover:border-accent/40 text-foreground-muted hover:text-foreground rounded-xl transition-all hover:-translate-y-0.5 duration-200"
|
||||||
|
>
|
||||||
|
View All on GitHub
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
176
src/components/sections/portfolio/TechStackSection.tsx
Normal file
176
src/components/sections/portfolio/TechStackSection.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { motion, useInView } from "framer-motion";
|
||||||
|
import {
|
||||||
|
SiNextdotjs,
|
||||||
|
SiReact,
|
||||||
|
SiTypescript,
|
||||||
|
SiTailwindcss,
|
||||||
|
SiFramer,
|
||||||
|
SiNestjs,
|
||||||
|
SiNodedotjs,
|
||||||
|
SiExpress,
|
||||||
|
SiPostgresql,
|
||||||
|
SiMongodb,
|
||||||
|
SiRedis,
|
||||||
|
SiPrisma,
|
||||||
|
SiDocker,
|
||||||
|
SiGit,
|
||||||
|
SiGithub,
|
||||||
|
SiLinux,
|
||||||
|
SiTypeorm,
|
||||||
|
SiRedbull,
|
||||||
|
} from "react-icons/si";
|
||||||
|
|
||||||
|
// Note: If react-icons is not installed, replace with text-based badges
|
||||||
|
// npm install react-icons
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
{
|
||||||
|
label: "Frontend",
|
||||||
|
color: "from-blue-500/20 to-cyan-500/10",
|
||||||
|
borderColor: "border-blue-500/20",
|
||||||
|
iconColor: "text-blue-400",
|
||||||
|
tech: [
|
||||||
|
{ name: "Next.js", Icon: SiNextdotjs },
|
||||||
|
{ name: "React", Icon: SiReact },
|
||||||
|
{ name: "TypeScript", Icon: SiTypescript },
|
||||||
|
{ name: "Tailwind CSS", Icon: SiTailwindcss },
|
||||||
|
{ name: "Framer Motion", Icon: SiFramer },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Backend",
|
||||||
|
color: "from-emerald-500/20 to-teal-500/10",
|
||||||
|
borderColor: "border-emerald-500/20",
|
||||||
|
iconColor: "text-emerald-400",
|
||||||
|
tech: [
|
||||||
|
{ name: "NestJS", Icon: SiNestjs },
|
||||||
|
{ name: "Node.js", Icon: SiNodedotjs },
|
||||||
|
{ name: "Express", Icon: SiExpress },
|
||||||
|
{ name: "BullMQ", Icon: SiRedbull },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Database",
|
||||||
|
color: "from-orange-500/20 to-amber-500/10",
|
||||||
|
borderColor: "border-orange-500/20",
|
||||||
|
iconColor: "text-orange-400",
|
||||||
|
tech: [
|
||||||
|
{ name: "PostgreSQL", Icon: SiPostgresql },
|
||||||
|
{ name: "MongoDB", Icon: SiMongodb },
|
||||||
|
{ name: "Redis", Icon: SiRedis },
|
||||||
|
{ name: "Prisma ORM", Icon: SiPrisma },
|
||||||
|
{ name: "TypeORM", Icon: SiTypeorm },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "DevOps",
|
||||||
|
color: "from-purple-500/20 to-violet-500/10",
|
||||||
|
borderColor: "border-purple-500/20",
|
||||||
|
iconColor: "text-purple-400",
|
||||||
|
tech: [
|
||||||
|
{ name: "Docker", Icon: SiDocker },
|
||||||
|
{ name: "Git", Icon: SiGit },
|
||||||
|
{ name: "GitHub", Icon: SiGithub },
|
||||||
|
{ name: "Linux", Icon: SiLinux },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const containerVariants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: { staggerChildren: 0.1, delayChildren: 0.1 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemVariants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: { opacity: 1, y: 0, transition: { duration: 0.5, ease: "easeOut" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TechStackSection() {
|
||||||
|
const ref = useRef(null);
|
||||||
|
const isInView = useInView(ref, { once: true, margin: "-100px" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="stack" className="py-5 lg:py-32 relative">
|
||||||
|
<div className="max-w-6xl mx-auto px-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<motion.span
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
whileInView={{ opacity: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
className="text-accent font-mono text-sm tracking-widest uppercase"
|
||||||
|
>
|
||||||
|
Technology
|
||||||
|
</motion.span>
|
||||||
|
<motion.h2
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.1 }}
|
||||||
|
className="font-display font-bold text-4xl md:text-5xl text-foreground mt-3"
|
||||||
|
>
|
||||||
|
My Tech Stack
|
||||||
|
</motion.h2>
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.2 }}
|
||||||
|
className="text-foreground-muted mt-4 max-w-xl mx-auto"
|
||||||
|
>
|
||||||
|
Battle-tested tools and frameworks I use to build production systems
|
||||||
|
</motion.p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<motion.div
|
||||||
|
ref={ref}
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate={isInView ? "visible" : "hidden"}
|
||||||
|
className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6"
|
||||||
|
>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<motion.div
|
||||||
|
key={category.label}
|
||||||
|
variants={itemVariants}
|
||||||
|
className={`p-6 rounded-2xl bg-gradient-to-br ${category.color} border ${category.borderColor} hover:shadow-glow transition-all duration-300 group`}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
className={`font-display font-semibold text-sm tracking-widest uppercase mb-5 ${category.iconColor}`}
|
||||||
|
>
|
||||||
|
{category.label}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{category.tech.map((item) => {
|
||||||
|
const Icon = item.Icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.name}
|
||||||
|
className="flex items-center gap-3 p-2.5 rounded-xl bg-background/30 hover:bg-background/60 transition-all duration-200 group/item cursor-default"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size={18}
|
||||||
|
className={`${category.iconColor} group-hover/item:scale-110 transition-transform flex-shrink-0`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-foreground-muted group-hover/item:text-foreground transition-colors font-medium">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
src/components/ui/ProjectCard.tsx
Normal file
117
src/components/ui/ProjectCard.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { ExternalLink, Github, ArrowUpRight } from "lucide-react";
|
||||||
|
import type { Project } from "@/types";
|
||||||
|
|
||||||
|
interface ProjectCardProps {
|
||||||
|
project: Project;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic pseudo-random number seeded by a string — avoids SSR/client hydration mismatch
|
||||||
|
function seededRandom(seed: string): number {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < seed.length; i++) {
|
||||||
|
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
|
||||||
|
}
|
||||||
|
return (hash % 1000) / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectCard({ project, index }: ProjectCardProps) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5, delay: index * 0.1, ease: "easeOut" }}
|
||||||
|
whileHover={{ y: -4 }}
|
||||||
|
className="group relative rounded-2xl bg-surface-elevated border border-border overflow-hidden hover:border-accent/30 hover:shadow-card-hover transition-all duration-300"
|
||||||
|
>
|
||||||
|
{/* Project image / placeholder */}
|
||||||
|
<div className="relative h-48 bg-gradient-to-br from-surface to-background overflow-hidden">
|
||||||
|
{project.imageUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={project.imageUrl}
|
||||||
|
alt={project.title}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<div className="grid grid-cols-3 gap-1 opacity-20">
|
||||||
|
{Array.from({ length: 9 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="w-12 h-8 rounded-sm bg-accent"
|
||||||
|
style={{
|
||||||
|
opacity: seededRandom(`${project.id}-${i}`) * 0.8 + 0.2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-accent/10 via-transparent to-purple-500/10" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Gradient overlay */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-surface-elevated via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||||
|
|
||||||
|
{/* Action links on hover */}
|
||||||
|
<div className="absolute top-4 right-4 flex gap-2 opacity-0 group-hover:opacity-100 transition-all duration-300 translate-y-1 group-hover:translate-y-0">
|
||||||
|
{project.githubUrl && (
|
||||||
|
<a
|
||||||
|
href={project.githubUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="p-2 rounded-lg bg-background/80 backdrop-blur-sm border border-border hover:border-accent/40 text-foreground-muted hover:text-foreground transition-colors"
|
||||||
|
aria-label="GitHub repository"
|
||||||
|
>
|
||||||
|
<Github size={14} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{project.liveUrl && (
|
||||||
|
<a
|
||||||
|
href={project.liveUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="p-2 rounded-lg bg-background/80 backdrop-blur-sm border border-border hover:border-accent/40 text-foreground-muted hover:text-foreground transition-colors"
|
||||||
|
aria-label="Live demo"
|
||||||
|
>
|
||||||
|
<ExternalLink size={14} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-start justify-between gap-4 mb-3">
|
||||||
|
<h3 className="font-display font-semibold text-lg text-foreground group-hover:text-accent transition-colors leading-tight">
|
||||||
|
{project.title}
|
||||||
|
</h3>
|
||||||
|
<ArrowUpRight
|
||||||
|
size={16}
|
||||||
|
className="text-foreground-subtle opacity-0 group-hover:opacity-100 flex-shrink-0 mt-1 transition-opacity"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-foreground-muted leading-relaxed mb-5 line-clamp-2">
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Tech stack tags */}
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{project.techStack.map((tech) => (
|
||||||
|
<span
|
||||||
|
key={tech}
|
||||||
|
className="px-2.5 py-1 text-xs font-mono text-foreground-muted bg-surface border border-border-subtle rounded-md"
|
||||||
|
>
|
||||||
|
{tech}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/components/ui/ThemeToggle.tsx
Normal file
58
src/components/ui/ThemeToggle.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { resolvedTheme, setTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
// Avoid hydration mismatch — only render after mount
|
||||||
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-surface-elevated border border-border" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDark = resolvedTheme === "dark";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||||
|
className="relative w-9 h-9 rounded-lg border border-border bg-surface-elevated hover:border-accent/40 hover:bg-surface transition-all duration-200 flex items-center justify-center overflow-hidden"
|
||||||
|
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||||
|
title={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||||
|
>
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
{isDark ? (
|
||||||
|
<motion.span
|
||||||
|
key="moon"
|
||||||
|
initial={{ opacity: 0, rotate: -90, scale: 0.6 }}
|
||||||
|
animate={{ opacity: 1, rotate: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, rotate: 90, scale: 0.6 }}
|
||||||
|
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||||
|
className="absolute"
|
||||||
|
>
|
||||||
|
<Moon size={15} className="text-foreground-muted" />
|
||||||
|
</motion.span>
|
||||||
|
) : (
|
||||||
|
<motion.span
|
||||||
|
key="sun"
|
||||||
|
initial={{ opacity: 0, rotate: 90, scale: 0.6 }}
|
||||||
|
animate={{ opacity: 1, rotate: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, rotate: -90, scale: 0.6 }}
|
||||||
|
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||||
|
className="absolute"
|
||||||
|
>
|
||||||
|
<Sun size={15} className="text-foreground-muted" />
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/lib/constant.ts
Normal file
38
src/lib/constant.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
export const NavLinks = [
|
||||||
|
{ label: "About", href: "#about" },
|
||||||
|
{ label: "Stack", href: "#stack" },
|
||||||
|
{ label: "Projects", href: "#projects" },
|
||||||
|
{ label: "Experience", href: "#experience" },
|
||||||
|
{ label: "Contact", href: "#contact" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ContactUrls = {
|
||||||
|
github: "https://github.com/Dzuuul",
|
||||||
|
linkedin: "https://www.linkedin.com/in/dzulfikrimaulana",
|
||||||
|
email: "mailto:developer@dzulfikri.com?subject=Inquiry:%20Fullstack%20Developer%20Opportunity%20-%20[Company/Project%20Name]&body=Hi%20Fikri,%0D%0A%0D%0AI%20am%20[Name]%20from%20[Company/Organization%20Name].%0D%0A%0D%0AI%20recently%20explored%20your%20portfolio%20and%20was%20impressed%20by%20your%20work%20as%20a%20Fullstack%20Developer.%20I%20would%20like%20to%20discuss%20a%20potential%20opportunity%20with%20you,%20whether%20it%20be%20for%20a%20career%20role%20within%20our%20team%20or%20a%20professional%20collaboration%20on%20an%20upcoming%20project.%0D%0A%0D%0AAre%20you%20available%20for%20a%20brief%20introductory%20call%20or%20a%20chat%20sometime%20this%20week%20to%20discuss%20this%20further?%0D%0A%0D%0ABest%20regards,%0D%0A[Name]%0D%0A[LinkedIn%20Profile/Contact%20Info]"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HeroSection = {
|
||||||
|
badge: "Available for opportunities",
|
||||||
|
headline: "Fullstack",
|
||||||
|
headline2: "Developer",
|
||||||
|
tagline1: "Specializing in Next.js & NestJS",
|
||||||
|
tagline2:
|
||||||
|
"Building scalable web applications with Next.js and NestJS, focused on performance, clean architecture, and real-world systems.",
|
||||||
|
image: "/images/hero-portrait.png",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AboutSection = {
|
||||||
|
title: "About Me",
|
||||||
|
description: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ArchitectureSection = {
|
||||||
|
title: "Architecture",
|
||||||
|
description: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ContactSection = {
|
||||||
|
title: "Contact Me",
|
||||||
|
description: "",
|
||||||
|
};
|
||||||
23
src/lib/prisma.ts
Normal file
23
src/lib/prisma.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
import { PrismaPg } from "@prisma/adapter-pg";
|
||||||
|
|
||||||
|
const connectionString = `${process.env.DATABASE_URL}`;
|
||||||
|
const pool = new Pool({ connectionString });
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: PrismaClient | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prisma =
|
||||||
|
globalForPrisma.prisma ??
|
||||||
|
new PrismaClient({
|
||||||
|
adapter,
|
||||||
|
log:
|
||||||
|
process.env.NODE_ENV === "development"
|
||||||
|
? ["query", "error", "warn"]
|
||||||
|
: ["error"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||||
23
src/lib/s3.ts
Normal file
23
src/lib/s3.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
|
||||||
|
const s3 = new S3Client({
|
||||||
|
region: "auto",
|
||||||
|
endpoint: process.env.R2_ENDPOINT,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.R2_ACCESS_KEY!,
|
||||||
|
secretAccessKey: process.env.R2_SECRET_KEY!,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function uploadFile(file: File) {
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
|
||||||
|
await s3.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: process.env.R2_BUCKET_NAME,
|
||||||
|
Key: file.name,
|
||||||
|
Body: buffer,
|
||||||
|
ContentType: file.type,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
10
src/lib/visitor.ts
Normal file
10
src/lib/visitor.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export function getVisitorId() {
|
||||||
|
let visitorId = localStorage.getItem("visitor_id");
|
||||||
|
|
||||||
|
if (!visitorId) {
|
||||||
|
visitorId = crypto.randomUUID();
|
||||||
|
localStorage.setItem("visitor_id", visitorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return visitorId;
|
||||||
|
}
|
||||||
25
src/proxy.ts
Normal file
25
src/proxy.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
|
||||||
|
export function proxy(request: NextRequest) {
|
||||||
|
const token = request.cookies.get("token")?.value;
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
const isAuthPage = pathname.startsWith("/dashboard/auth");
|
||||||
|
const isProtectedPage = pathname.startsWith("/dashboard") && !isAuthPage;
|
||||||
|
|
||||||
|
// user belum login, mau buka dashboard → redirect ke login
|
||||||
|
if (!token && isProtectedPage) {
|
||||||
|
return NextResponse.redirect(new URL("/dashboard/auth", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
// user sudah login, tapi buka login → redirect ke dashboard
|
||||||
|
if (token && isAuthPage) {
|
||||||
|
return NextResponse.redirect(new URL("/dashboard/overview", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/dashboard/:path*"],
|
||||||
|
};
|
||||||
41
src/types/index.ts
Normal file
41
src/types/index.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export interface Project {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
imageUrl?: string | null
|
||||||
|
liveUrl?: string | null
|
||||||
|
githubUrl?: string | null
|
||||||
|
techStack: string[]
|
||||||
|
featured: boolean
|
||||||
|
order: number
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Experience {
|
||||||
|
id: string
|
||||||
|
company: string
|
||||||
|
role: string
|
||||||
|
startDate: Date
|
||||||
|
endDate?: Date | null
|
||||||
|
current: boolean
|
||||||
|
description: string
|
||||||
|
highlights: string[]
|
||||||
|
techStack: string[]
|
||||||
|
order: number
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactFormData {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
subject: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactFormState {
|
||||||
|
success: boolean
|
||||||
|
error?: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
121
tailwind.config.ts
Normal file
121
tailwind.config.ts
Normal 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
41
tsconfig.json
Normal 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
8
vercel.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"cron": [
|
||||||
|
{
|
||||||
|
"path": "/api/cron/publish",
|
||||||
|
"schedule": "*/5 * * * *"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user