feat: implement portfolio dashboard with skill, experience, and message management features
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import { prisma } from "@/core/db/prisma";
|
||||
import { verifySession } from "@/core/security/session";
|
||||
import { redirect } from "@/i18n/routing";
|
||||
import { getLocale } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ExperienceForm } from "@/features/experience/experience-form";
|
||||
|
||||
export default async function EditExperiencePage({
|
||||
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 experience = await prisma.experience.findUnique({ where: { id } });
|
||||
if (!experience) notFound();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/10 p-6 lg:p-12">
|
||||
<ExperienceForm
|
||||
initialData={{
|
||||
year: experience.year,
|
||||
title: experience.title,
|
||||
company: experience.company,
|
||||
description: experience.description,
|
||||
achievements: experience.achievements as string[],
|
||||
order: experience.order,
|
||||
}}
|
||||
experienceId={experience.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
src/app/[locale]/admin/dashboard/experience/create/page.tsx
Normal file
16
src/app/[locale]/admin/dashboard/experience/create/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { verifySession } from "@/core/security/session";
|
||||
import { redirect } from "@/i18n/routing";
|
||||
import { getLocale } from "next-intl/server";
|
||||
import { ExperienceForm } from "@/features/experience/experience-form";
|
||||
|
||||
export default async function CreateExperiencePage() {
|
||||
const session = await verifySession();
|
||||
const locale = await getLocale();
|
||||
if (!session) redirect({ href: "/admin/login", locale });
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/10 p-6 lg:p-12">
|
||||
<ExperienceForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
src/app/[locale]/admin/dashboard/experience/page.tsx
Normal file
110
src/app/[locale]/admin/dashboard/experience/page.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
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, Briefcase } from "lucide-react";
|
||||
import { DeleteExperienceButton } from "@/features/experience/delete-experience-button";
|
||||
|
||||
export default async function AdminExperiencePage() {
|
||||
const session = await verifySession();
|
||||
const locale = await getLocale();
|
||||
|
||||
if (!session) {
|
||||
redirect({ href: "/admin/login", locale });
|
||||
}
|
||||
|
||||
const experiences = await prisma.experience.findMany({
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/10 p-6 lg:p-12">
|
||||
<div className="max-w-5xl 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">Work Experience</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Manage your career timeline.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/dashboard/experience/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} />
|
||||
Add Experience
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{experiences.length === 0 ? (
|
||||
<div className="p-12 text-center flex flex-col items-center bg-card rounded-2xl border border-border/50">
|
||||
<Briefcase size={48} className="text-muted-foreground/40 mb-4" />
|
||||
<h3 className="text-lg font-bold">No experience entries yet</h3>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
Build your career timeline in the CMS.
|
||||
</p>
|
||||
<Link
|
||||
href="/admin/dashboard/experience/create"
|
||||
className="px-6 py-2 rounded-xl border border-border hover:bg-muted/50 transition-colors font-medium text-sm"
|
||||
>
|
||||
Add First Entry
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{experiences.map((exp) => (
|
||||
<div
|
||||
key={exp.id}
|
||||
className="group flex items-start justify-between gap-4 p-5 rounded-2xl bg-card border border-border/50 hover:border-accent/30 hover:shadow-md transition-all"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="text-xs font-mono font-bold text-accent bg-accent/10 px-2.5 py-1 rounded-full">
|
||||
{exp.year}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
order: {exp.order}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-bold text-base">{exp.title}</h3>
|
||||
<p className="text-sm text-muted-foreground font-medium flex items-center gap-1.5 mt-0.5">
|
||||
<Briefcase size={12} className="text-accent/60" />
|
||||
{exp.company}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-2 line-clamp-2 leading-relaxed">
|
||||
{exp.description}
|
||||
</p>
|
||||
{Array.isArray(exp.achievements) && exp.achievements.length > 0 && (
|
||||
<div className="mt-2 flex gap-2 flex-wrap">
|
||||
<span className="text-xs font-mono text-muted-foreground/60">
|
||||
{(exp.achievements as string[]).length} achievements
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
||||
<Link
|
||||
href={`/admin/dashboard/experience/${exp.id}/edit`}
|
||||
className="p-2 text-muted-foreground hover:text-accent hover:bg-accent/10 rounded-lg transition-colors"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</Link>
|
||||
<DeleteExperienceButton id={exp.id} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
src/app/[locale]/admin/dashboard/inbox/page.tsx
Normal file
108
src/app/[locale]/admin/dashboard/inbox/page.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
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 { ArrowLeft, Mail, MailOpen, Inbox } from "lucide-react";
|
||||
import { MarkReadButton, DeleteMessageButton } from "@/features/messages/message-actions";
|
||||
|
||||
export default async function AdminInboxPage() {
|
||||
const session = await verifySession();
|
||||
const locale = await getLocale();
|
||||
|
||||
if (!session) {
|
||||
redirect({ href: "/admin/login", locale });
|
||||
}
|
||||
|
||||
const messages = await prisma.message.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
const unreadCount = messages.filter((m) => !m.isRead).length;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/10 p-6 lg:p-12">
|
||||
<div className="max-w-5xl 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 flex items-center gap-3">
|
||||
Inbox
|
||||
{unreadCount > 0 && (
|
||||
<span className="text-sm font-mono font-bold px-2.5 py-1 rounded-full bg-blue-500 text-white">
|
||||
{unreadCount} baru
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Pesan masuk dari pengunjung portfolio.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{messages.length === 0 ? (
|
||||
<div className="p-12 text-center flex flex-col items-center bg-card rounded-2xl border border-border/50">
|
||||
<Inbox size={48} className="text-muted-foreground/40 mb-4" />
|
||||
<h3 className="text-lg font-bold">Inbox kosong</h3>
|
||||
<p className="text-sm text-muted-foreground">Belum ada pesan masuk.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`relative p-5 rounded-2xl border transition-all ${
|
||||
message.isRead
|
||||
? "bg-card border-border/50 opacity-70"
|
||||
: "bg-card border-blue-500/30 shadow-sm shadow-blue-500/5"
|
||||
}`}
|
||||
>
|
||||
{/* Unread indicator */}
|
||||
{!message.isRead && (
|
||||
<span className="absolute top-4 left-4 w-2 h-2 rounded-full bg-blue-500" />
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-between gap-4 pl-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div
|
||||
className={`p-1.5 rounded-lg ${
|
||||
message.isRead
|
||||
? "text-muted-foreground bg-muted"
|
||||
: "text-blue-500 bg-blue-500/10"
|
||||
}`}
|
||||
>
|
||||
{message.isRead ? <MailOpen size={14} /> : <Mail size={14} />}
|
||||
</div>
|
||||
<span className="font-bold text-sm">{message.senderName}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{message.senderEmail}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-3 pl-9">
|
||||
{message.content}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground/60 font-mono mt-2 pl-9">
|
||||
{new Date(message.createdAt).toLocaleString("id-ID")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{!message.isRead && <MarkReadButton id={message.id} />}
|
||||
<DeleteMessageButton id={message.id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { verifySession, clearSession } from "@/core/security/session";
|
||||
import { Link, redirect } from "@/i18n/routing";
|
||||
import { getLocale } from "next-intl/server";
|
||||
import { LogOut, ArrowRight } from "lucide-react";
|
||||
import { LogOut, ArrowRight, Code2, Inbox, Briefcase, FolderKanban } from "lucide-react";
|
||||
import { prisma } from "@/core/db/prisma";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
@@ -12,6 +12,55 @@ export default async function DashboardPage() {
|
||||
redirect({ href: "/admin/login", locale });
|
||||
}
|
||||
|
||||
// Fetch counts in parallel
|
||||
const [projectCount, skillCount, unreadMessageCount, experienceCount] =
|
||||
await Promise.all([
|
||||
prisma.project.count(),
|
||||
prisma.skill.count(),
|
||||
prisma.message.count({ where: { isRead: false } }),
|
||||
prisma.experience.count(),
|
||||
]);
|
||||
|
||||
const cards = [
|
||||
{
|
||||
href: "/admin/dashboard/projects",
|
||||
icon: <FolderKanban size={22} />,
|
||||
title: "Projects",
|
||||
description: "Manage portfolio projects and case studies.",
|
||||
count: projectCount,
|
||||
color: "text-accent",
|
||||
accentClass: "from-accent to-purple-500",
|
||||
},
|
||||
{
|
||||
href: "/admin/dashboard/skills",
|
||||
icon: <Code2 size={22} />,
|
||||
title: "Tech Stack",
|
||||
description: "Update your skills and technical arsenal.",
|
||||
count: skillCount,
|
||||
color: "text-emerald-500",
|
||||
accentClass: "from-emerald-500 to-teal-500",
|
||||
},
|
||||
{
|
||||
href: "/admin/dashboard/inbox",
|
||||
icon: <Inbox size={22} />,
|
||||
title: "Inbox",
|
||||
description: "Read messages from visitors.",
|
||||
count: unreadMessageCount,
|
||||
color: "text-blue-500",
|
||||
accentClass: "from-blue-500 to-cyan-500",
|
||||
badge: unreadMessageCount > 0 ? `${unreadMessageCount} baru` : null,
|
||||
},
|
||||
{
|
||||
href: "/admin/dashboard/experience",
|
||||
icon: <Briefcase size={22} />,
|
||||
title: "Experience",
|
||||
description: "Manage your career timeline.",
|
||||
count: experienceCount,
|
||||
color: "text-violet-500",
|
||||
accentClass: "from-violet-500 to-purple-500",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/30">
|
||||
{/* Navbar Minimal Dashboard */}
|
||||
@@ -21,15 +70,17 @@ export default async function DashboardPage() {
|
||||
<h1 className="text-xl font-bold tracking-tight">Admin Dashboard</h1>
|
||||
<span className="text-xs text-muted-foreground">{session.email}</span>
|
||||
</div>
|
||||
|
||||
<form action={async () => {
|
||||
"use server";
|
||||
await clearSession();
|
||||
redirect({ href: "/admin/login", locale });
|
||||
}}>
|
||||
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
await clearSession();
|
||||
redirect({ href: "/admin/login", locale });
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-error hover:bg-error/10 hover:border-error/30 border border-transparent rounded-lg transition-colors"
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-red-500 hover:bg-red-500/10 hover:border-red-500/30 border border-transparent rounded-lg transition-colors"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
Sign Out
|
||||
@@ -40,30 +91,42 @@ export default async function DashboardPage() {
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-7xl mx-auto px-6 py-12">
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{/* Card: Projects */}
|
||||
<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">
|
||||
<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>
|
||||
<div className="text-3xl font-mono font-bold text-accent">Go →</div>
|
||||
</Link>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{cards.map((card) => (
|
||||
<Link
|
||||
key={card.href}
|
||||
href={card.href}
|
||||
className="relative p-6 rounded-2xl bg-card border border-border/50 shadow-sm hover:shadow-lg hover:border-accent/30 hover:-translate-y-1 transition-all group block overflow-hidden"
|
||||
>
|
||||
{/* Top accent bar */}
|
||||
<div className={`absolute top-0 inset-x-0 h-0.5 bg-gradient-to-r ${card.accentClass} opacity-0 group-hover:opacity-100 transition-opacity`} />
|
||||
|
||||
{/* Card: Skills */}
|
||||
<div className="p-6 rounded-2xl bg-card border border-border/50 shadow-sm hover:shadow-md transition-shadow">
|
||||
<h2 className="text-lg font-bold mb-2">Tech Stack</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">Update your skills and technical arsenal.</p>
|
||||
<div className="text-3xl font-mono font-bold text-emerald-500">--</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className={`p-2.5 rounded-xl bg-gradient-to-br ${card.accentClass} text-white shadow-md`}>
|
||||
{card.icon}
|
||||
</div>
|
||||
<ArrowRight
|
||||
size={18}
|
||||
className="text-muted-foreground group-hover:text-accent group-hover:translate-x-1 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Card: Messages */}
|
||||
<div className="p-6 rounded-2xl bg-card border border-border/50 shadow-sm hover:shadow-md transition-shadow relative overflow-hidden">
|
||||
<h2 className="text-lg font-bold mb-2">Inbox</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">Read messages from visitors.</p>
|
||||
<div className="text-3xl font-mono font-bold text-blue-500">--</div>
|
||||
</div>
|
||||
<h2 className="text-lg font-bold mb-1 flex items-center gap-2">
|
||||
{card.title}
|
||||
{card.badge && (
|
||||
<span className="text-[10px] font-mono font-bold px-2 py-0.5 rounded-full bg-blue-500 text-white">
|
||||
{card.badge}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{card.description}
|
||||
</p>
|
||||
<div className={`text-3xl font-mono font-bold ${card.color}`}>
|
||||
{card.count}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
26
src/app/[locale]/admin/dashboard/skills/[id]/edit/page.tsx
Normal file
26
src/app/[locale]/admin/dashboard/skills/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { prisma } from "@/core/db/prisma";
|
||||
import { verifySession } from "@/core/security/session";
|
||||
import { redirect } from "@/i18n/routing";
|
||||
import { getLocale } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import { SkillForm } from "@/features/skills/skill-form";
|
||||
|
||||
export default async function EditSkillPage({
|
||||
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 skill = await prisma.skill.findUnique({ where: { id } });
|
||||
if (!skill) notFound();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/10 p-6 lg:p-12">
|
||||
<SkillForm initialData={skill} skillId={skill.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
src/app/[locale]/admin/dashboard/skills/create/page.tsx
Normal file
16
src/app/[locale]/admin/dashboard/skills/create/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { verifySession } from "@/core/security/session";
|
||||
import { redirect } from "@/i18n/routing";
|
||||
import { getLocale } from "next-intl/server";
|
||||
import { SkillForm } from "@/features/skills/skill-form";
|
||||
|
||||
export default async function CreateSkillPage() {
|
||||
const session = await verifySession();
|
||||
const locale = await getLocale();
|
||||
if (!session) redirect({ href: "/admin/login", locale });
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/10 p-6 lg:p-12">
|
||||
<SkillForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
src/app/[locale]/admin/dashboard/skills/page.tsx
Normal file
112
src/app/[locale]/admin/dashboard/skills/page.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
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, Code2 } from "lucide-react";
|
||||
import { DeleteSkillButton } from "@/features/skills/delete-skill-button";
|
||||
import { SkillIcon } from "@/features/skills/skill-icon";
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
backend: "Enterprise Backend",
|
||||
infra: "Database & Infra",
|
||||
frontend: "Frontend",
|
||||
mobile: "Mobile",
|
||||
};
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
backend: "text-blue-500 bg-blue-500/10",
|
||||
infra: "text-emerald-500 bg-emerald-500/10",
|
||||
frontend: "text-violet-500 bg-violet-500/10",
|
||||
mobile: "text-orange-500 bg-orange-500/10",
|
||||
};
|
||||
|
||||
export default async function AdminSkillsPage() {
|
||||
const session = await verifySession();
|
||||
const locale = await getLocale();
|
||||
|
||||
if (!session) {
|
||||
redirect({ href: "/admin/login", locale });
|
||||
}
|
||||
|
||||
const skills = await prisma.skill.findMany({
|
||||
orderBy: [{ category: "asc" }, { name: "asc" }],
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/10 p-6 lg:p-12">
|
||||
<div className="max-w-5xl 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">Tech Stack</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Manage your skills and technology arsenal.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/dashboard/skills/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} />
|
||||
Add Skill
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{skills.length === 0 ? (
|
||||
<div className="p-12 text-center flex flex-col items-center bg-card rounded-2xl border border-border/50">
|
||||
<Code2 size={48} className="text-muted-foreground/40 mb-4" />
|
||||
<h3 className="text-lg font-bold">No skills yet</h3>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
Start adding your tech stack.
|
||||
</p>
|
||||
<Link
|
||||
href="/admin/dashboard/skills/create"
|
||||
className="px-6 py-2 rounded-xl border border-border hover:bg-muted/50 transition-colors font-medium text-sm"
|
||||
>
|
||||
Add First Skill
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{skills.map((skill) => (
|
||||
<div
|
||||
key={skill.id}
|
||||
className="group flex items-center justify-between p-4 rounded-2xl bg-card border border-border/50 hover:border-accent/30 hover:shadow-md transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<SkillIcon iconName={skill.iconName} name={skill.name} />
|
||||
<div>
|
||||
<p className="font-semibold text-sm">{skill.name}</p>
|
||||
<span
|
||||
className={`text-[10px] font-mono font-bold px-2 py-0.5 rounded-full ${
|
||||
CATEGORY_COLORS[skill.category] ?? "text-muted-foreground bg-muted"
|
||||
}`}
|
||||
>
|
||||
{CATEGORY_LABELS[skill.category] ?? skill.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Link
|
||||
href={`/admin/dashboard/skills/${skill.id}/edit`}
|
||||
className="p-2 text-muted-foreground hover:text-accent hover:bg-accent/10 rounded-lg transition-colors"
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</Link>
|
||||
<DeleteSkillButton id={skill.id} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user