166 lines
6.3 KiB
TypeScript
166 lines
6.3 KiB
TypeScript
"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>
|
|
);
|
|
}
|