feat: implement portfolio dashboard with skill, experience, and message management features
This commit is contained in:
165
src/features/skills/skill-form.tsx
Normal file
165
src/features/skills/skill-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user