webdev-service / components /admin /BlogForm.tsx
underrate's picture
Initial commit
34ed5b1 verified
Raw
History Blame Contribute Delete
14.9 kB
"use client";
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useSession } from "next-auth/react";
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import LinkExtension from '@tiptap/extension-link';
import ImageExtension from '@tiptap/extension-image';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card, CardContent } from '@/components/ui/card';
import { Bold, Italic, List, ListOrdered, Link as LinkIcon, Image as ImageIcon, Loader2 } from 'lucide-react';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000/api';
interface BlogFormProps {
initialData?: any;
isEditing?: boolean;
}
export default function BlogForm({ initialData = null, isEditing = false }: BlogFormProps) {
const router = useRouter();
const { data: session } = useSession();
const [isLoading, setIsLoading] = useState(false);
// Form State
const [title, setTitle] = useState(initialData?.title || '');
const [slug, setSlug] = useState(initialData?.slug || '');
const [excerpt, setExcerpt] = useState(initialData?.excerpt || '');
const [featuredImage, setFeaturedImage] = useState(initialData?.featuredImage || '');
const [status, setStatus] = useState(initialData?.status || 'DRAFT');
const [publishedAt, setPublishedAt] = useState(initialData?.publishedAt ? new Date(initialData.publishedAt).toISOString().slice(0, 16) : '');
const [tags, setTags] = useState(initialData?.tags ? JSON.parse(initialData.tags).join(', ') : '');
const editor = useEditor({
extensions: [
StarterKit,
LinkExtension.configure({ openOnClick: false }),
ImageExtension.configure({ inline: true }),
],
content: initialData?.content || '<p>Start writing your post here...</p>',
immediatelyRender: false,
editorProps: {
attributes: {
class: 'prose prose-sm sm:prose lg:prose-lg xl:prose-2xl mx-auto focus:outline-none min-h-[400px] p-4 border rounded-md max-w-none',
},
},
});
const setLink = () => {
const previousUrl = editor?.getAttributes('link').href;
const url = window.prompt('URL', previousUrl);
if (url === null) return;
if (url === '') {
editor?.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
editor?.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
};
const addImage = () => {
const url = window.prompt('Image URL');
if (url) {
editor?.chain().focus().setImage({ src: url }).run();
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
const content = editor?.getHTML();
const tagsArray = tags.split(',').map((t: string) => t.trim()).filter(Boolean);
const payload = {
title,
slug: slug || undefined,
content,
excerpt,
featuredImage,
status,
publishedAt: publishedAt ? new Date(publishedAt).toISOString() : null,
tags: JSON.stringify(tagsArray)
};
try {
const url = isEditing && initialData?.id
? `${API_URL}/admin/blog/${initialData.id}`
: `${API_URL}/admin/blog`;
const method = isEditing ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session?.user?.id}`
},
body: JSON.stringify(payload)
});
if (res.ok) {
router.push('/admin/blog');
router.refresh();
} else {
const data = await res.json();
alert(`Error: ${data.error || 'Failed to save post'}`);
}
} catch (error) {
console.error("Save error:", error);
alert("An unexpected error occurred while saving.");
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-8">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<h2 className="text-3xl font-bold tracking-tight">
{isEditing ? 'Edit Article' : 'New Article'}
</h2>
<div className="flex gap-2 w-full md:w-auto">
<Button variant="outline" type="button" onClick={() => router.push('/admin/blog')}>
Cancel
</Button>
<Button type="submit" disabled={isLoading || !title || !editor?.getHTML()}>
{isLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{isEditing ? 'Save Changes' : 'Create Post'}
</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Main Content Area */}
<div className="lg:col-span-2 space-y-6">
<Card>
<CardContent className="p-6 space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Post Title *</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter an engaging title..."
required
className="text-lg font-medium"
/>
</div>
<div className="space-y-2">
<Label>Content *</Label>
{editor && (
<div className="border rounded-md overflow-hidden bg-background">
{/* TipTap Toolbar */}
<div className="flex flex-wrap items-center gap-1 p-1 border-b bg-muted/30">
<Button type="button" variant="ghost" size="sm" onClick={() => editor.chain().focus().toggleBold().run()} className={editor.isActive('bold') ? 'bg-muted' : ''}>
<Bold className="h-4 w-4" />
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => editor.chain().focus().toggleItalic().run()} className={editor.isActive('italic') ? 'bg-muted' : ''}>
<Italic className="h-4 w-4" />
</Button>
<div className="w-px h-4 bg-border mx-1" />
<Button type="button" variant="ghost" size="sm" onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} className={editor.isActive('heading', { level: 2 }) ? 'bg-muted' : ''}>
H2
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} className={editor.isActive('heading', { level: 3 }) ? 'bg-muted' : ''}>
H3
</Button>
<div className="w-px h-4 bg-border mx-1" />
<Button type="button" variant="ghost" size="sm" onClick={() => editor.chain().focus().toggleBulletList().run()} className={editor.isActive('bulletList') ? 'bg-muted' : ''}>
<List className="h-4 w-4" />
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => editor.chain().focus().toggleOrderedList().run()} className={editor.isActive('orderedList') ? 'bg-muted' : ''}>
<ListOrdered className="h-4 w-4" />
</Button>
<div className="w-px h-4 bg-border mx-1" />
<Button type="button" variant="ghost" size="sm" onClick={setLink} className={editor.isActive('link') ? 'bg-muted' : ''}>
<LinkIcon className="h-4 w-4" />
</Button>
<Button type="button" variant="ghost" size="sm" onClick={addImage}>
<ImageIcon className="h-4 w-4" />
</Button>
</div>
{/* TipTap Editor */}
<EditorContent editor={editor} />
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="excerpt">Excerpt / Summary</Label>
<Textarea
id="excerpt"
value={excerpt}
onChange={(e) => setExcerpt(e.target.value)}
placeholder="Brief summary for search engines and social sharing..."
rows={3}
/>
</div>
</CardContent>
</Card>
</div>
{/* Sidebar Metrics & Settings */}
<div className="space-y-6">
<Card>
<CardContent className="p-6 space-y-4">
<div className="space-y-2">
<Label htmlFor="status">Publishing Status</Label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="DRAFT">Draft</SelectItem>
<SelectItem value="PUBLISHED">Published</SelectItem>
<SelectItem value="SCHEDULED">Scheduled</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="publishedAt">Publish Date</Label>
<Input
id="publishedAt"
type="datetime-local"
value={publishedAt}
onChange={(e) => setPublishedAt(e.target.value)}
disabled={status === 'DRAFT'}
/>
{status === 'DRAFT' && <p className="text-xs text-muted-foreground">Date is disabled while in Draft status.</p>}
</div>
<div className="space-y-2 pt-4 border-t">
<Label htmlFor="slug">Custom URL Slug (Optional)</Label>
<Input
id="slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="e.g. my-awesome-post"
/>
<p className="text-xs text-muted-foreground">Leave blank to auto-generate from title.</p>
</div>
<div className="space-y-2 pt-4 border-t">
<Label htmlFor="featuredImage">Featured Image URL</Label>
<Input
id="featuredImage"
value={featuredImage}
onChange={(e) => setFeaturedImage(e.target.value)}
placeholder="https://..."
/>
{featuredImage && (
<div className="mt-2 text-xs text-muted-foreground overflow-hidden rounded-md h-32 bg-muted relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={featuredImage} alt="Featured preview" className="object-cover w-full h-full" />
</div>
)}
</div>
<div className="space-y-2 pt-4 border-t">
<Label htmlFor="tags">Tags (Comma separated)</Label>
<Input
id="tags"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="React, Design, Tech"
/>
</div>
</CardContent>
</Card>
</div>
</div>
</form>
);
}