feat: Introduce site configuration management with a new Prisma model and API endpoints, and add a health check endpoint.
This commit is contained in:
@@ -70,6 +70,7 @@
|
|||||||
"@prisma/client",
|
"@prisma/client",
|
||||||
"@prisma/engines",
|
"@prisma/engines",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
|
"esbuild",
|
||||||
"prisma",
|
"prisma",
|
||||||
"sharp",
|
"sharp",
|
||||||
"unrs-resolver"
|
"unrs-resolver"
|
||||||
|
|||||||
@@ -105,3 +105,15 @@ model Tag {
|
|||||||
|
|
||||||
blogs Blog[]
|
blogs Blog[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SiteConfig {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
cvUrl String?
|
||||||
|
linkedinUrl String?
|
||||||
|
githubUrl String?
|
||||||
|
twitterUrl String?
|
||||||
|
heroTitle String?
|
||||||
|
heroSubtitle String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Calendar, Clock, ArrowRight, Tag as TagIcon, Search } from "lucide-react";
|
import {
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
ArrowRight,
|
||||||
|
Tag as TagIcon,
|
||||||
|
Search,
|
||||||
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -34,12 +40,16 @@ export default async function BlogPage() {
|
|||||||
Blog<span className="text-primary">.</span>
|
Blog<span className="text-primary">.</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="max-w-xl text-lg text-foreground-muted leading-relaxed">
|
<p className="max-w-xl text-lg text-foreground-muted leading-relaxed">
|
||||||
Exploring the world of fullstack development, architecture, and developer productivity. Sharing what I learn along the way.
|
Exploring the world of fullstack development, architecture, and
|
||||||
|
developer productivity. Sharing what I learn along the way.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-col md:flex-row gap-4 items-center">
|
<div className="mt-4 flex flex-col md:flex-row gap-4 items-center">
|
||||||
<div className="relative w-full max-w-md group">
|
<div className="relative w-full max-w-md group">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-foreground-muted group-focus-within:text-primary transition-colors" size={18} />
|
<Search
|
||||||
|
className="absolute left-3 top-1/2 -translate-y-1/2 text-foreground-muted group-focus-within:text-primary transition-colors"
|
||||||
|
size={18}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search articles..."
|
placeholder="Search articles..."
|
||||||
@@ -61,8 +71,13 @@ export default async function BlogPage() {
|
|||||||
<Clock className="text-primary/40" size={32} />
|
<Clock className="text-primary/40" size={32} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="text-xl font-display font-semibold text-foreground">No publications yet</h3>
|
<h3 className="text-xl font-display font-semibold text-foreground">
|
||||||
<p className="text-foreground-muted max-w-xs">Writing takes time. Check back soon for new insights and stories.</p>
|
No publications yet
|
||||||
|
</h3>
|
||||||
|
<p className="text-foreground-muted max-w-xs">
|
||||||
|
Writing takes time. Check back soon for new insights and
|
||||||
|
stories.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -82,11 +97,12 @@ function BlogCard({ blog, index }: { blog: any; index: number }) {
|
|||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<div className="flex items-center gap-1.5 text-xs text-foreground-muted bg-surface-elevated/50 px-3 py-1.5 rounded-full border border-border/50">
|
<div className="flex items-center gap-1.5 text-xs text-foreground-muted bg-surface-elevated/50 px-3 py-1.5 rounded-full border border-border/50">
|
||||||
<Calendar size={12} className="text-primary/60" />
|
<Calendar size={12} className="text-primary/60" />
|
||||||
{blog.publishedAt ? format(new Date(blog.publishedAt), "MMM dd, yyyy") : "Draft"}
|
{blog.publishedAt
|
||||||
|
? format(new Date(blog.publishedAt), "MMM dd, yyyy")
|
||||||
|
: "Draft"}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 text-xs text-foreground-muted bg-surface-elevated/50 px-3 py-1.5 rounded-full border border-border/50">
|
<div className="flex items-center gap-1.5 text-xs text-foreground-muted bg-surface-elevated/50 px-3 py-1.5 rounded-full border border-border/50">
|
||||||
<Clock size={12} className="text-primary/60" />
|
<Clock size={12} className="text-primary/60" />5 min read
|
||||||
5 min read
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -95,13 +111,17 @@ function BlogCard({ blog, index }: { blog: any; index: number }) {
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p className="text-foreground-muted text-sm leading-relaxed line-clamp-3 mb-8">
|
<p className="text-foreground-muted text-sm leading-relaxed line-clamp-3 mb-8">
|
||||||
{blog.excerpt || "No excerpt available for this post. Click to read the full article."}
|
{blog.excerpt ||
|
||||||
|
"No excerpt available for this post. Click to read the full article."}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-auto pt-6 border-t border-border/20 flex items-center justify-between">
|
<div className="mt-auto pt-6 border-t border-border/20 flex items-center justify-between">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{blog.tags.slice(0, 2).map((tag: any) => (
|
{blog.tags.slice(0, 2).map((tag: any) => (
|
||||||
<span key={tag.id} className="text-[10px] font-bold uppercase tracking-widest text-foreground-muted/60 flex items-center gap-1">
|
<span
|
||||||
|
key={tag.id}
|
||||||
|
className="text-[10px] font-bold uppercase tracking-widest text-foreground-muted/60 flex items-center gap-1"
|
||||||
|
>
|
||||||
<span className="w-1 h-1 rounded-full bg-primary" />
|
<span className="w-1 h-1 rounded-full bg-primary" />
|
||||||
{tag.name}
|
{tag.name}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
54
src/app/api/dashboard/cms/site-config/route.ts
Normal file
54
src/app/api/dashboard/cms/site-config/route.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
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 GET() {
|
||||||
|
try {
|
||||||
|
const siteConfig = await prisma.siteConfig.findFirst();
|
||||||
|
return NextResponse.json({ success: true, data: siteConfig });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Site config error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Failed to get site config" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/app/api/health/route.ts
Normal file
12
src/app/api/health/route.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
status: "ok",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,58 +1,60 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from "react";
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from 'zod'
|
import { z } from "zod";
|
||||||
import { Send, CheckCircle2, Mail, MapPin, Clock } from 'lucide-react'
|
import { Send, CheckCircle2, Mail, MapPin, Clock } from "lucide-react";
|
||||||
import type { ContactFormData } from '@/types'
|
import type { ContactFormData } from "@/types";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
name: z.string().min(2, 'Name must be at least 2 characters'),
|
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||||
email: z.string().email('Please enter a valid email'),
|
email: z.string().email("Please enter a valid email"),
|
||||||
subject: z.string().min(4, 'Subject must be at least 4 characters'),
|
subject: z.string().min(4, "Subject must be at least 4 characters"),
|
||||||
message: z.string().min(10, 'Message must be at least 10 characters'),
|
message: z.string().min(10, "Message must be at least 10 characters"),
|
||||||
})
|
});
|
||||||
|
|
||||||
const contactInfo = [
|
const contactInfo = [
|
||||||
{ icon: Mail, label: 'Email', value: 'hello@yourportfolio.dev' },
|
{ icon: Mail, label: "Email", value: "developer@dzulfikri.com" },
|
||||||
{ icon: MapPin, label: 'Location', value: 'Indonesia' },
|
{ icon: MapPin, label: "Location", value: "Indonesia" },
|
||||||
{ icon: Clock, label: 'Response time', value: 'Within 24 hours' },
|
{ icon: Clock, label: "Response time", value: "Within 24 hours" },
|
||||||
]
|
];
|
||||||
|
|
||||||
export function ContactSection() {
|
export function ContactSection() {
|
||||||
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
|
const [status, setStatus] = useState<
|
||||||
const [errorMsg, setErrorMsg] = useState('')
|
"idle" | "loading" | "success" | "error"
|
||||||
|
>("idle");
|
||||||
|
const [errorMsg, setErrorMsg] = useState("");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<ContactFormData>({ resolver: zodResolver(schema) })
|
} = useForm<ContactFormData>({ resolver: zodResolver(schema) });
|
||||||
|
|
||||||
const onSubmit = async (data: ContactFormData) => {
|
const onSubmit = async (data: ContactFormData) => {
|
||||||
setStatus('loading')
|
setStatus("loading");
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/contact', {
|
const res = await fetch("/api/contact", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
})
|
});
|
||||||
const json = await res.json()
|
const json = await res.json();
|
||||||
if (json.success) {
|
if (json.success) {
|
||||||
setStatus('success')
|
setStatus("success");
|
||||||
reset()
|
reset();
|
||||||
} else {
|
} else {
|
||||||
setErrorMsg(json.error || 'Something went wrong')
|
setErrorMsg(json.error || "Something went wrong");
|
||||||
setStatus('error')
|
setStatus("error");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setErrorMsg('Network error. Please try again.')
|
setErrorMsg("Network error. Please try again.");
|
||||||
setStatus('error')
|
setStatus("error");
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="contact" className="py-32 relative overflow-hidden">
|
<section id="contact" className="py-32 relative overflow-hidden">
|
||||||
@@ -88,8 +90,8 @@ export function ContactSection() {
|
|||||||
transition={{ duration: 0.5, delay: 0.2 }}
|
transition={{ duration: 0.5, delay: 0.2 }}
|
||||||
className="text-foreground-muted mt-4 max-w-xl mx-auto"
|
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
|
Have a project in mind? Let's talk about how I can help you build
|
||||||
remarkable.
|
something remarkable.
|
||||||
</motion.p>
|
</motion.p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -103,7 +105,7 @@ export function ContactSection() {
|
|||||||
className="lg:col-span-2 space-y-4"
|
className="lg:col-span-2 space-y-4"
|
||||||
>
|
>
|
||||||
{contactInfo.map((item) => {
|
{contactInfo.map((item) => {
|
||||||
const Icon = item.icon
|
const Icon = item.icon;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.label}
|
key={item.label}
|
||||||
@@ -113,19 +115,28 @@ export function ContactSection() {
|
|||||||
<Icon size={16} className="text-accent" />
|
<Icon size={16} className="text-accent" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-foreground-muted font-mono">{item.label}</p>
|
<p className="text-xs text-foreground-muted font-mono">
|
||||||
<p className="text-sm text-foreground font-medium">{item.value}</p>
|
{item.label}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-foreground font-medium">
|
||||||
|
{item.value}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<div className="p-5 rounded-xl bg-gradient-to-br from-accent/10 to-purple-500/5 border border-border mt-6">
|
<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">
|
<p className="text-sm text-foreground-muted leading-relaxed">
|
||||||
I'm currently open to{' '}
|
I'm currently open to{" "}
|
||||||
<span className="text-foreground font-medium">freelance projects</span>,{' '}
|
<span className="text-foreground font-medium">
|
||||||
<span className="text-foreground font-medium">full-time positions</span>, and
|
freelance projects
|
||||||
interesting technical collaborations.
|
</span>
|
||||||
|
,{" "}
|
||||||
|
<span className="text-foreground font-medium">
|
||||||
|
full-time positions
|
||||||
|
</span>
|
||||||
|
, and interesting technical collaborations.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -140,7 +151,7 @@ export function ContactSection() {
|
|||||||
>
|
>
|
||||||
<div className="rounded-2xl bg-surface-elevated border border-border p-8">
|
<div className="rounded-2xl bg-surface-elevated border border-border p-8">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{status === 'success' ? (
|
{status === "success" ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="success"
|
key="success"
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
@@ -155,10 +166,11 @@ export function ContactSection() {
|
|||||||
Message Sent!
|
Message Sent!
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-foreground-muted text-sm">
|
<p className="text-foreground-muted text-sm">
|
||||||
Thank you for reaching out. I'll get back to you within 24 hours.
|
Thank you for reaching out. I'll get back to you within 24
|
||||||
|
hours.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => setStatus('idle')}
|
onClick={() => setStatus("idle")}
|
||||||
className="mt-6 text-sm text-accent hover:text-accent/80 transition-colors"
|
className="mt-6 text-sm text-accent hover:text-accent/80 transition-colors"
|
||||||
>
|
>
|
||||||
Send another message
|
Send another message
|
||||||
@@ -179,12 +191,14 @@ export function ContactSection() {
|
|||||||
Name
|
Name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
{...register('name')}
|
{...register("name")}
|
||||||
placeholder="Your 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"
|
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 && (
|
{errors.name && (
|
||||||
<p className="mt-1.5 text-xs text-red-400">{errors.name.message}</p>
|
<p className="mt-1.5 text-xs text-red-400">
|
||||||
|
{errors.name.message}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -192,13 +206,15 @@ export function ContactSection() {
|
|||||||
Email
|
Email
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
{...register('email')}
|
{...register("email")}
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="you@email.com"
|
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"
|
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 && (
|
{errors.email && (
|
||||||
<p className="mt-1.5 text-xs text-red-400">{errors.email.message}</p>
|
<p className="mt-1.5 text-xs text-red-400">
|
||||||
|
{errors.email.message}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -208,12 +224,14 @@ export function ContactSection() {
|
|||||||
Subject
|
Subject
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
{...register('subject')}
|
{...register("subject")}
|
||||||
placeholder="Project inquiry / Collaboration / etc."
|
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"
|
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 && (
|
{errors.subject && (
|
||||||
<p className="mt-1.5 text-xs text-red-400">{errors.subject.message}</p>
|
<p className="mt-1.5 text-xs text-red-400">
|
||||||
|
{errors.subject.message}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -222,17 +240,19 @@ export function ContactSection() {
|
|||||||
Message
|
Message
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
{...register('message')}
|
{...register("message")}
|
||||||
rows={5}
|
rows={5}
|
||||||
placeholder="Tell me about your project, goals, and timeline..."
|
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"
|
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 && (
|
{errors.message && (
|
||||||
<p className="mt-1.5 text-xs text-red-400">{errors.message.message}</p>
|
<p className="mt-1.5 text-xs text-red-400">
|
||||||
|
{errors.message.message}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status === 'error' && (
|
{status === "error" && (
|
||||||
<p className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 px-4 py-3 rounded-xl">
|
<p className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 px-4 py-3 rounded-xl">
|
||||||
{errorMsg}
|
{errorMsg}
|
||||||
</p>
|
</p>
|
||||||
@@ -240,10 +260,10 @@ export function ContactSection() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={status === 'loading'}
|
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"
|
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' ? (
|
{status === "loading" ? (
|
||||||
<>
|
<>
|
||||||
<div className="w-4 h-4 border-2 border-background/30 border-t-background rounded-full animate-spin" />
|
<div className="w-4 h-4 border-2 border-background/30 border-t-background rounded-full animate-spin" />
|
||||||
Sending...
|
Sending...
|
||||||
@@ -263,5 +283,5 @@ export function ContactSection() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { ArrowRight, Github, Linkedin, Mail, ChevronDown, MessageCircle, Zap } from "lucide-react";
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Github,
|
||||||
|
Linkedin,
|
||||||
|
Mail,
|
||||||
|
ChevronDown,
|
||||||
|
MessageCircle,
|
||||||
|
Zap,
|
||||||
|
} from "lucide-react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { HeroSection as HeroData } from "@/lib/constant";
|
import { HeroSection as HeroData } from "@/lib/constant";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -15,7 +23,7 @@ const LottieAnimation = dynamic(
|
|||||||
loading: () => (
|
loading: () => (
|
||||||
<div className="w-20 h-20 animate-pulse bg-accent/20 rounded-xl" />
|
<div className="w-20 h-20 animate-pulse bg-accent/20 rounded-xl" />
|
||||||
),
|
),
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const containerVariants = {
|
const containerVariants = {
|
||||||
@@ -44,17 +52,14 @@ export function HeroSection() {
|
|||||||
const [showScrollIndicator, setShowScrollIndicator] = useState(true);
|
const [showScrollIndicator, setShowScrollIndicator] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
||||||
fetch("/lottie/loading.json")
|
fetch("/lottie/loading.json")
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((data) => setLoadingAnimation(data))
|
.then((data) => setLoadingAnimation(data))
|
||||||
.catch((err) =>
|
.catch((err) => console.error("Failed to load Lottie animation:", err));
|
||||||
console.error("Failed to load Lottie animation:", err)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check dark mode
|
// Check dark mode
|
||||||
const checkDarkMode = () => {
|
const checkDarkMode = () => {
|
||||||
setIsDark(document.documentElement.classList.contains('dark'));
|
setIsDark(document.documentElement.classList.contains("dark"));
|
||||||
};
|
};
|
||||||
|
|
||||||
checkDarkMode();
|
checkDarkMode();
|
||||||
@@ -63,7 +68,7 @@ export function HeroSection() {
|
|||||||
const observer = new MutationObserver(checkDarkMode);
|
const observer = new MutationObserver(checkDarkMode);
|
||||||
observer.observe(document.documentElement, {
|
observer.observe(document.documentElement, {
|
||||||
attributes: true,
|
attributes: true,
|
||||||
attributeFilter: ['class']
|
attributeFilter: ["class"],
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
@@ -83,8 +88,8 @@ export function HeroSection() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('scroll', handleScroll);
|
window.addEventListener("scroll", handleScroll);
|
||||||
return () => window.removeEventListener('scroll', handleScroll);
|
return () => window.removeEventListener("scroll", handleScroll);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -108,9 +113,9 @@ export function HeroSection() {
|
|||||||
className="absolute inset-0 lg:hidden opacity-10"
|
className="absolute inset-0 lg:hidden opacity-10"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url(${HeroData.image})`,
|
backgroundImage: `url(${HeroData.image})`,
|
||||||
backgroundSize: 'cover',
|
backgroundSize: "cover",
|
||||||
backgroundPosition: 'center',
|
backgroundPosition: "center",
|
||||||
backgroundRepeat: 'no-repeat'
|
backgroundRepeat: "no-repeat",
|
||||||
}}
|
}}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
@@ -289,7 +294,6 @@ export function HeroSection() {
|
|||||||
>
|
>
|
||||||
{/* Window frame */}
|
{/* Window frame */}
|
||||||
<div className="relative h-full w-full rounded-lg border border-border bg-surface-elevated shadow-2xl overflow-hidden">
|
<div className="relative h-full w-full rounded-lg border border-border bg-surface-elevated shadow-2xl overflow-hidden">
|
||||||
|
|
||||||
{/* Window header */}
|
{/* Window header */}
|
||||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-surface-elevated/70">
|
<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="flex gap-2">
|
||||||
@@ -316,15 +320,15 @@ export function HeroSection() {
|
|||||||
<div className="absolute -bottom-8 -left-8 rounded-xl bg-surface-elevated border border-border p-4">
|
<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 items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-accent/10 text-accent">
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-accent/10 text-accent">
|
||||||
<Zap
|
<Zap size={24} strokeWidth={2} aria-hidden="true" />
|
||||||
size={24}
|
|
||||||
strokeWidth={2}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-lg font-semibold text-foreground">10+ Projects</p>
|
<p className="text-lg font-semibold text-foreground">
|
||||||
<p className="text-sm text-foreground-muted">Completed successfully</p>
|
10+ Projects
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-foreground-muted">
|
||||||
|
Completed successfully
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -333,15 +337,15 @@ export function HeroSection() {
|
|||||||
<div className="absolute -top-12 -right-8 rounded-xl bg-surface-elevated border border-border p-4">
|
<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 items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-accent/10 text-accent">
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-accent/10 text-accent">
|
||||||
<Zap
|
<Zap size={24} strokeWidth={2} aria-hidden="true" />
|
||||||
size={24}
|
|
||||||
strokeWidth={2}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-lg font-semibold text-foreground">Production Systems</p>
|
<p className="text-lg font-semibold text-foreground">
|
||||||
<p className="text-sm text-foreground-muted">Built & Maintained</p>
|
Production Systems
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-foreground-muted">
|
||||||
|
Built & Maintained
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -371,7 +375,11 @@ export function HeroSection() {
|
|||||||
</span>
|
</span>
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ y: [0, 4, 0] }}
|
animate={{ y: [0, 4, 0] }}
|
||||||
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
|
transition={{
|
||||||
|
duration: 1.5,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
className="flex items-center justify-center"
|
className="flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<ChevronDown size={16} className="text-accent" />
|
<ChevronDown size={16} className="text-accent" />
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
export function getVisitorId() {
|
export function getVisitorId() {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
|
||||||
let visitorId = localStorage.getItem("visitor_id");
|
let visitorId = localStorage.getItem("visitor_id");
|
||||||
|
|
||||||
if (!visitorId) {
|
if (!visitorId) {
|
||||||
visitorId = crypto.randomUUID();
|
if (crypto?.randomUUID) {
|
||||||
|
visitorId = crypto.randomUUID();
|
||||||
|
} else {
|
||||||
|
visitorId = generateUUID();
|
||||||
|
}
|
||||||
|
|
||||||
localStorage.setItem("visitor_id", visitorId);
|
localStorage.setItem("visitor_id", visitorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return visitorId;
|
return visitorId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateUUID() {
|
||||||
|
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
|
||||||
|
const r = (Math.random() * 16) | 0;
|
||||||
|
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user