feat: implement skill management system with database schema, server actions, and dynamic tech stack section

This commit is contained in:
Yolando
2026-04-03 17:54:02 +07:00
parent 43858ce798
commit 9f3510df8f
9 changed files with 102 additions and 36 deletions

View File

@@ -29,7 +29,7 @@ export default async function AdminSkillsPage() {
}
const skills = await prisma.skill.findMany({
orderBy: [{ category: "asc" }, { name: "asc" }],
orderBy: [{ category: "asc" }, { sortOrder: "asc" }, { name: "asc" }],
});
return (
@@ -84,13 +84,18 @@ export default async function AdminSkillsPage() {
<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 className="flex items-center gap-1.5">
<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>
<span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded-full">
#{skill.sortOrder}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">

View File

@@ -12,6 +12,7 @@ const skillSchema = z.object({
{ message: "Invalid category" }
),
iconName: z.string().optional(),
sortOrder: z.coerce.number().int().min(0).default(0),
});
export async function createSkillAction(prevState: any, formData: FormData) {
@@ -22,6 +23,7 @@ export async function createSkillAction(prevState: any, formData: FormData) {
name: formData.get("name") as string,
category: formData.get("category") as string,
iconName: formData.get("iconName") as string,
sortOrder: formData.get("sortOrder") as string,
};
const validation = skillSchema.safeParse(data);
@@ -35,6 +37,7 @@ export async function createSkillAction(prevState: any, formData: FormData) {
name: validation.data.name,
category: validation.data.category,
iconName: validation.data.iconName || null,
sortOrder: validation.data.sortOrder,
},
});
revalidatePath("/admin/dashboard/skills");
@@ -58,6 +61,7 @@ export async function updateSkillAction(
name: formData.get("name") as string,
category: formData.get("category") as string,
iconName: formData.get("iconName") as string,
sortOrder: formData.get("sortOrder") as string,
};
const validation = skillSchema.safeParse(data);
@@ -72,6 +76,7 @@ export async function updateSkillAction(
name: validation.data.name,
category: validation.data.category,
iconName: validation.data.iconName || null,
sortOrder: validation.data.sortOrder,
},
});
revalidatePath("/admin/dashboard/skills");

View File

@@ -0,0 +1,53 @@
"use client";
import { motion } from "framer-motion";
import { Code2 } from "lucide-react";
interface AnimatedTechItemProps {
name: string;
iconName: string | null;
index: number;
}
export function AnimatedTechItem({ name, iconName, index }: AnimatedTechItemProps) {
return (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
duration: 0.4,
delay: index * 0.05,
ease: [0.25, 0.4, 0.25, 1],
}}
whileHover={{
scale: 1.06,
y: -4,
transition: { duration: 0.25, ease: "easeOut" },
}}
whileTap={{ scale: 0.97 }}
className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl bg-muted/50 border border-border/30
hover:border-accent/40 hover:bg-accent/8 hover:shadow-lg hover:shadow-accent/5
transition-colors duration-300 cursor-default group/item"
>
{iconName ? (
<motion.img
src={`https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/${iconName}/${iconName}-original.svg`}
alt={name}
className="w-5 h-5 object-contain flex-shrink-0"
whileHover={{
rotate: [0, -10, 10, -5, 0],
transition: { duration: 0.5 },
}}
/>
) : (
<Code2
size={16}
className="text-muted-foreground group-hover/item:text-accent transition-colors flex-shrink-0"
/>
)}
<span className="text-xs font-mono font-medium truncate group-hover/item:text-accent transition-colors duration-300">
{name}
</span>
</motion.div>
);
}

View File

@@ -144,6 +144,20 @@ export function SkillForm({ initialData, skillId }: { initialData?: any; skillId
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold">
Sort Order <span className="text-muted-foreground font-normal">(angka kecil = tampil duluan)</span>
</label>
<input
name="sortOrder"
type="number"
min={0}
defaultValue={initialData?.sortOrder ?? 0}
placeholder="0"
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-emerald-500/50 transition-all font-mono"
/>
</div>
<div className="pt-6 border-t border-border/50 flex justify-end">
<button
type="submit"

View File

@@ -1,6 +1,7 @@
import { prisma } from "@/core/db/prisma";
import { AnimatedSection } from "@/shared/components/animated-section";
import { SectionHeading } from "@/shared/components/section-heading";
import { AnimatedTechItem } from "./animated-tech-item";
import { Layers, Code2 } from "lucide-react";
import { getTranslations } from "next-intl/server";
@@ -36,7 +37,7 @@ export async function TechStackSection() {
const t = await getTranslations("TechStack");
const skills = await prisma.skill.findMany({
orderBy: [{ category: "asc" }, { name: "asc" }],
orderBy: [{ category: "asc" }, { sortOrder: "asc" }, { name: "asc" }],
});
// Group by category
@@ -57,7 +58,6 @@ export async function TechStackSection() {
<div className="relative max-w-6xl mx-auto px-6">
<AnimatedSection>
<SectionHeading
badge={t("badge")}
title={t("title")}
subtitle={t("subtitle")}
/>
@@ -99,27 +99,13 @@ export async function TechStackSection() {
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{items.map((skill) => (
<div
{items.map((skill, skillIndex) => (
<AnimatedTechItem
key={skill.id}
className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl bg-muted/50 border border-border/30 hover:border-accent/30 hover:bg-accent/5 transition-all duration-300 group/item"
>
{skill.iconName ? (
<img
src={`https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/${skill.iconName}/${skill.iconName}-original.svg`}
alt={skill.name}
className="w-5 h-5 object-contain flex-shrink-0"
/>
) : (
<Code2
size={16}
className="text-muted-foreground group-hover/item:text-accent transition-colors flex-shrink-0"
/>
)}
<span className="text-xs font-mono font-medium truncate">
{skill.name}
</span>
</div>
name={skill.name}
iconName={skill.iconName}
index={skillIndex}
/>
))}
</div>
</div>