feat: implement skill management system with Prisma schema, admin dashboard, and server actions
This commit is contained in:
335
issue.md
Normal file
335
issue.md
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
# Issue: Peningkatan Bagian Proyek (Project Section Enhancement)
|
||||||
|
|
||||||
|
## Deskripsi
|
||||||
|
Setiap kartu proyek perlu diperkaya dengan informasi tambahan: **tech stack**, **keterangan lengkap**, **interaksi klik untuk detail**, **tahun pengerjaan**, dan **target/pencapaian** yang berhasil tercapai.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Langkah-langkah Implementasi
|
||||||
|
|
||||||
|
### Langkah 1 — Update Skema Prisma (Database)
|
||||||
|
|
||||||
|
**File:** `prisma/schema.prisma`
|
||||||
|
|
||||||
|
Tambahkan kolom baru pada model `Project`:
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model Project {
|
||||||
|
// ... field yang sudah ada ...
|
||||||
|
|
||||||
|
techStack String[] // array nama teknologi, e.g. ["Java", "Spring Boot", "PostgreSQL"]
|
||||||
|
year Int? // tahun pengerjaan, e.g. 2024
|
||||||
|
highlights String[] // target/pencapaian, e.g. ["Reduce latency 40%", "10K daily users"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Setelah edit schema, jalankan migrasi:
|
||||||
|
```bash
|
||||||
|
npx prisma migrate dev --name add_project_details
|
||||||
|
npx prisma generate
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Langkah 2 — Update Zod Schema Validasi
|
||||||
|
|
||||||
|
**File:** `src/features/projects/project-schema.ts`
|
||||||
|
|
||||||
|
Tambahkan field baru ke `projectSchema`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
techStack: z.string().optional(), // dikirim sebagai string CSV dari form, e.g. "Java,Spring Boot,Docker"
|
||||||
|
year: z.coerce.number().optional(),
|
||||||
|
highlights: z.string().optional(), // dikirim sebagai string CSV, e.g. "Reduce latency 40%,10K users"
|
||||||
|
```
|
||||||
|
|
||||||
|
> Catatan: Field diproses sebagai CSV string di form, lalu di-parse menjadi `String[]` sebelum disimpan ke DB.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Langkah 3 — Update Server Action (Create & Update)
|
||||||
|
|
||||||
|
**File:** `src/features/projects/actions.ts`
|
||||||
|
|
||||||
|
Di fungsi `createProjectAction` dan `updateProjectAction`, tambahkan parsing:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const rawTechStack = formData.get("techStack") as string;
|
||||||
|
const rawHighlights = formData.get("highlights") as string;
|
||||||
|
|
||||||
|
// Di blok prisma.project.create / prisma.project.update:
|
||||||
|
techStack: rawTechStack ? rawTechStack.split(",").map(s => s.trim()) : [],
|
||||||
|
year: formData.get("year") ? Number(formData.get("year")) : null,
|
||||||
|
highlights: rawHighlights ? rawHighlights.split(",").map(s => s.trim()) : [],
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Langkah 4 — Update Form Admin (Tambah Field Baru)
|
||||||
|
|
||||||
|
**File:** `src/features/projects/project-form.tsx`
|
||||||
|
|
||||||
|
Tambahkan tiga input baru di dalam `<form>`:
|
||||||
|
|
||||||
|
#### 4a. Tech Stack (input teks, pisahkan dengan koma)
|
||||||
|
```tsx
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-semibold">Tech Stack</label>
|
||||||
|
<input
|
||||||
|
name="techStack"
|
||||||
|
defaultValue={initialData?.techStack?.join(", ") || ""}
|
||||||
|
placeholder="Java, Spring Boot, PostgreSQL, Docker"
|
||||||
|
className="..."
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">Pisahkan setiap teknologi dengan koma</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4b. Tahun Pengerjaan (input number)
|
||||||
|
```tsx
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-semibold">Tahun Pengerjaan</label>
|
||||||
|
<input
|
||||||
|
name="year"
|
||||||
|
type="number"
|
||||||
|
min="2000"
|
||||||
|
max="2099"
|
||||||
|
defaultValue={initialData?.year || new Date().getFullYear()}
|
||||||
|
placeholder="2024"
|
||||||
|
className="..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4c. Pencapaian / Highlights (input teks, pisahkan dengan koma)
|
||||||
|
```tsx
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-semibold">Target & Pencapaian</label>
|
||||||
|
<textarea
|
||||||
|
name="highlights"
|
||||||
|
defaultValue={initialData?.highlights?.join(", ") || ""}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Reduce API latency 40%, Handle 10K daily users, 99.9% uptime"
|
||||||
|
className="..."
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">Pisahkan setiap pencapaian dengan koma</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Langkah 5 — Update Tampilan Kartu Proyek (Project Card)
|
||||||
|
|
||||||
|
**File:** `src/features/projects/projects-section.tsx`
|
||||||
|
|
||||||
|
#### 5a. Tampilkan Tech Stack sebagai badge di kartu
|
||||||
|
Di dalam `project-card`, ganti bagian tags yang lama dengan tech stack:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{/* Tech Stack Badges */}
|
||||||
|
{project.techStack?.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-auto">
|
||||||
|
{project.techStack.map((tech: string) => (
|
||||||
|
<span key={tech} className="px-2 py-1 rounded-md text-[10px] font-mono bg-accent/10 text-accent border border-accent/20">
|
||||||
|
{tech}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5b. Tampilkan Tahun di pojok kanan atas
|
||||||
|
```tsx
|
||||||
|
{project.year && (
|
||||||
|
<span className="text-[10px] font-mono text-muted-foreground">
|
||||||
|
{project.year}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Langkah 6 — Buat Komponen Detail Modal / Drawer
|
||||||
|
|
||||||
|
Buat file baru: `src/features/projects/project-detail-modal.tsx`
|
||||||
|
|
||||||
|
Komponen modal yang muncul ketika kartu proyek di-klik. Isi detail:
|
||||||
|
- Judul proyek
|
||||||
|
- Deskripsi lengkap
|
||||||
|
- Tahun pengerjaan
|
||||||
|
- Tech stack (badge berwarna)
|
||||||
|
- Highlights / pencapaian sebagai checklist ✅
|
||||||
|
- Tombol link ke repo dan live demo
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { X, CheckCircle2, ExternalLink, GitFork, Calendar } from "lucide-react";
|
||||||
|
|
||||||
|
export function ProjectDetailModal({ project, onClose }: { project: any; onClose: () => void }) {
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
{/* Modal container */}
|
||||||
|
<motion.div
|
||||||
|
className="relative w-full max-w-2xl bg-card border border-border rounded-2xl p-8 shadow-xl overflow-y-auto max-h-[85vh]"
|
||||||
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.9, opacity: 0 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Close button */}
|
||||||
|
<button onClick={onClose} className="absolute top-4 right-4 p-2 rounded-full hover:bg-muted text-muted-foreground">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<h2 className="text-2xl font-bold mb-1">{project.title}</h2>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-4">
|
||||||
|
<Calendar size={14} />
|
||||||
|
<span>{project.year}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cover image */}
|
||||||
|
{project.imageUrl && (
|
||||||
|
<img src={project.imageUrl} alt={project.title} className="w-full h-48 object-cover rounded-xl mb-6" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground mb-6">{project.description}</p>
|
||||||
|
|
||||||
|
{/* Tech Stack */}
|
||||||
|
{project.techStack?.length > 0 && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h4 className="text-sm font-semibold mb-2">Tech Stack</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{project.techStack.map((tech: string) => (
|
||||||
|
<span key={tech} className="px-3 py-1.5 rounded-lg text-xs font-mono bg-accent/10 text-accent border border-accent/20">
|
||||||
|
{tech}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Highlights */}
|
||||||
|
{project.highlights?.length > 0 && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h4 className="text-sm font-semibold mb-2">Target & Pencapaian</h4>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{project.highlights.map((item: string, i: number) => (
|
||||||
|
<li key={i} className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||||
|
<CheckCircle2 size={16} className="text-success mt-0.5 shrink-0" />
|
||||||
|
<span>{item}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Links */}
|
||||||
|
<div className="flex gap-3 pt-4 border-t border-border/50">
|
||||||
|
{project.repoUrl && (
|
||||||
|
<a href={project.repoUrl} target="_blank" rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-muted hover:bg-muted/80 text-sm font-medium transition-colors">
|
||||||
|
<GitFork size={14} /> Repository
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{project.liveUrl && (
|
||||||
|
<a href={project.liveUrl} target="_blank" rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-accent text-accent-foreground text-sm font-medium hover:opacity-90 transition-opacity">
|
||||||
|
<ExternalLink size={14} /> Live Demo
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Langkah 7 — Integrasikan Modal ke Kartu Proyek
|
||||||
|
|
||||||
|
**File:** `src/features/projects/projects-section.tsx`
|
||||||
|
|
||||||
|
Tambahkan state untuk kontrol modal:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [selectedProject, setSelectedProject] = useState<any | null>(null);
|
||||||
|
```
|
||||||
|
|
||||||
|
Bungkus `project-card` dengan `onClick`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div
|
||||||
|
className="project-card group ... cursor-pointer"
|
||||||
|
onClick={() => setSelectedProject(project)}
|
||||||
|
>
|
||||||
|
{/* ... isi kartu ... */}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Render modal di luar grid:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{selectedProject && (
|
||||||
|
<ProjectDetailModal
|
||||||
|
project={selectedProject}
|
||||||
|
onClose={() => setSelectedProject(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Langkah 8 — Update i18n (Opsional)
|
||||||
|
|
||||||
|
**File:** `messages/id.json` dan `messages/en.json`
|
||||||
|
|
||||||
|
Tambahkan key terjemahan baru jika ada label yang perlu diterjemahkan, misalnya:
|
||||||
|
```json
|
||||||
|
"Projects": {
|
||||||
|
"techStack": "Tech Stack",
|
||||||
|
"year": "Tahun",
|
||||||
|
"highlights": "Pencapaian"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ringkasan Perubahan File
|
||||||
|
|
||||||
|
| File | Aksi | Keterangan |
|
||||||
|
|------|------|-----------|
|
||||||
|
| `prisma/schema.prisma` | **MODIFY** | Tambah kolom `techStack`, `year`, `highlights` |
|
||||||
|
| `src/features/projects/project-schema.ts` | **MODIFY** | Tambah validasi field baru |
|
||||||
|
| `src/features/projects/actions.ts` | **MODIFY** | Parse & simpan field baru ke DB |
|
||||||
|
| `src/features/projects/project-form.tsx` | **MODIFY** | Tambah input TechStack, Year, Highlights |
|
||||||
|
| `src/features/projects/projects-section.tsx` | **MODIFY** | Tampilkan data baru, integrasikan modal |
|
||||||
|
| `src/features/projects/project-detail-modal.tsx` | **NEW** | Komponen modal detail proyek |
|
||||||
|
| `messages/id.json` | **MODIFY** | Tambah key terjemahan baru |
|
||||||
|
| `messages/en.json` | **MODIFY** | Tambah key terjemahan baru |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Urutan Pengerjaan yang Disarankan
|
||||||
|
|
||||||
|
1. ✅ Update `prisma/schema.prisma` → jalankan migrasi
|
||||||
|
2. ✅ Update `project-schema.ts`
|
||||||
|
3. ✅ Update `actions.ts`
|
||||||
|
4. ✅ Update `project-form.tsx`
|
||||||
|
5. ✅ Buat `project-detail-modal.tsx`
|
||||||
|
6. ✅ Update `projects-section.tsx`
|
||||||
|
7. ✅ Update file i18n
|
||||||
@@ -85,8 +85,8 @@
|
|||||||
},
|
},
|
||||||
"Projects": {
|
"Projects": {
|
||||||
"badge": "Portfolio",
|
"badge": "Portfolio",
|
||||||
"title": "Projects & Case Studies",
|
"title": "Featured Projects",
|
||||||
"subtitle": "Real-world enterprise solutions built for scale, security, and reliability.",
|
"subtitle": "A collection of software solutions and backend systems I've developed to solve real-world business challenges.",
|
||||||
"filters": {
|
"filters": {
|
||||||
"all": "All Projects",
|
"all": "All Projects",
|
||||||
"backend": "Backend",
|
"backend": "Backend",
|
||||||
|
|||||||
@@ -85,8 +85,8 @@
|
|||||||
},
|
},
|
||||||
"Projects": {
|
"Projects": {
|
||||||
"badge": "Portofolio",
|
"badge": "Portofolio",
|
||||||
"title": "Proyek & Studi Kasus",
|
"title": "Proyek Unggulan",
|
||||||
"subtitle": "Solusi enterprise dunia nyata yang dibangun untuk skalabilitas, keamanan, dan keandalan.",
|
"subtitle": "Kumpulan solusi perangkat lunak dan sistem backend yang saya kembangkan untuk menjawab tantangan bisnis nyata.",
|
||||||
"filters": {
|
"filters": {
|
||||||
"all": "Semua Proyek",
|
"all": "Semua Proyek",
|
||||||
"backend": "Backend",
|
"backend": "Backend",
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ export function ProjectsSection({ initialProjects }: { initialProjects?: any[] }
|
|||||||
<div className="max-w-6xl mx-auto px-6">
|
<div className="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")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user