Compare commits
2 Commits
0549f12a97
...
ef6b44604a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef6b44604a | ||
|
|
01ecca4b28 |
1548
package-lock.json
generated
1548
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
BIN
public/uploads/1774706832711-Screenshot_2026-03-28_205042.png
Normal file
BIN
public/uploads/1774706832711-Screenshot_2026-03-28_205042.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 180 KiB |
BIN
public/uploads/1774707074665-Screenshot_2026-03-20_031020.png
Normal file
BIN
public/uploads/1774707074665-Screenshot_2026-03-20_031020.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 183 KiB |
@@ -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 →</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">
|
||||||
|
|||||||
33
src/app/[locale]/admin/dashboard/projects/[id]/edit/page.tsx
Normal file
33
src/app/[locale]/admin/dashboard/projects/[id]/edit/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/app/[locale]/admin/dashboard/projects/create/page.tsx
Normal file
19
src/app/[locale]/admin/dashboard/projects/create/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
src/app/[locale]/admin/dashboard/projects/page.tsx
Normal file
115
src/app/[locale]/admin/dashboard/projects/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 />
|
||||||
|
|||||||
90
src/app/api/admin/projects/[id]/route.ts
Normal file
90
src/app/api/admin/projects/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/app/api/admin/projects/route.ts
Normal file
68
src/app/api/admin/projects/route.ts
Normal 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
28
src/core/storage/local.ts
Normal 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
38
src/core/storage/minio.ts
Normal 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}`;
|
||||||
|
}
|
||||||
156
src/features/projects/actions.ts
Normal file
156
src/features/projects/actions.ts
Normal 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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/features/projects/delete-button.tsx
Normal file
28
src/features/projects/delete-button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
210
src/features/projects/project-form.tsx
Normal file
210
src/features/projects/project-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/features/projects/project-schema.ts
Normal file
19
src/features/projects/project-schema.ts
Normal 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>;
|
||||||
@@ -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"
|
||||||
|
|||||||
36
src/features/projects/toggle-button.tsx
Normal file
36
src/features/projects/toggle-button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user