feat: implement project showcase section with database integration, filtering, and detail modals

This commit is contained in:
Yolando
2026-04-03 18:29:10 +07:00
parent e511a50021
commit 9554674c79
16 changed files with 550 additions and 391 deletions

View File

@@ -21,6 +21,9 @@ export async function createProjectAction(prevState: any, formData: FormData) {
liveUrl: formData.get("liveUrl") as string,
isPublished: 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);
@@ -51,12 +54,15 @@ export async function createProjectAction(prevState: any, formData: FormData) {
liveUrl: validation.data.liveUrl || null,
isPublished: validation.data.isPublished,
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) : [],
},
});
revalidatePath("/admin/dashboard");
revalidatePath("/");
return { success: true };
} catch (error: any) {
console.error("DB Error:", error);
@@ -80,6 +86,9 @@ export async function updateProjectAction(id: string, prevState: any, formData:
liveUrl: formData.get("liveUrl") as string,
isPublished: 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);
@@ -108,13 +117,16 @@ export async function updateProjectAction(id: string, prevState: any, formData:
liveUrl: validation.data.liveUrl || null,
isPublished: validation.data.isPublished,
...(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) : [],
},
});
revalidatePath("/admin/dashboard");
revalidatePath("/admin/dashboard/projects");
revalidatePath("/");
return { success: true };
} catch (error: any) {
if (error?.code === "P2002") {

View 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>
);
}

View File

@@ -3,13 +3,46 @@
import { useState } from "react";
import { createProjectAction, updateProjectAction } from "./actions";
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 }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
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>) {
e.preventDefault();
@@ -18,14 +51,20 @@ export function ProjectForm({ initialData, projectId }: { initialData?: any; pro
try {
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 method = projectId ? "PUT" : "POST";
const response = await fetch(url, {
method,
body: formData,
});
const response = await fetch(url, { method, body: formData });
const result = await response.json();
if (!response.ok || !result.success) {
@@ -145,27 +184,112 @@ export function ProjectForm({ initialData, projectId }: { initialData?: any; pro
</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">
<label className="text-sm font-semibold">Cover Image</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">
{preview ? (
<img src={preview} alt="Preview" className="w-full h-full object-cover opacity-80" />
) : (
<div className="flex flex-col items-center text-muted-foreground">
<Upload size={24} className="mb-2" />
<span className="text-sm font-medium">Click to upload image</span>
<span className="text-xs mt-1 opacity-75">PNG, JPG, WEBP recommended</span>
</div>
)}
<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="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 pencapaian dengan koma</p>
</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
type="file"
name="image"
name="images"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) setPreview(URL.createObjectURL(file));
}}
onChange={handleFileSelect}
/>
</label>
</div>

View File

@@ -14,6 +14,9 @@ export const projectSchema = z.object({
.any()
.refine((file) => !file || file?.size === 0 || file?.name, "Invalid file format")
.optional(),
techStack: z.string().optional(),
year: z.coerce.number().optional(),
highlights: z.string().optional(),
});
export type ProjectFormValues = z.infer<typeof projectSchema>;

View File

@@ -4,6 +4,7 @@ import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { AnimatedSection } from "@/shared/components/animated-section";
import { SectionHeading } from "@/shared/components/section-heading";
import { ProjectDetailModal } from "./project-detail-modal";
import {
ExternalLink,
GitFork,
@@ -18,6 +19,7 @@ import { useTranslations } from "next-intl";
export function ProjectsSection({ initialProjects }: { initialProjects?: any[] }) {
const t = useTranslations("Projects");
const [activeFilter, setActiveFilter] = useState("all");
const [selectedProject, setSelectedProject] = useState<any | null>(null);
// Use initialProjects from DB or fallback to empty array
const projects = initialProjects || [];
@@ -93,7 +95,10 @@ export function ProjectsSection({ initialProjects }: { initialProjects?: any[] }
exit={{ opacity: 0, scale: 0.9 }}
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 */}
<div className="flex items-center justify-between mb-4">
<span
@@ -165,22 +170,50 @@ export function ProjectsSection({ initialProjects }: { initialProjects?: any[] }
</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">
{(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>
))}
{project.techStack?.length > 0 ? (
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>
))
) : (
(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>
{/* 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>
</motion.div>
))}
</AnimatePresence>
</motion.div>
{/* Detail Modal */}
{selectedProject && (
<ProjectDetailModal
project={selectedProject}
onClose={() => setSelectedProject(null)}
/>
)}
</>
)}
</div>