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

@@ -60,10 +60,10 @@
} }
}, },
"TechStack": { "TechStack": {
"badge": "Tech Arsenal", "badge": "Technologies",
"title": "Technology Stack", "title": "Skills & Technologies",
"seeMore": "See more", "seeMore": "See more",
"subtitle": "A comprehensive toolkit forged through years of enterprise development, from backend infrastructure to mobile interfaces.", "subtitle": "The core technologies and tools I use to build robust and scalable enterprise applications.",
"categories": { "categories": {
"backend": { "backend": {
"title": "Enterprise Backend", "title": "Enterprise Backend",

View File

@@ -60,10 +60,10 @@
} }
}, },
"TechStack": { "TechStack": {
"badge": "Arsenal Teknologi", "badge": "Teknologi",
"title": "Tech Stack", "title": "Keahlian & Teknologi",
"seeMore": "Lihat semua", "seeMore": "Lihat semua",
"subtitle": "Perangkat komprehensif yang ditempa dari pengalaman pengembangan enterprise, mulai dari infrastruktur backend hingga antarmuka mobile.", "subtitle": "Kumpulan teknologi dan perangkat utama yang saya gunakan sehari-hari untuk mengembangkan aplikasi berskala enterprise.",
"categories": { "categories": {
"backend": { "backend": {
"title": "Enterprise Backend", "title": "Enterprise Backend",

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "skills" ADD COLUMN "sort_order" INTEGER NOT NULL DEFAULT 0;

View File

@@ -38,6 +38,7 @@ model Skill {
name String name String
iconName String? @map("icon_name") iconName String? @map("icon_name")
category String category String
sortOrder Int @default(0) @map("sort_order")
projects ProjectSkill[] projects ProjectSkill[]

View File

@@ -29,7 +29,7 @@ export default async function AdminSkillsPage() {
} }
const skills = await prisma.skill.findMany({ const skills = await prisma.skill.findMany({
orderBy: [{ category: "asc" }, { name: "asc" }], orderBy: [{ category: "asc" }, { sortOrder: "asc" }, { name: "asc" }],
}); });
return ( return (
@@ -84,13 +84,18 @@ export default async function AdminSkillsPage() {
<SkillIcon iconName={skill.iconName} name={skill.name} /> <SkillIcon iconName={skill.iconName} name={skill.name} />
<div> <div>
<p className="font-semibold text-sm">{skill.name}</p> <p className="font-semibold text-sm">{skill.name}</p>
<span <div className="flex items-center gap-1.5">
className={`text-[10px] font-mono font-bold px-2 py-0.5 rounded-full ${ <span
CATEGORY_COLORS[skill.category] ?? "text-muted-foreground bg-muted" 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> {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> </div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <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" } { message: "Invalid category" }
), ),
iconName: z.string().optional(), iconName: z.string().optional(),
sortOrder: z.coerce.number().int().min(0).default(0),
}); });
export async function createSkillAction(prevState: any, formData: FormData) { 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, name: formData.get("name") as string,
category: formData.get("category") as string, category: formData.get("category") as string,
iconName: formData.get("iconName") as string, iconName: formData.get("iconName") as string,
sortOrder: formData.get("sortOrder") as string,
}; };
const validation = skillSchema.safeParse(data); const validation = skillSchema.safeParse(data);
@@ -35,6 +37,7 @@ export async function createSkillAction(prevState: any, formData: FormData) {
name: validation.data.name, name: validation.data.name,
category: validation.data.category, category: validation.data.category,
iconName: validation.data.iconName || null, iconName: validation.data.iconName || null,
sortOrder: validation.data.sortOrder,
}, },
}); });
revalidatePath("/admin/dashboard/skills"); revalidatePath("/admin/dashboard/skills");
@@ -58,6 +61,7 @@ export async function updateSkillAction(
name: formData.get("name") as string, name: formData.get("name") as string,
category: formData.get("category") as string, category: formData.get("category") as string,
iconName: formData.get("iconName") as string, iconName: formData.get("iconName") as string,
sortOrder: formData.get("sortOrder") as string,
}; };
const validation = skillSchema.safeParse(data); const validation = skillSchema.safeParse(data);
@@ -72,6 +76,7 @@ export async function updateSkillAction(
name: validation.data.name, name: validation.data.name,
category: validation.data.category, category: validation.data.category,
iconName: validation.data.iconName || null, iconName: validation.data.iconName || null,
sortOrder: validation.data.sortOrder,
}, },
}); });
revalidatePath("/admin/dashboard/skills"); 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> </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"> <div className="pt-6 border-t border-border/50 flex justify-end">
<button <button
type="submit" type="submit"

View File

@@ -1,6 +1,7 @@
import { prisma } from "@/core/db/prisma"; import { prisma } from "@/core/db/prisma";
import { AnimatedSection } from "@/shared/components/animated-section"; import { AnimatedSection } from "@/shared/components/animated-section";
import { SectionHeading } from "@/shared/components/section-heading"; import { SectionHeading } from "@/shared/components/section-heading";
import { AnimatedTechItem } from "./animated-tech-item";
import { Layers, Code2 } from "lucide-react"; import { Layers, Code2 } from "lucide-react";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
@@ -36,7 +37,7 @@ export async function TechStackSection() {
const t = await getTranslations("TechStack"); const t = await getTranslations("TechStack");
const skills = await prisma.skill.findMany({ const skills = await prisma.skill.findMany({
orderBy: [{ category: "asc" }, { name: "asc" }], orderBy: [{ category: "asc" }, { sortOrder: "asc" }, { name: "asc" }],
}); });
// Group by category // Group by category
@@ -57,7 +58,6 @@ export async function TechStackSection() {
<div className="relative max-w-6xl mx-auto px-6"> <div className="relative max-w-6xl mx-auto px-6">
<AnimatedSection> <AnimatedSection>
<SectionHeading <SectionHeading
badge={t("badge")}
title={t("title")} title={t("title")}
subtitle={t("subtitle")} subtitle={t("subtitle")}
/> />
@@ -99,27 +99,13 @@ export async function TechStackSection() {
</div> </div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{items.map((skill) => ( {items.map((skill, skillIndex) => (
<div <AnimatedTechItem
key={skill.id} 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" name={skill.name}
> iconName={skill.iconName}
{skill.iconName ? ( index={skillIndex}
<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>
))} ))}
</div> </div>
</div> </div>