feat: implement portfolio dashboard with skill, experience, and message management features

This commit is contained in:
Yolando
2026-04-03 17:02:54 +07:00
parent ef6b44604a
commit e0f6e4bd8b
34 changed files with 2128 additions and 435 deletions

View File

@@ -0,0 +1,165 @@
"use client";
import { useState } from "react";
import { createSkillAction, updateSkillAction } from "./actions";
import { useRouter } from "@/i18n/routing";
import { Loader2, ArrowLeft, Save, PlusCircle } from "lucide-react";
const CATEGORIES = [
{ value: "backend", label: "Enterprise Backend" },
{ value: "infra", label: "Database & Infrastructure" },
{ value: "frontend", label: "Frontend Development" },
{ value: "mobile", label: "Mobile Development" },
];
const DEVICON_SUGGESTIONS = [
"java", "spring", "oracle", "docker", "postgresql", "redis", "kubernetes",
"react", "nextjs", "typescript", "tailwindcss", "angular", "flutter", "swift",
"kafka", "nginx", "jenkins", "github",
];
export function SkillForm({ initialData, skillId }: { initialData?: any; skillId?: string }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [iconPreview, setIconPreview] = useState(initialData?.iconName || "");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setLoading(true);
const formData = new FormData(e.currentTarget);
let result;
if (skillId) {
result = await updateSkillAction(skillId, null, formData);
} else {
result = await createSkillAction(null, formData);
}
if (!result.success) {
setError(result.message || "An error occurred");
setLoading(false);
} else {
router.push("/admin/dashboard/skills");
router.refresh();
}
}
return (
<div className="max-w-2xl mx-auto p-6 lg:p-10 rounded-3xl bg-card border border-border shadow-sm relative overflow-hidden">
<div className="absolute top-0 inset-x-0 h-1 bg-gradient-to-r from-emerald-500 to-teal-500" />
<div className="mb-8 flex items-center gap-4">
<button
type="button"
onClick={() => router.back()}
className="p-2 rounded-full hover:bg-muted text-muted-foreground transition-colors"
>
<ArrowLeft size={20} />
</button>
<div>
<h1 className="text-2xl font-bold tracking-tight">
{skillId ? "Edit Skill" : "Add New Skill"}
</h1>
<p className="text-sm text-muted-foreground">
{skillId ? "Update tech stack item" : "Add a new technology to your arsenal"}
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-500 text-sm font-medium">
{error}
</div>
)}
<div className="space-y-2">
<label className="text-sm font-semibold">Skill Name</label>
<input
name="name"
required
defaultValue={initialData?.name}
placeholder="e.g. Spring Boot"
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="space-y-2">
<label className="text-sm font-semibold">Category</label>
<select
name="category"
required
defaultValue={initialData?.category || "backend"}
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 cursor-pointer"
>
{CATEGORIES.map((cat) => (
<option key={cat.value} value={cat.value}>
{cat.label}
</option>
))}
</select>
</div>
<div className="space-y-3">
<label className="text-sm font-semibold">
Devicon Slug <span className="text-muted-foreground font-normal">(opsional untuk ikon di Hero)</span>
</label>
<input
name="iconName"
defaultValue={initialData?.iconName || ""}
placeholder="e.g. spring, java, docker"
onChange={(e) => setIconPreview(e.target.value.trim())}
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"
/>
{/* Icon Preview */}
{iconPreview && (
<div className="flex items-center gap-3 p-3 rounded-xl border border-border bg-muted/20">
<img
src={`https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/${iconPreview}/${iconPreview}-original.svg`}
alt={iconPreview}
className="w-10 h-10 object-contain"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
<span className="text-xs text-muted-foreground font-mono">{iconPreview}</span>
</div>
)}
{/* Quick suggestions */}
<div className="flex flex-wrap gap-2">
{DEVICON_SUGGESTIONS.map((s) => (
<button
key={s}
type="button"
onClick={() => {
setIconPreview(s);
const input = document.querySelector('input[name="iconName"]') as HTMLInputElement;
if (input) input.value = s;
}}
className="px-2 py-1 text-[11px] font-mono rounded-lg bg-muted/50 border border-border hover:border-emerald-500/50 hover:bg-emerald-500/10 transition-colors"
>
{s}
</button>
))}
</div>
</div>
<div className="pt-6 border-t border-border/50 flex justify-end">
<button
type="submit"
disabled={loading}
className="flex items-center gap-2 px-8 py-3 rounded-xl bg-foreground text-background font-semibold text-sm hover:opacity-90 transition-all disabled:opacity-50 hover:scale-[1.02] shadow-md"
>
{loading ? (
<Loader2 size={18} className="animate-spin" />
) : skillId ? (
<><Save size={18} /> Update Skill</>
) : (
<><PlusCircle size={18} /> Save Skill</>
)}
</button>
</div>
</form>
</div>
);
}