feat: implement skill management system with database schema, server actions, and dynamic tech stack section
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "skills" ADD COLUMN "sort_order" INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -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[]
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +84,7 @@ 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>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<span
|
<span
|
||||||
className={`text-[10px] font-mono font-bold px-2 py-0.5 rounded-full ${
|
className={`text-[10px] font-mono font-bold px-2 py-0.5 rounded-full ${
|
||||||
CATEGORY_COLORS[skill.category] ?? "text-muted-foreground bg-muted"
|
CATEGORY_COLORS[skill.category] ?? "text-muted-foreground bg-muted"
|
||||||
@@ -91,6 +92,10 @@ export default async function AdminSkillsPage() {
|
|||||||
>
|
>
|
||||||
{CATEGORY_LABELS[skill.category] ?? skill.category}
|
{CATEGORY_LABELS[skill.category] ?? skill.category}
|
||||||
</span>
|
</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">
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
53
src/features/skills/animated-tech-item.tsx
Normal file
53
src/features/skills/animated-tech-item.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user