Compare commits

...

2 Commits

19 changed files with 2431 additions and 69 deletions

1548
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1019.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"jose": "^6.2.2", "jose": "^6.2.2",

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

View File

@@ -1,7 +1,8 @@
import { verifySession, clearSession } from "@/core/security/session"; import { verifySession, clearSession } from "@/core/security/session";
import { redirect } from "@/i18n/routing"; import { Link, redirect } from "@/i18n/routing";
import { getLocale } from "next-intl/server"; import { getLocale } from "next-intl/server";
import { LogOut } from "lucide-react"; import { LogOut, ArrowRight } from "lucide-react";
import { prisma } from "@/core/db/prisma";
export default async function DashboardPage() { export default async function DashboardPage() {
const session = await verifySession(); const session = await verifySession();
@@ -41,11 +42,14 @@ export default async function DashboardPage() {
<main className="max-w-7xl mx-auto px-6 py-12"> <main className="max-w-7xl mx-auto px-6 py-12">
<div className="grid gap-6 md:grid-cols-3"> <div className="grid gap-6 md:grid-cols-3">
{/* Card: Projects */} {/* Card: Projects */}
<div className="p-6 rounded-2xl bg-card border border-border/50 shadow-sm hover:shadow-md transition-shadow"> <Link href="/admin/dashboard/projects" className="p-6 rounded-2xl bg-card border border-border/50 shadow-sm hover:shadow-md hover:border-accent/40 hover:-translate-y-1 transition-all group block">
<h2 className="text-lg font-bold mb-2">Projects</h2> <div className="flex justify-between items-start mb-2">
<h2 className="text-lg font-bold">Projects</h2>
<ArrowRight size={18} className="text-muted-foreground group-hover:text-accent transition-colors" />
</div>
<p className="text-sm text-muted-foreground mb-4">Manage portfolio projects and case studies.</p> <p className="text-sm text-muted-foreground mb-4">Manage portfolio projects and case studies.</p>
<div className="text-3xl font-mono font-bold text-accent">--</div> <div className="text-3xl font-mono font-bold text-accent">Go &rarr;</div>
</div> </Link>
{/* Card: Skills */} {/* Card: Skills */}
<div className="p-6 rounded-2xl bg-card border border-border/50 shadow-sm hover:shadow-md transition-shadow"> <div className="p-6 rounded-2xl bg-card border border-border/50 shadow-sm hover:shadow-md transition-shadow">

View File

@@ -0,0 +1,33 @@
import { ProjectForm } from "@/features/projects/project-form";
import { verifySession } from "@/core/security/session";
import { redirect } from "@/i18n/routing";
import { getLocale } from "next-intl/server";
import { prisma } from "@/core/db/prisma";
import { notFound } from "next/navigation";
export default async function EditProjectPage({
params
}: {
params: Promise<{ id: string }>
}) {
const session = await verifySession();
const locale = await getLocale();
if (!session) {
redirect({ href: "/admin/login", locale });
}
const { id } = await params;
const project = await prisma.project.findUnique({
where: { id }
});
if (!project) return notFound();
return (
<div className="min-h-screen bg-muted/10 px-6 py-12">
<ProjectForm initialData={project} projectId={project.id} />
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { ProjectForm } from "@/features/projects/project-form";
import { verifySession } from "@/core/security/session";
import { redirect } from "@/i18n/routing";
import { getLocale } from "next-intl/server";
export default async function CreateProjectPage() {
const session = await verifySession();
const locale = await getLocale();
if (!session) {
redirect({ href: "/admin/login", locale });
}
return (
<div className="min-h-screen bg-muted/10 px-6 py-12">
<ProjectForm />
</div>
);
}

View File

@@ -0,0 +1,115 @@
import { prisma } from "@/core/db/prisma";
import { verifySession } from "@/core/security/session";
import { Link, redirect } from "@/i18n/routing";
import { getLocale } from "next-intl/server";
import { Plus, Pencil, ArrowLeft } from "lucide-react";
import { DeleteProjectButton } from "@/features/projects/delete-button";
import { TogglePublishButton } from "@/features/projects/toggle-button";
export default async function AdminProjectsPage() {
const session = await verifySession();
const locale = await getLocale();
if (!session) {
redirect({ href: "/admin/login", locale });
}
const projects = await prisma.project.findMany({
orderBy: { createdAt: 'desc' }
});
return (
<div className="min-h-screen bg-muted/10 p-6 lg:p-12">
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-4">
<Link
href="/admin/dashboard"
className="p-2 rounded-full hover:bg-muted text-muted-foreground transition-colors"
>
<ArrowLeft size={20} />
</Link>
<div>
<h1 className="text-3xl font-bold tracking-tight">Manage Projects</h1>
<p className="text-sm text-muted-foreground mt-1">Add, update, or remove portfolio case studies.</p>
</div>
</div>
<Link
href="/admin/dashboard/projects/create"
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-foreground text-background font-semibold text-sm shadow-md hover:scale-105 transition-all"
>
<Plus size={16} />
New Project
</Link>
</div>
<div className="bg-card border border-border/50 rounded-2xl shadow-sm overflow-hidden">
{projects.length === 0 ? (
<div className="p-12 text-center flex flex-col items-center">
<div className="text-muted-foreground mb-4 opacity-50">
<Plus size={48} />
</div>
<h3 className="text-lg font-bold">No projects yet</h3>
<p className="text-sm text-muted-foreground mb-6">Get started by creating your first portfolio case study.</p>
<Link
href="/admin/dashboard/projects/create"
className="px-6 py-2 rounded-xl border border-border hover:bg-muted/50 transition-colors font-medium text-sm"
>
Create Project
</Link>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-border/50 bg-muted/20 text-muted-foreground text-sm uppercase tracking-wider">
<th className="p-4 font-semibold">Title</th>
<th className="p-4 font-semibold">Category</th>
<th className="p-4 font-semibold">Status</th>
<th className="p-4 font-semibold">Date Added</th>
<th className="p-4 font-semibold text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border/30">
{projects.map((project: any) => (
<tr key={project.id} className="hover:bg-muted/10 transition-colors">
<td className="p-4 font-medium flex items-center gap-3">
{project.imageUrl && (
<div className="w-10 h-10 rounded overflow-hidden aspect-square flex-shrink-0 bg-muted">
<img src={project.imageUrl} alt={project.title} className="w-full h-full object-cover" />
</div>
)}
<div>
<div className="font-semibold">{project.title}</div>
<div className="font-mono text-xs text-muted-foreground truncate max-w-[200px]">{project.slug}</div>
</div>
</td>
<td className="p-4 text-sm text-muted-foreground">{project.category}</td>
<td className="p-4">
<TogglePublishButton id={project.id} isPublished={project.isPublished} />
</td>
<td className="p-4 text-sm text-muted-foreground font-mono">
{new Date(project.createdAt).toLocaleDateString()}
</td>
<td className="p-4 text-right">
<div className="flex justify-end gap-2">
<Link
href={`/admin/dashboard/projects/${project.id}/edit`}
className="p-2 text-muted-foreground hover:text-accent hover:bg-accent/10 rounded-lg transition-colors"
>
<Pencil size={18} />
</Link>
<DeleteProjectButton id={project.id} />
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -5,8 +5,14 @@ import { ExperienceSection } from "@/features/experience/experience-section";
import { TechStackSection } from "@/features/skills/tech-stack-section"; import { TechStackSection } from "@/features/skills/tech-stack-section";
import { ProjectsSection } from "@/features/projects/projects-section"; import { ProjectsSection } from "@/features/projects/projects-section";
import { ContactSection } from "@/features/messages/contact-section"; import { ContactSection } from "@/features/messages/contact-section";
import { prisma } from "@/core/db/prisma";
export default async function HomePage() {
const publishedProjects = await prisma.project.findMany({
where: { isPublished: true },
orderBy: { createdAt: "desc" }
});
export default function HomePage() {
return ( return (
<> <>
<Navbar /> <Navbar />
@@ -14,7 +20,7 @@ export default function HomePage() {
<HeroSection /> <HeroSection />
<ExperienceSection /> <ExperienceSection />
<TechStackSection /> <TechStackSection />
<ProjectsSection /> <ProjectsSection initialProjects={publishedProjects} />
<ContactSection /> <ContactSection />
</main> </main>
<Footer /> <Footer />

View File

@@ -0,0 +1,90 @@
import { NextResponse } from "next/server";
import { prisma } from "@/core/db/prisma";
import { uploadFileLocally } from "@/core/storage/local";
import { verifySession } from "@/core/security/session";
import { projectSchema } from "@/features/projects/project-schema";
import { revalidatePath } from "next/cache";
export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await verifySession();
if (!session) {
return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
try {
const formData = await req.formData();
const data = {
title: formData.get("title") as string,
slug: formData.get("slug") as string,
description: formData.get("description") as string,
category: formData.get("category") as string,
repoUrl: formData.get("repoUrl") as string,
liveUrl: formData.get("liveUrl") as string,
isPublished: formData.get("isPublished") === "true" || formData.get("isPublished") === "on",
image: formData.get("image") as File | null,
};
const validation = projectSchema.safeParse(data);
if (!validation.success) {
return NextResponse.json({ success: false, message: validation.error.issues[0].message }, { status: 400 });
}
let imageUrl: string | undefined = undefined;
if (data.image && data.image.size > 0 && data.image.name) {
try {
imageUrl = await uploadFileLocally(data.image);
} catch (e: any) {
console.error("Local upload error:", e);
return NextResponse.json({ success: false, message: "Failed to upload image locally." }, { status: 500 });
}
}
const project = await prisma.project.update({
where: { id },
data: {
title: validation.data.title,
slug: validation.data.slug,
description: validation.data.description,
category: validation.data.category,
repoUrl: validation.data.repoUrl || null,
liveUrl: validation.data.liveUrl || null,
isPublished: validation.data.isPublished,
...(imageUrl && { imageUrl }),
},
});
revalidatePath("/admin/dashboard/projects");
revalidatePath("/admin/dashboard");
revalidatePath("/");
return NextResponse.json({ success: true, project });
} catch (error: any) {
console.error("API Error (Update Project):", error);
if (error?.code === "P2002") {
return NextResponse.json({ success: false, message: "Project slug already exists" }, { status: 400 });
}
return NextResponse.json({ success: false, message: "Failed to update project" }, { status: 500 });
}
}
export async function DELETE(req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await verifySession();
if (!session) {
return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
try {
await prisma.project.delete({ where: { id } });
revalidatePath("/admin/dashboard/projects");
revalidatePath("/admin/dashboard");
revalidatePath("/");
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ success: false, message: "Failed to delete project" }, { status: 500 });
}
}

View File

@@ -0,0 +1,68 @@
import { NextResponse } from "next/server";
import { prisma } from "@/core/db/prisma";
import { uploadFileLocally } from "@/core/storage/local";
import { verifySession } from "@/core/security/session";
import { projectSchema } from "@/features/projects/project-schema";
import { revalidatePath } from "next/cache";
export async function POST(req: Request) {
const session = await verifySession();
if (!session) {
return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 });
}
try {
const formData = await req.formData();
const data = {
title: formData.get("title") as string,
slug: formData.get("slug") as string,
description: formData.get("description") as string,
category: formData.get("category") as string,
repoUrl: formData.get("repoUrl") as string,
liveUrl: formData.get("liveUrl") as string,
isPublished: formData.get("isPublished") === "true" || formData.get("isPublished") === "on",
image: formData.get("image") as File | null,
};
const validation = projectSchema.safeParse(data);
if (!validation.success) {
return NextResponse.json({ success: false, message: validation.error.issues[0].message }, { status: 400 });
}
let imageUrl: string | undefined = undefined;
if (data.image && data.image.size > 0 && data.image.name) {
try {
imageUrl = await uploadFileLocally(data.image);
} catch (e: any) {
console.error("Local upload error:", e);
return NextResponse.json({ success: false, message: "Failed to upload image locally." }, { status: 500 });
}
}
const project = await prisma.project.create({
data: {
title: validation.data.title,
slug: validation.data.slug,
description: validation.data.description,
category: validation.data.category,
repoUrl: validation.data.repoUrl || null,
liveUrl: validation.data.liveUrl || null,
isPublished: validation.data.isPublished,
imageUrl: imageUrl,
},
});
revalidatePath("/admin/dashboard/projects");
revalidatePath("/admin/dashboard");
revalidatePath("/");
return NextResponse.json({ success: true, project });
} catch (error: any) {
console.error("API Error (Create Project):", error);
if (error?.code === "P2002") {
return NextResponse.json({ success: false, message: "Project slug already exists" }, { status: 400 });
}
return NextResponse.json({ success: false, message: "Failed to create project" }, { status: 500 });
}
}

28
src/core/storage/local.ts Normal file
View File

@@ -0,0 +1,28 @@
import { writeFile, mkdir } from 'fs/promises';
import path from 'path';
/**
* Uploads a file to the local public/uploads directory.
* Returns the public relative URL to be saved in the database.
*/
export async function uploadFileLocally(file: File): Promise<string> {
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const uploadDir = path.join(process.cwd(), 'public', 'uploads');
// Create directory if it doesn't exist
try {
await mkdir(uploadDir, { recursive: true });
} catch (e) {
// Directory already exists, ignore
}
const uniqueName = `${Date.now()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, "_")}`;
const filePath = path.join(uploadDir, uniqueName);
await writeFile(filePath, buffer);
// Return the path relative to the public directory so Next.js can serve it
return `/uploads/${uniqueName}`;
}

38
src/core/storage/minio.ts Normal file
View File

@@ -0,0 +1,38 @@
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
export const s3Client = new S3Client({
region: "us-east-1",
endpoint: process.env.MINIO_ENDPOINT || "http://localhost:9000",
forcePathStyle: true, // Required for MinIO
credentials: {
accessKeyId: process.env.MINIO_ACCESS_KEY || "admin",
secretAccessKey: process.env.MINIO_SECRET_KEY || "password123",
},
});
export const BUCKET_NAME = process.env.MINIO_BUCKET_NAME || "portfolio";
/**
* Uploads a file to MinIO and returns the public URL
*/
export async function uploadFileToMinio(file: File): Promise<string> {
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// Generate unique filename to prevent overwriting
const uniqueName = `${Date.now()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, "_")}`;
await s3Client.send(
new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: uniqueName,
Body: buffer,
ContentType: file.type,
// Setting ACL public-read ensures the frontend can display the image natively
// NOTE: Ensure the MinIO bucket policy itself allows public read if ACL doesn't stick
// ACL: "public-read",
})
);
return `${process.env.MINIO_ENDPOINT}/${BUCKET_NAME}/${uniqueName}`;
}

View File

@@ -0,0 +1,156 @@
"use server";
import { prisma } from "@/core/db/prisma";
import { uploadFileToMinio } from "@/core/storage/minio";
import { verifySession } from "@/core/security/session";
import { projectSchema } from "./project-schema";
import { revalidatePath } from "next/cache";
export async function createProjectAction(prevState: any, formData: FormData) {
// 1. Verify Authentication
const session = await verifySession();
if (!session) return { success: false, message: "Unauthorized" };
// 2. Extract and Validate Form Data
const data = {
title: formData.get("title") as string,
slug: formData.get("slug") as string,
description: formData.get("description") as string,
category: formData.get("category") as string,
repoUrl: formData.get("repoUrl") as string,
liveUrl: formData.get("liveUrl") as string,
isPublished: formData.get("isPublished") === "on",
image: formData.get("image") as File | null,
};
const validation = projectSchema.safeParse(data);
if (!validation.success) {
return { success: false, message: validation.error.issues[0].message };
}
// 3. Process Image Upload
let imageUrl: string | undefined = undefined;
if (data.image && data.image.size > 0 && data.image.name) {
try {
imageUrl = await uploadFileToMinio(data.image);
} catch (e) {
console.error("Image upload failed:", e);
return { success: false, message: "Failed to upload image. Ensure MinIO is running and bucket exists." };
}
}
// 4. Save to Database
try {
const project = await prisma.project.create({
data: {
title: validation.data.title,
slug: validation.data.slug,
description: validation.data.description,
category: validation.data.category,
repoUrl: validation.data.repoUrl || null,
liveUrl: validation.data.liveUrl || null,
isPublished: validation.data.isPublished,
imageUrl: imageUrl,
},
});
revalidatePath("/admin/dashboard");
revalidatePath("/");
return { success: true };
} catch (error: any) {
console.error("DB Error:", error);
if (error?.code === "P2002") {
return { success: false, message: "Project slug already exists" };
}
return { success: false, message: "Failed to create project" };
}
}
export async function updateProjectAction(id: string, prevState: any, formData: FormData) {
const session = await verifySession();
if (!session) return { success: false, message: "Unauthorized" };
const data = {
title: formData.get("title") as string,
slug: formData.get("slug") as string,
description: formData.get("description") as string,
category: formData.get("category") as string,
repoUrl: formData.get("repoUrl") as string,
liveUrl: formData.get("liveUrl") as string,
isPublished: formData.get("isPublished") === "on",
image: formData.get("image") as File | null,
};
const validation = projectSchema.safeParse(data);
if (!validation.success) {
return { success: false, message: validation.error.issues[0].message };
}
let imageUrl: string | undefined = undefined;
if (data.image && data.image.size > 0 && data.image.name) {
try {
imageUrl = await uploadFileToMinio(data.image);
} catch (e) {
return { success: false, message: "Failed to upload new image." };
}
}
try {
await prisma.project.update({
where: { id },
data: {
title: validation.data.title,
slug: validation.data.slug,
description: validation.data.description,
category: validation.data.category,
repoUrl: validation.data.repoUrl || null,
liveUrl: validation.data.liveUrl || null,
isPublished: validation.data.isPublished,
...(imageUrl && { imageUrl }), // only update image if a new one was uploaded
},
});
revalidatePath("/admin/dashboard");
revalidatePath("/admin/dashboard/projects");
revalidatePath("/");
return { success: true };
} catch (error: any) {
if (error?.code === "P2002") {
return { success: false, message: "Project slug already exists" };
}
return { success: false, message: "Failed to update project" };
}
}
export async function deleteProjectAction(id: string) {
const session = await verifySession();
if (!session) return { success: false, message: "Unauthorized" };
try {
await prisma.project.delete({ where: { id } });
revalidatePath("/admin/dashboard");
revalidatePath("/");
return { success: true };
} catch (error) {
return { success: false, message: "Failed to delete project" };
}
}
export async function toggleProjectPublishAction(id: string, isPublished: boolean) {
const session = await verifySession();
if (!session) return { success: false, message: "Unauthorized" };
try {
await prisma.project.update({
where: { id },
data: { isPublished: !isPublished },
});
revalidatePath("/admin/dashboard");
revalidatePath("/");
return { success: true };
} catch (error) {
return { success: false, message: "Failed to update project status" };
}
}

View File

@@ -0,0 +1,28 @@
"use client";
import { useState } from "react";
import { deleteProjectAction } from "./actions";
import { Trash2, Loader2 } from "lucide-react";
export function DeleteProjectButton({ id }: { id: string }) {
const [loading, setLoading] = useState(false);
async function handleDelete() {
if (confirm("Are you sure you want to delete this project? This cannot be undone.")) {
setLoading(true);
await deleteProjectAction(id);
setLoading(false);
}
}
return (
<button
onClick={handleDelete}
disabled={loading}
className="p-2 text-muted-foreground hover:text-error hover:bg-error/10 rounded-lg transition-colors disabled:opacity-50"
title="Delete Project"
>
{loading ? <Loader2 size={18} className="animate-spin" /> : <Trash2 size={18} />}
</button>
);
}

View File

@@ -0,0 +1,210 @@
"use client";
import { useState } from "react";
import { createProjectAction, updateProjectAction } from "./actions";
import { useRouter } from "@/i18n/routing";
import { Loader2, Upload, PlusCircle, ArrowLeft, Save } from "lucide-react";
export function ProjectForm({ initialData, projectId }: { initialData?: any; projectId?: string }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [preview, setPreview] = useState<string | null>(initialData?.imageUrl || null);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setLoading(true);
try {
const formData = new FormData(e.currentTarget);
const url = projectId ? `/api/admin/projects/${projectId}` : "/api/admin/projects";
const method = projectId ? "PUT" : "POST";
const response = await fetch(url, {
method,
body: formData,
});
const result = await response.json();
if (!response.ok || !result.success) {
setError(result.message || "An error occurred");
setLoading(false);
} else {
setLoading(false);
router.refresh();
router.push("/admin/dashboard/projects");
}
} catch (err) {
setError("An unexpected error occurred during submission.");
setLoading(false);
}
}
return (
<div className="max-w-3xl mx-auto p-6 lg:p-10 rounded-3xl bg-card border border-border overflow-hidden relative shadow-sm">
<div className="absolute top-0 inset-x-0 h-1 bg-gradient-to-r from-accent to-purple-500" />
<div className="mb-8 flex items-center gap-4">
<button
type="button"
onClick={() => router.back()}
className="p-2 rounded-full hover:bg-muted text-muted-foreground transition-colors"
>
<ArrowLeft size={20} />
</button>
<div>
<h1 className="text-2xl font-bold tracking-tight">
{projectId ? "Edit Project" : "Create New Project"}
</h1>
<p className="text-sm text-muted-foreground">
{projectId ? "Update your portfolio case study" : "Add a new case study to your portfolio"}
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-4 rounded-xl bg-error/10 border border-error/20 text-error text-sm font-medium">
{error}
</div>
)}
<div className="grid md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-semibold">Project Title</label>
<input
name="title"
required
defaultValue={initialData?.title}
placeholder="Core Banking API"
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 transition-all font-mono"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold">URL Slug</label>
<input
name="slug"
required
defaultValue={initialData?.slug}
placeholder="core-banking-api"
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 transition-all font-mono"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold">Category</label>
<select
name="category"
required
defaultValue={initialData?.category || "Enterprise Backend"}
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 transition-all font-mono cursor-pointer"
>
<option value="Enterprise Backend">Enterprise Backend</option>
<option value="Frontend Development">Frontend Development</option>
<option value="Mobile Development">Mobile Development</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold">Description</label>
<textarea
name="description"
required
defaultValue={initialData?.description}
rows={4}
placeholder="High-performance API Gateway handling 500K+ daily transactions..."
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 transition-all font-mono resize-y"
/>
</div>
<div className="grid md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-semibold">Live URL (Optional)</label>
<input
name="liveUrl"
type="url"
defaultValue={initialData?.liveUrl || ""}
placeholder="https://example.com"
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 transition-all font-mono"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold">Repository URL (Optional)</label>
<input
name="repoUrl"
type="url"
defaultValue={initialData?.repoUrl || ""}
placeholder="https://github.com/..."
className="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 transition-all font-mono"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold">Cover Image</label>
<label className="flex flex-col items-center justify-center w-full h-40 rounded-xl border-2 border-dashed border-border hover:border-accent/50 bg-muted/10 hover:bg-muted/30 cursor-pointer transition-colors relative overflow-hidden">
{preview ? (
<img src={preview} alt="Preview" className="w-full h-full object-cover opacity-80" />
) : (
<div className="flex flex-col items-center text-muted-foreground">
<Upload size={24} className="mb-2" />
<span className="text-sm font-medium">Click to upload image</span>
<span className="text-xs mt-1 opacity-75">PNG, JPG, WEBP recommended</span>
</div>
)}
<input
type="file"
name="image"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) setPreview(URL.createObjectURL(file));
}}
/>
</label>
</div>
<div className="flex items-center gap-3 pt-2">
<input
type="checkbox"
name="isPublished"
id="isPublished"
className="w-5 h-5 rounded border-border text-accent focus:ring-accent/50"
defaultChecked={initialData ? initialData.isPublished : true}
/>
<label htmlFor="isPublished" className="text-sm font-medium cursor-pointer">
Publish immediately (visible to visitors)
</label>
</div>
<div className="pt-6 border-t border-border/50 flex justify-end">
<button
type="submit"
disabled={loading}
className="flex items-center gap-2 px-8 py-3 rounded-xl bg-foreground text-background font-semibold text-sm hover:opacity-90 transition-all disabled:opacity-50 disabled:scale-100 hover:scale-[1.02] shadow-md"
>
{loading ? (
<Loader2 size={18} className="animate-spin" />
) : projectId ? (
<>
<Save size={18} />
Update Project
</>
) : (
<>
<PlusCircle size={18} />
Save Project
</>
)}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { z } from "zod";
// Ensure image is optional but if provided it must be a File object
export const projectSchema = z.object({
title: z.string().min(3, "Title must be at least 3 characters long"),
slug: z.string().min(3, "Slug must be at least 3 characters long").regex(/^[a-z0-9-]+$/, "Slug must only contain lowercase letters, numbers, and dashes"),
description: z.string().min(10, "Description must be at least 10 characters long"),
category: z.string().min(1, "Category is required"),
repoUrl: z.union([z.string().url("Must be a valid URL"), z.literal("")]).optional(),
liveUrl: z.union([z.string().url("Must be a valid URL"), z.literal("")]).optional(),
isPublished: z.boolean().default(false),
// Validation for image handles Server ActionFormData natively
image: z
.any()
.refine((file) => !file || file?.size === 0 || file?.name, "Invalid file format")
.optional(),
});
export type ProjectFormValues = z.infer<typeof projectSchema>;

View File

@@ -15,66 +15,18 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
export function ProjectsSection() { export function ProjectsSection({ initialProjects }: { initialProjects?: any[] }) {
const t = useTranslations("Projects"); const t = useTranslations("Projects");
const [activeFilter, setActiveFilter] = useState("all"); const [activeFilter, setActiveFilter] = useState("all");
const projects = [ // Use initialProjects from DB or fallback to empty array
{ const projects = initialProjects || [];
id: "1",
title: t("items.apiGateway.title"),
description: t("items.apiGateway.description"),
category: "backend",
tags: ["Spring Boot", "Kafka", "Redis", "Docker"],
metrics: t.has("items.apiGateway.metrics") ? t("items.apiGateway.metrics") : undefined,
},
{
id: "2",
title: t("items.paymentEngine.title"),
description: t("items.paymentEngine.description"),
category: "backend",
tags: ["Java", "Kafka", "PostgreSQL", "gRPC"],
metrics: t.has("items.paymentEngine.metrics") ? t("items.paymentEngine.metrics") : undefined,
},
{
id: "3",
title: t("items.onboarding.title"),
description: t("items.onboarding.description"),
category: "frontend",
tags: ["React", "Next.js", "TypeScript", "Tailwind"],
repoUrl: "#",
liveUrl: "#",
},
{
id: "4",
title: t("items.dashboard.title"),
description: t("items.dashboard.description"),
category: "frontend",
tags: ["React", "Chart.js", "WebSocket", "REST"],
repoUrl: "#",
},
{
id: "5",
title: t("items.mobileApp.title"),
description: t("items.mobileApp.description"),
category: "mobile",
tags: ["React Native", "TypeScript", "Redux"],
},
{
id: "6",
title: t("items.authService.title"),
description: t("items.authService.description"),
category: "backend",
tags: ["Spring Security", "OAuth2", "JWT", "Redis"],
metrics: t.has("items.authService.metrics") ? t("items.authService.metrics") : undefined,
},
];
const filters = [ const filters = [
{ value: "all", label: t("filters.all"), icon: <Layers size={16} /> }, { value: "all", label: t("filters.all"), icon: <Layers size={16} /> },
{ value: "backend", label: t("filters.backend"), icon: <Server size={16} /> }, { value: "Enterprise Backend", label: "Backend", icon: <Server size={16} /> },
{ value: "frontend", label: t("filters.frontend"), icon: <Globe size={16} /> }, { value: "Frontend Development", label: "Frontend", icon: <Globe size={16} /> },
{ value: "mobile", label: t("filters.mobile"), icon: <Smartphone size={16} /> }, { value: "Mobile Development", label: "Mobile", icon: <Smartphone size={16} /> },
]; ];
const filteredProjects = const filteredProjects =
@@ -131,21 +83,21 @@ export function ProjectsSection() {
{/* Category badge */} {/* Category badge */}
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<span <span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[10px] font-mono font-semibold uppercase tracking-wider ${project.category === "backend" className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[10px] font-mono font-semibold uppercase tracking-wider ${project.category === "Enterprise Backend"
? "bg-blue-500/10 text-blue-500 dark:text-blue-400" ? "bg-blue-500/10 text-blue-500 dark:text-blue-400"
: project.category === "frontend" : project.category === "Frontend Development"
? "bg-violet-500/10 text-violet-500 dark:text-violet-400" ? "bg-violet-500/10 text-violet-500 dark:text-violet-400"
: "bg-orange-500/10 text-orange-500 dark:text-orange-400" : "bg-orange-500/10 text-orange-500 dark:text-orange-400"
}`} }`}
> >
{project.category === "backend" ? ( {project.category === "Enterprise Backend" ? (
<Server size={12} /> <Server size={12} />
) : project.category === "frontend" ? ( ) : project.category === "Frontend Development" ? (
<Globe size={12} /> <Globe size={12} />
) : ( ) : (
<Smartphone size={12} /> <Smartphone size={12} />
)} )}
{project.category} {project.category.replace(" Development", "").replace("Enterprise ", "")}
</span> </span>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{project.repoUrl && ( {project.repoUrl && (
@@ -169,6 +121,17 @@ export function ProjectsSection() {
</div> </div>
</div> </div>
{/* Image banner */}
{project.imageUrl && (
<div className="w-full h-40 mb-4 rounded-xl overflow-hidden bg-muted/20 relative group-hover:shadow-md transition-shadow">
<img
src={project.imageUrl}
alt={project.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
</div>
)}
{/* Title & description */} {/* Title & description */}
<h3 className="text-lg font-bold mb-2 group-hover:text-accent transition-colors"> <h3 className="text-lg font-bold mb-2 group-hover:text-accent transition-colors">
{project.title} {project.title}
@@ -187,9 +150,9 @@ export function ProjectsSection() {
</div> </div>
)} )}
{/* Tags */} {/* Tags (Using generic category slice due to PRISMA missing tags yet) */}
<div className="flex flex-wrap gap-1.5 mt-auto"> <div className="flex flex-wrap gap-1.5 mt-auto">
{project.tags.map((tag) => ( {(project.tags || [project.category.split(" ")[0]]).map((tag: string) => (
<span <span
key={tag} key={tag}
className="px-2 py-1 rounded-md text-[10px] font-mono bg-muted/50 text-muted-foreground border border-border/30" className="px-2 py-1 rounded-md text-[10px] font-mono bg-muted/50 text-muted-foreground border border-border/30"

View File

@@ -0,0 +1,36 @@
"use client";
import { useState } from "react";
import { toggleProjectPublishAction } from "./actions";
import { Loader2 } from "lucide-react";
export function TogglePublishButton({ id, isPublished }: { id: string; isPublished: boolean }) {
const [loading, setLoading] = useState(false);
async function handleToggle() {
setLoading(true);
await toggleProjectPublishAction(id, isPublished);
setLoading(false);
}
return (
<button
onClick={handleToggle}
disabled={loading}
className={`px-3 py-1 rounded-full text-xs font-bold font-mono border transition-all disabled:opacity-50 ${
isPublished
? "bg-success/10 text-success border-success/30 hover:bg-error/10 hover:text-error hover:border-error/30 group"
: "bg-muted/50 text-muted-foreground border-border hover:bg-success/10 hover:text-success hover:border-success/30"
}`}
>
{loading ? (
<Loader2 size={12} className="animate-spin" />
) : isPublished ? (
<span className="group-hover:hidden">Published</span>
) : (
<span>Draft</span>
)}
{isPublished && !loading && <span className="hidden group-hover:inline">Unpublish</span>}
</button>
);
}