feat: Add dashboard for managing portfolio content (blog, experience, projects) and restructure public portfolio routes.
This commit is contained in:
55
src/components/dashboard/DeleteButton.tsx
Normal file
55
src/components/dashboard/DeleteButton.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Trash2, Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DeleteButtonProps {
|
||||
id: string;
|
||||
onDelete: (id: string) => Promise<any>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DeleteButton({ id, onDelete, className }: DeleteButtonProps) {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm) {
|
||||
setConfirm(true);
|
||||
setTimeout(() => setConfirm(false), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await onDelete(id);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Failed to delete.");
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className={cn(
|
||||
"p-2 rounded-lg transition-all flex items-center gap-2",
|
||||
confirm
|
||||
? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
: "text-foreground-muted hover:text-destructive hover:bg-destructive/10",
|
||||
className
|
||||
)}
|
||||
title={confirm ? "Click again to confirm" : "Delete"}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Trash2 size={16} />
|
||||
)}
|
||||
{confirm && <span className="text-[10px] font-bold uppercase">Confirm?</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
252
src/components/dashboard/blog/BlogForm.tsx
Normal file
252
src/components/dashboard/blog/BlogForm.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { BlogStatus } from "@prisma/client";
|
||||
import { createBlog, updateBlog } from "@/lib/actions/blog";
|
||||
import {
|
||||
Save,
|
||||
X,
|
||||
Plus,
|
||||
Trash2,
|
||||
ChevronLeft,
|
||||
Layout,
|
||||
Type,
|
||||
FileText,
|
||||
Tag as TagIcon,
|
||||
Globe,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface BlogFormProps {
|
||||
initialData?: any;
|
||||
}
|
||||
|
||||
export function BlogForm({ initialData }: BlogFormProps) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tags, setTags] = useState<string[]>(
|
||||
initialData?.tags?.map((t: any) => t.name) || [],
|
||||
);
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const data = {
|
||||
title: formData.get("title") as string,
|
||||
slug: formData.get("slug") as string,
|
||||
content: formData.get("content") as string,
|
||||
excerpt: formData.get("excerpt") as string,
|
||||
status: formData.get("status") as BlogStatus,
|
||||
tags: tags,
|
||||
};
|
||||
|
||||
if (initialData) {
|
||||
await updateBlog(initialData.id, data);
|
||||
} else {
|
||||
await createBlog(data);
|
||||
}
|
||||
|
||||
router.push("/dashboard/blog");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Something went wrong. Check the console.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const addTag = () => {
|
||||
if (tagInput && !tags.includes(tagInput)) {
|
||||
setTags([...tags, tagInput]);
|
||||
setTagInput("");
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (tagToRemove: string) => {
|
||||
setTags(tags.filter((t) => t !== tagToRemove));
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-8 max-w-5xl mx-auto pb-20">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-border pb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/dashboard/blog"
|
||||
className="p-2 hover:bg-surface-elevated rounded-lg text-foreground-muted transition-colors"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-display font-bold text-foreground">
|
||||
{initialData ? "Edit Post" : "Create New Post"}
|
||||
</h1>
|
||||
<p className="text-sm text-foreground-muted">
|
||||
{initialData ? "Make changes to your article." : "Draft a new masterpiece."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="px-4 py-2 border border-border bg-surface text-foreground font-medium rounded-lg hover:bg-surface-elevated transition-all text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="inline-flex items-center gap-2 px-6 py-2 bg-primary text-primary-foreground font-semibold rounded-lg hover:shadow-lg hover:shadow-primary/20 transition-all disabled:opacity-50 text-sm"
|
||||
>
|
||||
<Save size={18} />
|
||||
{loading ? "Saving..." : initialData ? "Update Changes" : "Publish Post"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="bg-surface border border-border/50 rounded-2xl p-6 space-y-6 card-shadow">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-foreground flex items-center gap-2">
|
||||
<Type size={16} className="text-primary/60" />
|
||||
Post Title
|
||||
</label>
|
||||
<input
|
||||
name="title"
|
||||
defaultValue={initialData?.title}
|
||||
placeholder="Enter a catchy title..."
|
||||
required
|
||||
className="w-full px-4 py-3 bg-background border border-border/50 rounded-xl focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all text-lg font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-foreground flex items-center gap-2">
|
||||
<Globe size={16} className="text-primary/60" />
|
||||
Slug URL
|
||||
</label>
|
||||
<div className="flex">
|
||||
<span className="inline-flex items-center px-3 rounded-l-xl border border-r-0 border-border/50 bg-surface-elevated text-foreground-muted text-sm italic">
|
||||
/blog/
|
||||
</span>
|
||||
<input
|
||||
name="slug"
|
||||
defaultValue={initialData?.slug}
|
||||
placeholder="post-slug-here"
|
||||
required
|
||||
className="flex-1 px-4 py-2.5 bg-background border border-border/50 rounded-r-xl focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-foreground flex items-center gap-2">
|
||||
<FileText size={16} className="text-primary/60" />
|
||||
Content (Markdown supported)
|
||||
</label>
|
||||
<textarea
|
||||
name="content"
|
||||
defaultValue={initialData?.content}
|
||||
placeholder="Write your story here..."
|
||||
required
|
||||
rows={15}
|
||||
className="w-full px-4 py-3 bg-background border border-border/50 rounded-xl focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all text-sm font-mono leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar / Metadata */}
|
||||
<div className="space-y-6">
|
||||
{/* Status & Settings */}
|
||||
<div className="bg-surface border border-border/50 rounded-2xl p-6 space-y-6 card-shadow">
|
||||
<h3 className="text-sm font-bold uppercase tracking-widest text-foreground-muted">Publishing</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-foreground">Status</label>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={initialData?.status || BlogStatus.DRAFT}
|
||||
className="w-full px-4 py-2.5 bg-background border border-border/50 rounded-xl focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all text-sm"
|
||||
>
|
||||
<option value={BlogStatus.DRAFT}>Draft</option>
|
||||
<option value={BlogStatus.PUBLISHED}>Published</option>
|
||||
<option value={BlogStatus.SCHEDULED}>Scheduled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-2 border-t border-border/50">
|
||||
<label className="text-sm font-semibold text-foreground flex items-center gap-2">
|
||||
<Layout size={16} className="text-primary/60" />
|
||||
Excerpt
|
||||
</label>
|
||||
<textarea
|
||||
name="excerpt"
|
||||
defaultValue={initialData?.excerpt}
|
||||
placeholder="Brief summary of the post..."
|
||||
rows={4}
|
||||
className="w-full px-4 py-3 bg-background border border-border/50 rounded-xl focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all text-sm leading-snug"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="bg-surface border border-border/50 rounded-2xl p-6 space-y-4 card-shadow">
|
||||
<h3 className="text-sm font-bold uppercase tracking-widest text-foreground-muted flex items-center gap-2">
|
||||
<TagIcon size={14} />
|
||||
Tags
|
||||
</h3>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addTag())}
|
||||
placeholder="Add tag..."
|
||||
className="flex-1 px-3 py-2 bg-background border border-border/50 rounded-lg text-xs outline-none focus:border-primary"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addTag}
|
||||
className="p-2 bg-primary/10 text-primary rounded-lg hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-surface-elevated border border-border/50 rounded-md text-[10px] font-bold text-foreground"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTag(tag)}
|
||||
className="p-0.5 hover:text-destructive transition-colors"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{tags.length === 0 && (
|
||||
<p className="text-xs text-foreground-muted italic">No tags added yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
327
src/components/dashboard/experience/ExperienceForm.tsx
Normal file
327
src/components/dashboard/experience/ExperienceForm.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createExperience, updateExperience } from "@/lib/actions/experience";
|
||||
import {
|
||||
Save,
|
||||
ChevronLeft,
|
||||
Briefcase,
|
||||
Layers,
|
||||
Building2,
|
||||
Calendar,
|
||||
Layers as TechIcon,
|
||||
Trash2,
|
||||
Plus,
|
||||
X,
|
||||
ListIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ExperienceFormProps {
|
||||
initialData?: any;
|
||||
}
|
||||
|
||||
export function ExperienceForm({ initialData }: ExperienceFormProps) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [highlights, setHighlights] = useState<string[]>(
|
||||
initialData?.highlights || [],
|
||||
);
|
||||
const [highlightInput, setHighlightInput] = useState("");
|
||||
const [techStack, setTechStack] = useState<string[]>(
|
||||
initialData?.techStack || [],
|
||||
);
|
||||
const [techInput, setTechInput] = useState("");
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const data = {
|
||||
company: formData.get("company") as string,
|
||||
role: formData.get("role") as string,
|
||||
startDate: new Date(formData.get("startDate") as string),
|
||||
endDate: formData.get("endDate") ? new Date(formData.get("endDate") as string) : null,
|
||||
current: formData.get("current") === "on",
|
||||
description: formData.get("description") as string,
|
||||
highlights: highlights,
|
||||
techStack: techStack,
|
||||
order: parseInt(formData.get("order") as string) || 0,
|
||||
};
|
||||
|
||||
if (initialData) {
|
||||
await updateExperience(initialData.id, data);
|
||||
} else {
|
||||
await createExperience(data);
|
||||
}
|
||||
|
||||
router.push("/dashboard/experience");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Something went wrong.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const addHighlight = () => {
|
||||
if (highlightInput && !highlights.includes(highlightInput)) {
|
||||
setHighlights([...highlights, highlightInput]);
|
||||
setHighlightInput("");
|
||||
}
|
||||
};
|
||||
|
||||
const removeHighlight = (h: string) => {
|
||||
setHighlights(highlights.filter((t) => t !== h));
|
||||
};
|
||||
|
||||
const addTech = () => {
|
||||
if (techInput && !techStack.includes(techInput)) {
|
||||
setTechStack([...techStack, techInput]);
|
||||
setTechInput("");
|
||||
}
|
||||
};
|
||||
|
||||
const removeTech = (t: string) => {
|
||||
setTechStack(techStack.filter((tech) => tech !== t));
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-8 max-w-5xl mx-auto pb-20">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-border pb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/dashboard/experience"
|
||||
className="p-2 hover:bg-surface-elevated rounded-lg text-foreground-muted transition-colors"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-display font-bold text-foreground">
|
||||
{initialData ? "Edit Position" : "Add Work Experience"}
|
||||
</h1>
|
||||
<p className="text-sm text-foreground-muted">
|
||||
{initialData ? "Updating your career details." : "Showcase your professional journey."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="px-4 py-2 border border-border bg-surface text-foreground font-medium rounded-lg hover:bg-surface-elevated transition-all text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="inline-flex items-center gap-2 px-6 py-2 bg-primary text-primary-foreground font-semibold rounded-lg hover:shadow-lg hover:shadow-primary/20 transition-all disabled:opacity-50 text-sm"
|
||||
>
|
||||
<Save size={18} />
|
||||
{loading ? "Saving..." : initialData ? "Update Record" : "Add Experience"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="bg-surface border border-border/50 rounded-2xl p-6 space-y-6 card-shadow">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-foreground flex items-center gap-2">
|
||||
<Building2 size={16} className="text-primary/60" />
|
||||
Company Name
|
||||
</label>
|
||||
<input
|
||||
name="company"
|
||||
defaultValue={initialData?.company}
|
||||
placeholder="e.g. Google"
|
||||
required
|
||||
className="w-full px-4 py-2.5 bg-background border border-border/50 rounded-xl focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all text-sm font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-foreground flex items-center gap-2">
|
||||
<Briefcase size={16} className="text-primary/60" />
|
||||
Job Role
|
||||
</label>
|
||||
<input
|
||||
name="role"
|
||||
defaultValue={initialData?.role}
|
||||
placeholder="e.g. Senior Frontend Developer"
|
||||
required
|
||||
className="w-full px-4 py-2.5 bg-background border border-border/50 rounded-xl focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all text-sm font-medium"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-4">
|
||||
<label className="text-sm font-semibold text-foreground">Position Overview</label>
|
||||
<textarea
|
||||
name="description"
|
||||
defaultValue={initialData?.description}
|
||||
placeholder="Briefly summarize your key responsibilities and impact..."
|
||||
required
|
||||
rows={4}
|
||||
className="w-full px-4 py-3 bg-background border border-border/50 rounded-xl focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all text-sm leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface border border-border/50 rounded-2xl p-6 space-y-4 card-shadow">
|
||||
<h3 className="text-sm font-bold uppercase tracking-widest text-foreground-muted flex items-center gap-2">
|
||||
<ListIcon size={14} />
|
||||
Key Highlights & Impact
|
||||
</h3>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={highlightInput}
|
||||
onChange={(e) => setHighlightInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addHighlight())}
|
||||
placeholder="Add high-impact achievement..."
|
||||
className="flex-1 px-3 py-2 bg-background border border-border/50 rounded-lg text-xs outline-none focus:border-primary"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addHighlight}
|
||||
className="p-2 bg-primary/10 text-primary rounded-lg hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
{highlights.map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
className="flex items-start gap-3 p-3 bg-surface-elevated/50 border border-border/40 rounded-xl group"
|
||||
>
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary shrink-0 mt-1.5" />
|
||||
<span className="text-xs text-foreground flex-1 leading-normal">{h}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeHighlight(h)}
|
||||
className="p-1 text-foreground-muted hover:text-destructive transition-colors opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{highlights.length === 0 && (
|
||||
<p className="text-xs text-foreground-muted italic text-center py-4 bg-background/30 rounded-xl border border-dashed border-border/50">Describe your biggest wins in this role.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="bg-surface border border-border/50 rounded-2xl p-6 space-y-6 card-shadow">
|
||||
<h3 className="text-sm font-bold uppercase tracking-widest text-foreground-muted">Timeline</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold text-foreground flex items-center gap-2">
|
||||
<Calendar size={14} />
|
||||
Start Date
|
||||
</label>
|
||||
<input
|
||||
name="startDate"
|
||||
type="date"
|
||||
defaultValue={initialData?.startDate ? new Date(initialData.startDate).toISOString().split('T')[0] : ""}
|
||||
required
|
||||
className="w-full px-3 py-2 bg-background border border-border/50 rounded-lg outline-none text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold text-foreground flex items-center gap-2">
|
||||
<Calendar size={14} />
|
||||
End Date
|
||||
</label>
|
||||
<input
|
||||
name="endDate"
|
||||
type="date"
|
||||
defaultValue={initialData?.endDate ? new Date(initialData.endDate).toISOString().split('T')[0] : ""}
|
||||
disabled={initialData?.current}
|
||||
className="w-full px-3 py-2 bg-background border border-border/50 rounded-lg outline-none text-xs disabled:opacity-40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<input
|
||||
name="current"
|
||||
type="checkbox"
|
||||
id="current"
|
||||
defaultChecked={initialData?.current}
|
||||
className="w-3.5 h-3.5 rounded border-border text-primary focus:ring-primary outline-none"
|
||||
/>
|
||||
<label htmlFor="current" className="text-xs font-medium text-foreground cursor-pointer">
|
||||
Ongoing (Currently active)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-border/50">
|
||||
<label className="text-xs font-semibold text-foreground">Listing Priority Order</label>
|
||||
<input
|
||||
name="order"
|
||||
type="number"
|
||||
defaultValue={initialData?.order || 0}
|
||||
className="w-full mt-2 px-3 py-2 bg-background border border-border/50 rounded-lg outline-none text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface border border-border/50 rounded-2xl p-6 space-y-4 card-shadow">
|
||||
<h3 className="text-sm font-bold uppercase tracking-widest text-foreground-muted flex items-center gap-2">
|
||||
<TechIcon size={14} />
|
||||
Skills & Tech
|
||||
</h3>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={techInput}
|
||||
onChange={(e) => setTechInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addTech())}
|
||||
placeholder="Add tech..."
|
||||
className="flex-1 px-3 py-2 bg-background border border-border/50 rounded-lg text-xs outline-none focus:border-primary"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addTech}
|
||||
className="p-2 bg-primary/10 text-primary rounded-lg hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{techStack.map((tech) => (
|
||||
<span
|
||||
key={tech}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-surface-elevated border border-border/50 rounded-md text-[10px] font-bold text-foreground"
|
||||
>
|
||||
{tech}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTech(tech)}
|
||||
className="p-0.5 hover:text-destructive transition-colors"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
270
src/components/dashboard/projects/ProjectForm.tsx
Normal file
270
src/components/dashboard/projects/ProjectForm.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createProject, updateProject } from "@/lib/actions/project";
|
||||
import {
|
||||
Save,
|
||||
ChevronLeft,
|
||||
Briefcase,
|
||||
Layers,
|
||||
Link as LinkIcon,
|
||||
Github,
|
||||
Award,
|
||||
Trash2,
|
||||
Plus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ProjectFormProps {
|
||||
initialData?: any;
|
||||
}
|
||||
|
||||
export function ProjectForm({ initialData }: ProjectFormProps) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [techStack, setTechStack] = useState<string[]>(
|
||||
initialData?.techStack || [],
|
||||
);
|
||||
const [techInput, setTechInput] = useState("");
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const data = {
|
||||
title: formData.get("title") as string,
|
||||
description: formData.get("description") as string,
|
||||
imageUrl: formData.get("imageUrl") as string || null,
|
||||
liveUrl: formData.get("liveUrl") as string || null,
|
||||
githubUrl: formData.get("githubUrl") as string || null,
|
||||
techStack: techStack,
|
||||
featured: formData.get("featured") === "on",
|
||||
order: parseInt(formData.get("order") as string) || 0,
|
||||
};
|
||||
|
||||
if (initialData) {
|
||||
await updateProject(initialData.id, data);
|
||||
} else {
|
||||
await createProject(data);
|
||||
}
|
||||
|
||||
router.push("/dashboard/projects");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Something went wrong.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const addTech = () => {
|
||||
if (techInput && !techStack.includes(techInput)) {
|
||||
setTechStack([...techStack, techInput]);
|
||||
setTechInput("");
|
||||
}
|
||||
};
|
||||
|
||||
const removeTech = (tech: string) => {
|
||||
setTechStack(techStack.filter((t) => t !== tech));
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-8 max-w-5xl mx-auto pb-20">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-border pb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/dashboard/projects"
|
||||
className="p-2 hover:bg-surface-elevated rounded-lg text-foreground-muted transition-colors"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-display font-bold text-foreground">
|
||||
{initialData ? "Edit Project" : "New Portfolio Project"}
|
||||
</h1>
|
||||
<p className="text-sm text-foreground-muted">
|
||||
{initialData ? "Refine your project details." : "Add a new achievement to your collection."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="px-4 py-2 border border-border bg-surface text-foreground font-medium rounded-lg hover:bg-surface-elevated transition-all text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="inline-flex items-center gap-2 px-6 py-2 bg-primary text-primary-foreground font-semibold rounded-lg hover:shadow-lg hover:shadow-primary/20 transition-all disabled:opacity-50 text-sm"
|
||||
>
|
||||
<Save size={18} />
|
||||
{loading ? "Saving..." : initialData ? "Update Project" : "Create Project"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="bg-surface border border-border/50 rounded-2xl p-6 space-y-6 card-shadow">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-foreground flex items-center gap-2">
|
||||
<Briefcase size={16} className="text-primary/60" />
|
||||
Project Title
|
||||
</label>
|
||||
<input
|
||||
name="title"
|
||||
defaultValue={initialData?.title}
|
||||
placeholder="e.g. Finance Hub Pro"
|
||||
required
|
||||
className="w-full px-4 py-2.5 bg-background border border-border/50 rounded-xl focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all text-sm font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-foreground">Detailed Description</label>
|
||||
<textarea
|
||||
name="description"
|
||||
defaultValue={initialData?.description}
|
||||
placeholder="Describe your project, your role, and the value it provided..."
|
||||
required
|
||||
rows={8}
|
||||
className="w-full px-4 py-3 bg-background border border-border/50 rounded-xl focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all text-sm leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface border border-border/50 rounded-2xl p-6 space-y-6 card-shadow">
|
||||
<h3 className="text-sm font-bold uppercase tracking-widest text-foreground-muted">Assets & Visibility</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-foreground">Thumbnail Image URL</label>
|
||||
<input
|
||||
name="imageUrl"
|
||||
defaultValue={initialData?.imageUrl}
|
||||
placeholder="https://..."
|
||||
className="w-full px-4 py-2 bg-background border border-border/50 rounded-xl focus:ring-2 focus:ring-primary/20 outline-none transition-all text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-foreground">Display Order</label>
|
||||
<input
|
||||
name="order"
|
||||
type="number"
|
||||
defaultValue={initialData?.order || 0}
|
||||
className="w-full px-4 py-2 bg-background border border-border/50 rounded-xl focus:ring-2 focus:ring-primary/20 outline-none transition-all text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-4 border-t border-border/50">
|
||||
<input
|
||||
name="featured"
|
||||
type="checkbox"
|
||||
id="featured"
|
||||
defaultChecked={initialData?.featured}
|
||||
className="w-4 h-4 rounded border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
<label htmlFor="featured" className="text-sm font-medium text-foreground flex items-center gap-1.5 cursor-pointer">
|
||||
<Award size={14} className="text-yellow-500" />
|
||||
Feature this project on homepage
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Links Section */}
|
||||
<div className="bg-surface border border-border/50 rounded-2xl p-6 space-y-4 card-shadow">
|
||||
<h3 className="text-sm font-bold uppercase tracking-widest text-foreground-muted">Project Links</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold text-foreground flex items-center gap-2">
|
||||
<LinkIcon size={14} />
|
||||
Live Preview URL
|
||||
</label>
|
||||
<input
|
||||
name="liveUrl"
|
||||
defaultValue={initialData?.liveUrl}
|
||||
placeholder="https://example.com"
|
||||
className="w-full px-3 py-2 bg-background border border-border/50 rounded-lg focus:ring-2 focus:ring-primary/20 outline-none transition-all text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold text-foreground flex items-center gap-2">
|
||||
<Github size={14} />
|
||||
Github Repository
|
||||
</label>
|
||||
<input
|
||||
name="githubUrl"
|
||||
defaultValue={initialData?.githubUrl}
|
||||
placeholder="https://github.com/..."
|
||||
className="w-full px-3 py-2 bg-background border border-border/50 rounded-lg focus:ring-2 focus:ring-primary/20 outline-none transition-all text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tech Stack */}
|
||||
<div className="bg-surface border border-border/50 rounded-2xl p-6 space-y-4 card-shadow">
|
||||
<h3 className="text-sm font-bold uppercase tracking-widest text-foreground-muted flex items-center gap-2">
|
||||
<Layers size={14} />
|
||||
Tech Stack
|
||||
</h3>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={techInput}
|
||||
onChange={(e) => setTechInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addTech())}
|
||||
placeholder="Add tech..."
|
||||
className="flex-1 px-3 py-2 bg-background border border-border/50 rounded-lg text-xs outline-none focus:border-primary"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addTech}
|
||||
className="p-2 bg-primary/10 text-primary rounded-lg hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{techStack.map((tech) => (
|
||||
<span
|
||||
key={tech}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-surface-elevated border border-border/50 rounded-md text-[10px] font-bold text-foreground"
|
||||
>
|
||||
{tech}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTech(tech)}
|
||||
className="p-0.5 hover:text-destructive transition-colors"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{techStack.length === 0 && (
|
||||
<p className="text-xs text-foreground-muted italic text-center w-full">No technologies listed.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user