feat: implement project showcase section with database integration, filtering, and detail modals
This commit is contained in:
335
issue.md
335
issue.md
@@ -1,335 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -86,7 +86,10 @@
|
|||||||
"Projects": {
|
"Projects": {
|
||||||
"badge": "Portfolio",
|
"badge": "Portfolio",
|
||||||
"title": "Featured Projects",
|
"title": "Featured Projects",
|
||||||
"subtitle": "A collection of software solutions and backend systems I've developed to solve real-world business challenges.",
|
"subtitle": "My best work in software development",
|
||||||
|
"techStack": "Tech Stack",
|
||||||
|
"year": "Year",
|
||||||
|
"highlights": "Highlights",
|
||||||
"filters": {
|
"filters": {
|
||||||
"all": "All Projects",
|
"all": "All Projects",
|
||||||
"backend": "Backend",
|
"backend": "Backend",
|
||||||
|
|||||||
@@ -85,8 +85,11 @@
|
|||||||
},
|
},
|
||||||
"Projects": {
|
"Projects": {
|
||||||
"badge": "Portofolio",
|
"badge": "Portofolio",
|
||||||
"title": "Proyek Unggulan",
|
"title": "Project Pilihan",
|
||||||
"subtitle": "Kumpulan solusi perangkat lunak dan sistem backend yang saya kembangkan untuk menjawab tantangan bisnis nyata.",
|
"subtitle": "Karya terbaik saya dalam pengembangan perangkat lunak",
|
||||||
|
"techStack": "Tech Stack",
|
||||||
|
"year": "Tahun",
|
||||||
|
"highlights": "Pencapaian",
|
||||||
"filters": {
|
"filters": {
|
||||||
"all": "Semua Proyek",
|
"all": "Semua Proyek",
|
||||||
"backend": "Backend",
|
"backend": "Backend",
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "projects" ADD COLUMN "highlights" TEXT[],
|
||||||
|
ADD COLUMN "techStack" TEXT[],
|
||||||
|
ADD COLUMN "year" INTEGER;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "projects" ADD COLUMN "image_urls" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||||
@@ -22,12 +22,17 @@ model Project {
|
|||||||
slug String @unique
|
slug String @unique
|
||||||
description String @db.Text
|
description String @db.Text
|
||||||
imageUrl String? @map("image_url")
|
imageUrl String? @map("image_url")
|
||||||
|
imageUrls String[] @default([]) @map("image_urls")
|
||||||
repoUrl String? @map("repo_url")
|
repoUrl String? @map("repo_url")
|
||||||
liveUrl String? @map("live_url")
|
liveUrl String? @map("live_url")
|
||||||
category String
|
category String
|
||||||
isPublished Boolean @default(false) @map("is_published")
|
isPublished Boolean @default(false) @map("is_published")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
techStack String[]
|
||||||
|
year Int?
|
||||||
|
highlights String[]
|
||||||
|
|
||||||
skills ProjectSkill[]
|
skills ProjectSkill[]
|
||||||
|
|
||||||
@@map("projects")
|
@@map("projects")
|
||||||
|
|||||||
BIN
public/uploads/1775214382728-Screenshot_2026-03-28_205042.png
Normal file
BIN
public/uploads/1775214382728-Screenshot_2026-03-28_205042.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 180 KiB |
BIN
public/uploads/1775214826581-Screenshot_2026-03-28_205042.png
Normal file
BIN
public/uploads/1775214826581-Screenshot_2026-03-28_205042.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 180 KiB |
@@ -24,7 +24,9 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
|
|||||||
repoUrl: formData.get("repoUrl") as string,
|
repoUrl: formData.get("repoUrl") as string,
|
||||||
liveUrl: formData.get("liveUrl") as string,
|
liveUrl: formData.get("liveUrl") as string,
|
||||||
isPublished: formData.get("isPublished") === "true" || formData.get("isPublished") === "on",
|
isPublished: formData.get("isPublished") === "true" || formData.get("isPublished") === "on",
|
||||||
image: formData.get("image") as File | null,
|
techStack: formData.get("techStack") as string,
|
||||||
|
year: formData.get("year") as string,
|
||||||
|
highlights: formData.get("highlights") as string,
|
||||||
};
|
};
|
||||||
|
|
||||||
const validation = projectSchema.safeParse(data);
|
const validation = projectSchema.safeParse(data);
|
||||||
@@ -32,16 +34,28 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
|
|||||||
return NextResponse.json({ success: false, message: validation.error.issues[0].message }, { status: 400 });
|
return NextResponse.json({ success: false, message: validation.error.issues[0].message }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
let imageUrl: string | undefined = undefined;
|
// Existing images that should be retained (sent back from client)
|
||||||
if (data.image && data.image.size > 0 && data.image.name) {
|
const existingImages = formData.getAll("existingImages") as string[];
|
||||||
try {
|
|
||||||
imageUrl = await uploadFileLocally(data.image);
|
// Handle new images upload
|
||||||
} catch (e: any) {
|
const imageFiles = formData.getAll("images") as File[];
|
||||||
console.error("Local upload error:", e);
|
const newUploadedUrls: string[] = [];
|
||||||
return NextResponse.json({ success: false, message: "Failed to upload image locally." }, { status: 500 });
|
|
||||||
|
for (const file of imageFiles) {
|
||||||
|
if (file && file.size > 0 && file.name) {
|
||||||
|
try {
|
||||||
|
const url = await uploadFileLocally(file);
|
||||||
|
newUploadedUrls.push(url);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Local upload error:", e);
|
||||||
|
return NextResponse.json({ success: false, message: "Failed to upload one or more images." }, { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Merge: retained existing + newly uploaded
|
||||||
|
const allImageUrls = [...existingImages, ...newUploadedUrls];
|
||||||
|
|
||||||
const project = await prisma.project.update({
|
const project = await prisma.project.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
@@ -52,7 +66,12 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
|
|||||||
repoUrl: validation.data.repoUrl || null,
|
repoUrl: validation.data.repoUrl || null,
|
||||||
liveUrl: validation.data.liveUrl || null,
|
liveUrl: validation.data.liveUrl || null,
|
||||||
isPublished: validation.data.isPublished,
|
isPublished: validation.data.isPublished,
|
||||||
...(imageUrl && { imageUrl }),
|
// First image is cover
|
||||||
|
...(allImageUrls.length > 0 && { imageUrl: allImageUrls[0] }),
|
||||||
|
imageUrls: allImageUrls,
|
||||||
|
techStack: validation.data.techStack ? validation.data.techStack.split(",").map(s => s.trim()).filter(s => s) : [],
|
||||||
|
year: validation.data.year || null,
|
||||||
|
highlights: validation.data.highlights ? validation.data.highlights.split(",").map(s => s.trim()).filter(s => s) : [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ export async function POST(req: Request) {
|
|||||||
repoUrl: formData.get("repoUrl") as string,
|
repoUrl: formData.get("repoUrl") as string,
|
||||||
liveUrl: formData.get("liveUrl") as string,
|
liveUrl: formData.get("liveUrl") as string,
|
||||||
isPublished: formData.get("isPublished") === "true" || formData.get("isPublished") === "on",
|
isPublished: formData.get("isPublished") === "true" || formData.get("isPublished") === "on",
|
||||||
image: formData.get("image") as File | null,
|
techStack: formData.get("techStack") as string,
|
||||||
|
year: formData.get("year") as string,
|
||||||
|
highlights: formData.get("highlights") as string,
|
||||||
};
|
};
|
||||||
|
|
||||||
const validation = projectSchema.safeParse(data);
|
const validation = projectSchema.safeParse(data);
|
||||||
@@ -30,13 +32,19 @@ export async function POST(req: Request) {
|
|||||||
return NextResponse.json({ success: false, message: validation.error.issues[0].message }, { status: 400 });
|
return NextResponse.json({ success: false, message: validation.error.issues[0].message }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
let imageUrl: string | undefined = undefined;
|
// Handle multiple images upload
|
||||||
if (data.image && data.image.size > 0 && data.image.name) {
|
const imageFiles = formData.getAll("images") as File[];
|
||||||
try {
|
const uploadedUrls: string[] = [];
|
||||||
imageUrl = await uploadFileLocally(data.image);
|
|
||||||
} catch (e: any) {
|
for (const file of imageFiles) {
|
||||||
console.error("Local upload error:", e);
|
if (file && file.size > 0 && file.name) {
|
||||||
return NextResponse.json({ success: false, message: "Failed to upload image locally." }, { status: 500 });
|
try {
|
||||||
|
const url = await uploadFileLocally(file);
|
||||||
|
uploadedUrls.push(url);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Local upload error:", e);
|
||||||
|
return NextResponse.json({ success: false, message: "Failed to upload one or more images." }, { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +57,12 @@ export async function POST(req: Request) {
|
|||||||
repoUrl: validation.data.repoUrl || null,
|
repoUrl: validation.data.repoUrl || null,
|
||||||
liveUrl: validation.data.liveUrl || null,
|
liveUrl: validation.data.liveUrl || null,
|
||||||
isPublished: validation.data.isPublished,
|
isPublished: validation.data.isPublished,
|
||||||
imageUrl: imageUrl,
|
// First image becomes the cover (backward compat)
|
||||||
|
imageUrl: uploadedUrls[0] || undefined,
|
||||||
|
imageUrls: uploadedUrls,
|
||||||
|
techStack: validation.data.techStack ? validation.data.techStack.split(",").map(s => s.trim()).filter(s => s) : [],
|
||||||
|
year: validation.data.year || null,
|
||||||
|
highlights: validation.data.highlights ? validation.data.highlights.split(",").map(s => s.trim()).filter(s => s) : [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ export async function createProjectAction(prevState: any, formData: FormData) {
|
|||||||
liveUrl: formData.get("liveUrl") as string,
|
liveUrl: formData.get("liveUrl") as string,
|
||||||
isPublished: formData.get("isPublished") === "on",
|
isPublished: formData.get("isPublished") === "on",
|
||||||
image: formData.get("image") as File | null,
|
image: formData.get("image") as File | null,
|
||||||
|
techStack: formData.get("techStack") as string,
|
||||||
|
year: formData.get("year") as string,
|
||||||
|
highlights: formData.get("highlights") as string,
|
||||||
};
|
};
|
||||||
|
|
||||||
const validation = projectSchema.safeParse(data);
|
const validation = projectSchema.safeParse(data);
|
||||||
@@ -51,6 +54,9 @@ export async function createProjectAction(prevState: any, formData: FormData) {
|
|||||||
liveUrl: validation.data.liveUrl || null,
|
liveUrl: validation.data.liveUrl || null,
|
||||||
isPublished: validation.data.isPublished,
|
isPublished: validation.data.isPublished,
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
|
techStack: validation.data.techStack ? validation.data.techStack.split(",").map(s => s.trim()).filter(s => s) : [],
|
||||||
|
year: validation.data.year || null,
|
||||||
|
highlights: validation.data.highlights ? validation.data.highlights.split(",").map(s => s.trim()).filter(s => s) : [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -80,6 +86,9 @@ export async function updateProjectAction(id: string, prevState: any, formData:
|
|||||||
liveUrl: formData.get("liveUrl") as string,
|
liveUrl: formData.get("liveUrl") as string,
|
||||||
isPublished: formData.get("isPublished") === "on",
|
isPublished: formData.get("isPublished") === "on",
|
||||||
image: formData.get("image") as File | null,
|
image: formData.get("image") as File | null,
|
||||||
|
techStack: formData.get("techStack") as string,
|
||||||
|
year: formData.get("year") as string,
|
||||||
|
highlights: formData.get("highlights") as string,
|
||||||
};
|
};
|
||||||
|
|
||||||
const validation = projectSchema.safeParse(data);
|
const validation = projectSchema.safeParse(data);
|
||||||
@@ -108,6 +117,9 @@ export async function updateProjectAction(id: string, prevState: any, formData:
|
|||||||
liveUrl: validation.data.liveUrl || null,
|
liveUrl: validation.data.liveUrl || null,
|
||||||
isPublished: validation.data.isPublished,
|
isPublished: validation.data.isPublished,
|
||||||
...(imageUrl && { imageUrl }), // only update image if a new one was uploaded
|
...(imageUrl && { imageUrl }), // only update image if a new one was uploaded
|
||||||
|
techStack: validation.data.techStack ? validation.data.techStack.split(",").map(s => s.trim()).filter(s => s) : [],
|
||||||
|
year: validation.data.year || null,
|
||||||
|
highlights: validation.data.highlights ? validation.data.highlights.split(",").map(s => s.trim()).filter(s => s) : [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
265
src/features/projects/project-detail-modal.tsx
Normal file
265
src/features/projects/project-detail-modal.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
CheckCircle2,
|
||||||
|
ExternalLink,
|
||||||
|
GitFork,
|
||||||
|
Calendar,
|
||||||
|
LayoutGrid,
|
||||||
|
Award,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Images,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export function ProjectDetailModal({ project, onClose }: { project: any; onClose: () => void }) {
|
||||||
|
const techStack: string[] = project?.techStack || [];
|
||||||
|
const highlights: string[] = project?.highlights || [];
|
||||||
|
|
||||||
|
// Build image gallery: prefer imageUrls array, fallback to single imageUrl
|
||||||
|
const images: string[] =
|
||||||
|
project?.imageUrls && project.imageUrls.length > 0
|
||||||
|
? project.imageUrls
|
||||||
|
: project?.imageUrl
|
||||||
|
? [project.imageUrl]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const [activeIdx, setActiveIdx] = useState(0);
|
||||||
|
|
||||||
|
function prev() {
|
||||||
|
setActiveIdx((i) => (i === 0 ? images.length - 1 : i - 1));
|
||||||
|
}
|
||||||
|
function next() {
|
||||||
|
setActiveIdx((i) => (i === images.length - 1 ? 0 : i + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-50 bg-black/70 backdrop-blur-md flex items-center justify-center p-4 lg:p-8"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
{/* Modal container — wide 2-col layout */}
|
||||||
|
<motion.div
|
||||||
|
className="relative w-full max-w-6xl bg-card border border-border/50 rounded-[2rem] shadow-2xl overflow-hidden flex flex-col lg:flex-row"
|
||||||
|
style={{ maxHeight: "92vh" }}
|
||||||
|
initial={{ scale: 0.95, opacity: 0, y: 20 }}
|
||||||
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||||
|
exit={{ scale: 0.95, opacity: 0, y: 20 }}
|
||||||
|
transition={{ type: "spring", stiffness: 280, damping: 28 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* ── Close button ── */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-4 right-4 z-30 p-2.5 bg-black/40 hover:bg-black/70 backdrop-blur-md rounded-full text-white transition-all duration-300 hover:scale-110 shadow-lg"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* ══════════════════════════════════════════
|
||||||
|
LEFT COLUMN — Details (scrollable)
|
||||||
|
══════════════════════════════════════════ */}
|
||||||
|
<div className="flex flex-col lg:w-[42%] overflow-y-auto custom-scrollbar shrink-0">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-7 pt-8 pb-5 border-b border-border/30">
|
||||||
|
<h2 className="text-2xl md:text-3xl font-extrabold tracking-tight text-foreground leading-tight pr-10">
|
||||||
|
{project.title}
|
||||||
|
</h2>
|
||||||
|
{project.year && (
|
||||||
|
<div className="flex items-center gap-2 mt-3 text-sm font-medium text-muted-foreground">
|
||||||
|
<Calendar size={14} className="text-accent" />
|
||||||
|
<span>{project.year}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable body */}
|
||||||
|
<div className="flex-1 px-7 py-6 space-y-7">
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tech Stack */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<LayoutGrid size={15} className="text-accent" />
|
||||||
|
<h4 className="text-sm font-bold uppercase tracking-wider text-muted-foreground">Tech Stack</h4>
|
||||||
|
</div>
|
||||||
|
{techStack.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{techStack.map((tech: string) => (
|
||||||
|
<span
|
||||||
|
key={tech}
|
||||||
|
className="px-3 py-1 rounded-lg text-xs font-semibold bg-accent/8 text-accent border border-accent/15 hover:border-accent/40 hover:bg-accent/15 transition-colors"
|
||||||
|
>
|
||||||
|
{tech}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground italic">Belum ada tech stack.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Highlights */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Award size={15} className="text-emerald-500" />
|
||||||
|
<h4 className="text-sm font-bold uppercase tracking-wider text-muted-foreground">Pencapaian</h4>
|
||||||
|
</div>
|
||||||
|
{highlights.length > 0 ? (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{highlights.map((item: string, i: number) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="flex items-start gap-2.5 p-2.5 rounded-lg bg-emerald-500/5 border border-emerald-500/10 hover:bg-emerald-500/10 transition-colors"
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={14} className="text-emerald-500 mt-0.5 shrink-0" />
|
||||||
|
<span className="text-xs font-medium text-foreground/80">{item}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground italic">Belum ada data pencapaian.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons — sticky at bottom */}
|
||||||
|
<div className="px-7 py-5 border-t border-border/30 flex flex-wrap gap-3 bg-muted/10 shrink-0">
|
||||||
|
{project.liveUrl && (
|
||||||
|
<a
|
||||||
|
href={project.liveUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex-1 min-w-[120px] flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl bg-accent text-accent-foreground text-sm font-semibold shadow-lg shadow-accent/20 hover:shadow-xl hover:shadow-accent/40 hover:-translate-y-0.5 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<ExternalLink size={15} /> Kunjungi Web
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{project.repoUrl && (
|
||||||
|
<a
|
||||||
|
href={project.repoUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex-1 min-w-[120px] flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl bg-card border border-border/50 hover:border-border hover:bg-muted text-foreground text-sm font-semibold transition-all duration-300"
|
||||||
|
>
|
||||||
|
<GitFork size={15} /> Source Code
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{!project.liveUrl && !project.repoUrl && (
|
||||||
|
<p className="text-xs text-muted-foreground italic">Tidak ada link yang tersedia.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ══════════════════════════════════════════
|
||||||
|
RIGHT COLUMN — Image Gallery
|
||||||
|
══════════════════════════════════════════ */}
|
||||||
|
<div className="flex-1 flex flex-col bg-black/20 min-h-[300px] lg:min-h-0 border-t lg:border-t-0 lg:border-l border-border/20">
|
||||||
|
{images.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{/* Main image viewer */}
|
||||||
|
<div className="relative flex-1 flex items-center justify-center overflow-hidden bg-black/30">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.img
|
||||||
|
key={activeIdx}
|
||||||
|
src={images[activeIdx]}
|
||||||
|
alt={`${project.title} — image ${activeIdx + 1}`}
|
||||||
|
className="max-w-full max-h-full object-contain select-none"
|
||||||
|
style={{ maxHeight: "calc(92vh - 120px)" }}
|
||||||
|
initial={{ opacity: 0, x: 30 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -30 }}
|
||||||
|
transition={{ duration: 0.25 }}
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Arrow navigation (only when multiple images) */}
|
||||||
|
{images.length > 1 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={prev}
|
||||||
|
className="absolute left-3 p-2.5 bg-black/50 hover:bg-black/80 rounded-full text-white transition-all hover:scale-110 backdrop-blur-sm shadow-lg"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={20} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={next}
|
||||||
|
className="absolute right-3 p-2.5 bg-black/50 hover:bg-black/80 rounded-full text-white transition-all hover:scale-110 backdrop-blur-sm shadow-lg"
|
||||||
|
>
|
||||||
|
<ChevronRight size={20} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Image counter badge */}
|
||||||
|
{images.length > 1 && (
|
||||||
|
<div className="absolute top-3 left-3 flex items-center gap-1.5 px-2.5 py-1 bg-black/50 backdrop-blur-sm rounded-full text-white text-xs font-semibold">
|
||||||
|
<Images size={12} />
|
||||||
|
{activeIdx + 1} / {images.length}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thumbnail strip (only when multiple images) */}
|
||||||
|
{images.length > 1 && (
|
||||||
|
<div className="flex gap-2 p-3 overflow-x-auto bg-black/40 shrink-0 custom-scrollbar">
|
||||||
|
{images.map((url, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setActiveIdx(i)}
|
||||||
|
className={`shrink-0 w-16 h-12 rounded-lg overflow-hidden border-2 transition-all hover:scale-105 ${
|
||||||
|
i === activeIdx
|
||||||
|
? "border-accent shadow-lg shadow-accent/30"
|
||||||
|
: "border-white/10 opacity-60 hover:opacity-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<img src={url} alt={`thumb-${i}`} className="w-full h-full object-cover" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dot indicators */}
|
||||||
|
{images.length > 1 && (
|
||||||
|
<div className="flex justify-center gap-1.5 py-2 bg-black/40 shrink-0">
|
||||||
|
{images.map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setActiveIdx(i)}
|
||||||
|
className={`rounded-full transition-all ${
|
||||||
|
i === activeIdx
|
||||||
|
? "w-5 h-2 bg-accent"
|
||||||
|
: "w-2 h-2 bg-white/30 hover:bg-white/60"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
/* No image placeholder */
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center gap-4 text-muted-foreground/40 p-10">
|
||||||
|
<LayoutGrid size={60} strokeWidth={1} />
|
||||||
|
<p className="text-sm font-medium">Belum ada gambar</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,13 +3,46 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { createProjectAction, updateProjectAction } from "./actions";
|
import { createProjectAction, updateProjectAction } from "./actions";
|
||||||
import { useRouter } from "@/i18n/routing";
|
import { useRouter } from "@/i18n/routing";
|
||||||
import { Loader2, Upload, PlusCircle, ArrowLeft, Save } from "lucide-react";
|
import { Loader2, Upload, PlusCircle, ArrowLeft, Save, X, ImagePlus } from "lucide-react";
|
||||||
|
|
||||||
export function ProjectForm({ initialData, projectId }: { initialData?: any; projectId?: string }) {
|
export function ProjectForm({ initialData, projectId }: { initialData?: any; projectId?: string }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [preview, setPreview] = useState<string | null>(initialData?.imageUrl || null);
|
|
||||||
|
// Multiple images state
|
||||||
|
// existingPreviews: URLs of images already saved (for edit mode)
|
||||||
|
// newPreviews: blob URLs of newly selected files
|
||||||
|
const [existingPreviews, setExistingPreviews] = useState<string[]>(
|
||||||
|
initialData?.imageUrls && initialData.imageUrls.length > 0
|
||||||
|
? initialData.imageUrls
|
||||||
|
: initialData?.imageUrl
|
||||||
|
? [initialData.imageUrl]
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
const [newFiles, setNewFiles] = useState<File[]>([]);
|
||||||
|
const [newPreviews, setNewPreviews] = useState<string[]>([]);
|
||||||
|
|
||||||
|
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const files = Array.from(e.target.files || []);
|
||||||
|
const previews = files.map((f) => URL.createObjectURL(f));
|
||||||
|
setNewFiles((prev) => [...prev, ...files]);
|
||||||
|
setNewPreviews((prev) => [...prev, ...previews]);
|
||||||
|
// Reset input so same files can be re-selected
|
||||||
|
e.target.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeExistingImage(idx: number) {
|
||||||
|
setExistingPreviews((prev) => prev.filter((_, i) => i !== idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeNewImage(idx: number) {
|
||||||
|
URL.revokeObjectURL(newPreviews[idx]);
|
||||||
|
setNewFiles((prev) => prev.filter((_, i) => i !== idx));
|
||||||
|
setNewPreviews((prev) => prev.filter((_, i) => i !== idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
const allPreviews = [...existingPreviews, ...newPreviews];
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -18,14 +51,20 @@ export function ProjectForm({ initialData, projectId }: { initialData?: any; pro
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData(e.currentTarget);
|
const formData = new FormData(e.currentTarget);
|
||||||
|
|
||||||
|
// Remove the placeholder file inputs, we'll add manually
|
||||||
|
formData.delete("images");
|
||||||
|
|
||||||
|
// Append existing image URLs that were kept
|
||||||
|
existingPreviews.forEach((url) => formData.append("existingImages", url));
|
||||||
|
|
||||||
|
// Append new file objects
|
||||||
|
newFiles.forEach((file) => formData.append("images", file));
|
||||||
|
|
||||||
const url = projectId ? `/api/admin/projects/${projectId}` : "/api/admin/projects";
|
const url = projectId ? `/api/admin/projects/${projectId}` : "/api/admin/projects";
|
||||||
const method = projectId ? "PUT" : "POST";
|
const method = projectId ? "PUT" : "POST";
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, { method, body: formData });
|
||||||
method,
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (!response.ok || !result.success) {
|
if (!response.ok || !result.success) {
|
||||||
@@ -145,27 +184,112 @@ export function ProjectForm({ initialData, projectId }: { initialData?: any; pro
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
|
<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="w-full px-4 py-3 rounded-xl bg-muted/30 border border-border text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 transition-all font-mono"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">Pisahkan setiap teknologi dengan koma</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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={new Date().getFullYear().toString()}
|
||||||
|
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-accent/50 transition-all font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-semibold">Cover Image</label>
|
<label className="text-sm font-semibold">Target & Pencapaian</label>
|
||||||
<label className="flex flex-col items-center justify-center w-full h-40 rounded-xl border-2 border-dashed border-border hover:border-accent/50 bg-muted/10 hover:bg-muted/30 cursor-pointer transition-colors relative overflow-hidden">
|
<textarea
|
||||||
{preview ? (
|
name="highlights"
|
||||||
<img src={preview} alt="Preview" className="w-full h-full object-cover opacity-80" />
|
defaultValue={initialData?.highlights?.join(", ") || ""}
|
||||||
) : (
|
rows={3}
|
||||||
<div className="flex flex-col items-center text-muted-foreground">
|
placeholder="Reduce API latency 40%, Handle 10K daily users, 99.9% uptime"
|
||||||
<Upload size={24} className="mb-2" />
|
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-accent/50 transition-all font-mono"
|
||||||
<span className="text-sm font-medium">Click to upload image</span>
|
/>
|
||||||
<span className="text-xs mt-1 opacity-75">PNG, JPG, WEBP recommended</span>
|
<p className="text-xs text-muted-foreground">Pisahkan setiap pencapaian dengan koma</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{/* --- Multiple Image Upload --- */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm font-semibold">Project Images</label>
|
||||||
|
<span className="text-xs text-muted-foreground">{allPreviews.length} gambar dipilih</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid preview */}
|
||||||
|
{allPreviews.length > 0 && (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||||
|
{/* Existing images */}
|
||||||
|
{existingPreviews.map((url, idx) => (
|
||||||
|
<div key={`existing-${idx}`} className="relative group rounded-xl overflow-hidden border border-border aspect-video bg-muted/20">
|
||||||
|
<img src={url} alt={`Image ${idx + 1}`} className="w-full h-full object-cover" />
|
||||||
|
{idx === 0 && (
|
||||||
|
<span className="absolute top-1.5 left-1.5 text-[10px] font-bold bg-accent text-accent-foreground px-2 py-0.5 rounded-full">
|
||||||
|
Cover
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeExistingImage(idx)}
|
||||||
|
className="absolute top-1.5 right-1.5 p-1 bg-black/60 hover:bg-red-600 rounded-full text-white opacity-0 group-hover:opacity-100 transition-all"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* New images */}
|
||||||
|
{newPreviews.map((url, idx) => (
|
||||||
|
<div key={`new-${idx}`} className="relative group rounded-xl overflow-hidden border border-accent/30 aspect-video bg-muted/20">
|
||||||
|
<img src={url} alt={`New ${idx + 1}`} className="w-full h-full object-cover" />
|
||||||
|
{existingPreviews.length === 0 && idx === 0 && (
|
||||||
|
<span className="absolute top-1.5 left-1.5 text-[10px] font-bold bg-accent text-accent-foreground px-2 py-0.5 rounded-full">
|
||||||
|
Cover
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="absolute bottom-1.5 left-1.5 text-[10px] bg-black/50 text-white px-1.5 py-0.5 rounded">
|
||||||
|
Baru
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeNewImage(idx)}
|
||||||
|
className="absolute top-1.5 right-1.5 p-1 bg-black/60 hover:bg-red-600 rounded-full text-white opacity-0 group-hover:opacity-100 transition-all"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Upload trigger */}
|
||||||
|
<label className="flex items-center justify-center gap-3 w-full py-4 rounded-xl border-2 border-dashed border-border hover:border-accent/50 bg-muted/10 hover:bg-muted/30 cursor-pointer transition-colors">
|
||||||
|
<ImagePlus size={20} className="text-muted-foreground" />
|
||||||
|
<div className="text-center">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
{allPreviews.length > 0 ? "Tambah lebih banyak gambar" : "Klik untuk upload gambar"}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-muted-foreground/60 mt-0.5">PNG, JPG, WEBP · Bisa pilih banyak sekaligus</p>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
name="image"
|
name="images"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => {
|
onChange={handleFileSelect}
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) setPreview(URL.createObjectURL(file));
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export const projectSchema = z.object({
|
|||||||
.any()
|
.any()
|
||||||
.refine((file) => !file || file?.size === 0 || file?.name, "Invalid file format")
|
.refine((file) => !file || file?.size === 0 || file?.name, "Invalid file format")
|
||||||
.optional(),
|
.optional(),
|
||||||
|
techStack: z.string().optional(),
|
||||||
|
year: z.coerce.number().optional(),
|
||||||
|
highlights: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ProjectFormValues = z.infer<typeof projectSchema>;
|
export type ProjectFormValues = z.infer<typeof projectSchema>;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
|||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
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 { ProjectDetailModal } from "./project-detail-modal";
|
||||||
import {
|
import {
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
GitFork,
|
GitFork,
|
||||||
@@ -18,6 +19,7 @@ import { useTranslations } from "next-intl";
|
|||||||
export function ProjectsSection({ initialProjects }: { initialProjects?: any[] }) {
|
export function ProjectsSection({ initialProjects }: { initialProjects?: any[] }) {
|
||||||
const t = useTranslations("Projects");
|
const t = useTranslations("Projects");
|
||||||
const [activeFilter, setActiveFilter] = useState("all");
|
const [activeFilter, setActiveFilter] = useState("all");
|
||||||
|
const [selectedProject, setSelectedProject] = useState<any | null>(null);
|
||||||
|
|
||||||
// Use initialProjects from DB or fallback to empty array
|
// Use initialProjects from DB or fallback to empty array
|
||||||
const projects = initialProjects || [];
|
const projects = initialProjects || [];
|
||||||
@@ -93,7 +95,10 @@ export function ProjectsSection({ initialProjects }: { initialProjects?: any[] }
|
|||||||
exit={{ opacity: 0, scale: 0.9 }}
|
exit={{ opacity: 0, scale: 0.9 }}
|
||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
>
|
>
|
||||||
<div className="project-card group relative h-full flex flex-col p-6 rounded-2xl bg-card border border-border/50 hover:border-accent/30 transition-all duration-500">
|
<div
|
||||||
|
className="project-card group relative h-full flex flex-col p-6 rounded-2xl bg-card border border-border/50 hover:border-accent/30 transition-all duration-500 cursor-pointer"
|
||||||
|
onClick={() => setSelectedProject(project)}
|
||||||
|
>
|
||||||
{/* Category badge */}
|
{/* Category badge */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<span
|
<span
|
||||||
@@ -165,22 +170,50 @@ export function ProjectsSection({ initialProjects }: { initialProjects?: any[] }
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tags (Using generic category slice due to PRISMA missing tags yet) */}
|
{/* Tech Stack Badges */}
|
||||||
<div className="flex flex-wrap gap-1.5 mt-auto">
|
<div className="flex flex-wrap gap-1.5 mt-auto">
|
||||||
{(project.tags || [project.category.split(" ")[0]]).map((tag: string) => (
|
{project.techStack?.length > 0 ? (
|
||||||
<span
|
project.techStack.map((tech: string) => (
|
||||||
key={tag}
|
<span
|
||||||
className="px-2 py-1 rounded-md text-[10px] font-mono bg-muted/50 text-muted-foreground border border-border/30"
|
key={tech}
|
||||||
>
|
className="px-2 py-1 rounded-md text-[10px] font-mono bg-accent/10 text-accent border border-accent/20"
|
||||||
{tag}
|
>
|
||||||
</span>
|
{tech}
|
||||||
))}
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
(project.tags || [project.category.split(" ")[0]]).map((tag: string) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="px-2 py-1 rounded-md text-[10px] font-mono bg-muted/50 text-muted-foreground border border-border/30"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Year */}
|
||||||
|
{project.year && (
|
||||||
|
<div className="absolute top-6 right-6 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<span className="px-2 py-1 rounded-lg bg-card/80 backdrop-blur-sm border border-border text-[10px] font-mono text-muted-foreground shadow-sm">
|
||||||
|
{project.year}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Detail Modal */}
|
||||||
|
{selectedProject && (
|
||||||
|
<ProjectDetailModal
|
||||||
|
project={selectedProject}
|
||||||
|
onClose={() => setSelectedProject(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
8
test-db.ts
Normal file
8
test-db.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const projects = await prisma.project.findMany({ select: { title: true, techStack: true, year: true, highlights: true }})
|
||||||
|
console.log(JSON.stringify(projects, null, 2))
|
||||||
|
}
|
||||||
|
main().catch(console.error).finally(() => prisma.$disconnect())
|
||||||
Reference in New Issue
Block a user