1197 lines
61 KiB
TypeScript
1197 lines
61 KiB
TypeScript
import React, { useEffect, useState, useRef } from 'react';
|
|
import { Plus, Search, Edit, Trash2, X, Eye, EyeOff, Loader2, Calendar, User, Tag, GripVertical, Image as ImageIcon, Type, Quote, List, Video, ArrowRight, MoveUp, MoveDown, Sparkles, Upload } from 'lucide-react';
|
|
import { toast } from 'react-toastify';
|
|
import Loading from '../../shared/components/Loading';
|
|
import Pagination from '../../shared/components/Pagination';
|
|
import ConfirmationDialog from '../../shared/components/ConfirmationDialog';
|
|
import blogService, { BlogPost, BlogPostCreate, BlogPostUpdate, BlogSection } from '../../features/content/services/blogService';
|
|
|
|
const BlogManagementPage: React.FC = () => {
|
|
const [posts, setPosts] = useState<BlogPost[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [editingPost, setEditingPost] = useState<BlogPost | null>(null);
|
|
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; id: number | null }>({
|
|
show: false,
|
|
id: null,
|
|
});
|
|
const [filters, setFilters] = useState({
|
|
search: '',
|
|
published: undefined as boolean | undefined,
|
|
});
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
const [total, setTotal] = useState(0);
|
|
const itemsPerPage = 20;
|
|
|
|
const [formData, setFormData] = useState<BlogPostCreate>({
|
|
title: '',
|
|
slug: '',
|
|
excerpt: '',
|
|
content: '',
|
|
featured_image: '',
|
|
tags: [],
|
|
meta_title: '',
|
|
meta_description: '',
|
|
meta_keywords: '',
|
|
is_published: false,
|
|
published_at: undefined,
|
|
sections: [],
|
|
});
|
|
const [tagInput, setTagInput] = useState('');
|
|
const [saving, setSaving] = useState(false);
|
|
const [showSectionBuilder, setShowSectionBuilder] = useState(false);
|
|
const [uploadingImages, setUploadingImages] = useState<{ [key: string]: boolean }>({});
|
|
|
|
useEffect(() => {
|
|
setCurrentPage(1);
|
|
}, [filters]);
|
|
|
|
useEffect(() => {
|
|
fetchPosts();
|
|
}, [filters, currentPage]);
|
|
|
|
const fetchPosts = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const response = await blogService.getAllBlogPosts({
|
|
page: currentPage,
|
|
limit: itemsPerPage,
|
|
search: filters.search || undefined,
|
|
published: filters.published,
|
|
});
|
|
|
|
if (response.status === 'success' && response.data) {
|
|
setPosts(response.data.posts);
|
|
setTotalPages(response.data.pagination.pages);
|
|
setTotal(response.data.pagination.total);
|
|
}
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.detail || 'Unable to load blog posts');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleOpenModal = (post?: BlogPost) => {
|
|
if (post) {
|
|
setEditingPost(post);
|
|
setFormData({
|
|
title: post.title,
|
|
slug: post.slug,
|
|
excerpt: post.excerpt || '',
|
|
content: post.content || '',
|
|
featured_image: post.featured_image || '',
|
|
tags: post.tags || [],
|
|
meta_title: post.meta_title || '',
|
|
meta_description: post.meta_description || '',
|
|
meta_keywords: post.meta_keywords || '',
|
|
is_published: post.is_published || false,
|
|
published_at: post.published_at,
|
|
sections: post.sections || [],
|
|
});
|
|
setTagInput('');
|
|
} else {
|
|
setEditingPost(null);
|
|
setFormData({
|
|
title: '',
|
|
slug: '',
|
|
excerpt: '',
|
|
content: '',
|
|
featured_image: '',
|
|
tags: [],
|
|
meta_title: '',
|
|
meta_description: '',
|
|
meta_keywords: '',
|
|
is_published: false,
|
|
published_at: undefined,
|
|
sections: [],
|
|
});
|
|
setTagInput('');
|
|
}
|
|
setShowModal(true);
|
|
};
|
|
|
|
const addSection = (type: BlogSection['type']) => {
|
|
const newSection: BlogSection = {
|
|
type,
|
|
alignment: 'center',
|
|
is_visible: true,
|
|
};
|
|
// Add default fields based on section type
|
|
if (type === 'features') {
|
|
newSection.features = [];
|
|
} else if (type === 'gallery') {
|
|
newSection.images = [];
|
|
}
|
|
setFormData({
|
|
...formData,
|
|
sections: [...(formData.sections || []), newSection],
|
|
});
|
|
};
|
|
|
|
const updateSection = (index: number, updates: Partial<BlogSection>) => {
|
|
const updatedSections = [...(formData.sections || [])];
|
|
updatedSections[index] = { ...updatedSections[index], ...updates };
|
|
setFormData({ ...formData, sections: updatedSections });
|
|
};
|
|
|
|
const removeSection = (index: number) => {
|
|
const updatedSections = [...(formData.sections || [])];
|
|
updatedSections.splice(index, 1);
|
|
setFormData({ ...formData, sections: updatedSections });
|
|
};
|
|
|
|
const moveSection = (index: number, direction: 'up' | 'down') => {
|
|
const updatedSections = [...(formData.sections || [])];
|
|
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
|
if (newIndex >= 0 && newIndex < updatedSections.length) {
|
|
[updatedSections[index], updatedSections[newIndex]] = [updatedSections[newIndex], updatedSections[index]];
|
|
setFormData({ ...formData, sections: updatedSections });
|
|
}
|
|
};
|
|
|
|
const handleCloseModal = () => {
|
|
setShowModal(false);
|
|
setEditingPost(null);
|
|
};
|
|
|
|
const handleAddTag = () => {
|
|
if (tagInput.trim()) {
|
|
// Support comma-separated tags
|
|
const newTags = tagInput
|
|
.split(',')
|
|
.map(tag => tag.trim())
|
|
.filter(tag => tag.length > 0 && !formData.tags?.includes(tag));
|
|
|
|
if (newTags.length > 0) {
|
|
setFormData({
|
|
...formData,
|
|
tags: [...(formData.tags || []), ...newTags],
|
|
});
|
|
setTagInput('');
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleRemoveTag = (tag: string) => {
|
|
setFormData({
|
|
...formData,
|
|
tags: formData.tags?.filter(t => t !== tag) || [],
|
|
});
|
|
};
|
|
|
|
// Auto-generate slug from title
|
|
const generateSlug = (title: string): string => {
|
|
return title
|
|
.toLowerCase()
|
|
.trim()
|
|
.replace(/[^\w\s-]/g, '') // Remove special characters
|
|
.replace(/[\s_-]+/g, '-') // Replace spaces and underscores with hyphens
|
|
.replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
|
|
};
|
|
|
|
// Handle title change and auto-generate slug
|
|
const handleTitleChange = (title: string) => {
|
|
setFormData({
|
|
...formData,
|
|
title,
|
|
// Auto-generate slug if it's empty or matches the old title's slug
|
|
slug: formData.slug === '' || formData.slug === generateSlug(formData.title)
|
|
? generateSlug(title)
|
|
: formData.slug,
|
|
});
|
|
};
|
|
|
|
// Normalize sections before submission - ensure all required fields are present
|
|
const normalizeSections = (sections: BlogSection[]): BlogSection[] => {
|
|
return sections.map(section => {
|
|
const normalized: BlogSection = {
|
|
type: section.type,
|
|
is_visible: section.is_visible !== undefined ? section.is_visible : true,
|
|
};
|
|
|
|
// Add fields based on section type
|
|
if (section.title) normalized.title = section.title;
|
|
if (section.content) normalized.content = section.content;
|
|
if (section.image) normalized.image = section.image;
|
|
if (section.images && section.images.length > 0) normalized.images = section.images;
|
|
if (section.alignment) normalized.alignment = section.alignment;
|
|
if (section.cta_text) normalized.cta_text = section.cta_text;
|
|
if (section.cta_link) normalized.cta_link = section.cta_link;
|
|
if (section.video_url) normalized.video_url = section.video_url;
|
|
if (section.quote) normalized.quote = section.quote;
|
|
if (section.author) normalized.author = section.author;
|
|
if (section.features && section.features.length > 0) normalized.features = section.features;
|
|
|
|
return normalized;
|
|
});
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
try {
|
|
setSaving(true);
|
|
|
|
// Prepare data with normalized sections
|
|
const submitData = {
|
|
...formData,
|
|
// Ensure sections are properly formatted
|
|
sections: formData.sections && formData.sections.length > 0
|
|
? normalizeSections(formData.sections)
|
|
: undefined,
|
|
// Ensure tags are an array
|
|
tags: formData.tags && formData.tags.length > 0 ? formData.tags : undefined,
|
|
// Auto-generate slug if empty
|
|
slug: formData.slug || generateSlug(formData.title),
|
|
};
|
|
|
|
if (editingPost) {
|
|
const updateData: BlogPostUpdate = submitData;
|
|
await blogService.updateBlogPost(editingPost.id, updateData);
|
|
toast.success('Blog post updated successfully');
|
|
} else {
|
|
await blogService.createBlogPost(submitData as BlogPostCreate);
|
|
toast.success('Blog post created successfully');
|
|
}
|
|
handleCloseModal();
|
|
fetchPosts();
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.detail || 'Failed to save blog post');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!deleteConfirm.id) return;
|
|
try {
|
|
await blogService.deleteBlogPost(deleteConfirm.id);
|
|
toast.success('Blog post deleted successfully');
|
|
setDeleteConfirm({ show: false, id: null });
|
|
fetchPosts();
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.detail || 'Failed to delete blog post');
|
|
}
|
|
};
|
|
|
|
const formatDate = (dateString?: string) => {
|
|
if (!dateString) return 'Not published';
|
|
return new Date(dateString).toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
});
|
|
};
|
|
|
|
const handleImageUpload = async (sectionIndex: number, field: 'image' | 'images', file: File, imageIndex?: number) => {
|
|
const uploadKey = `${sectionIndex}-${field}-${imageIndex ?? ''}`;
|
|
try {
|
|
setUploadingImages({ ...uploadingImages, [uploadKey]: true });
|
|
const response = await blogService.uploadBlogImage(file);
|
|
if (response.status === 'success' && response.data) {
|
|
const updatedSections = [...(formData.sections || [])];
|
|
if (field === 'image') {
|
|
updatedSections[sectionIndex] = { ...updatedSections[sectionIndex], image: response.data.full_url };
|
|
} else if (field === 'images') {
|
|
const currentImages = updatedSections[sectionIndex].images || [];
|
|
if (imageIndex !== undefined) {
|
|
currentImages[imageIndex] = response.data.full_url;
|
|
} else {
|
|
currentImages.push(response.data.full_url);
|
|
}
|
|
updatedSections[sectionIndex] = { ...updatedSections[sectionIndex], images: currentImages };
|
|
}
|
|
setFormData({ ...formData, sections: updatedSections });
|
|
toast.success('Image uploaded successfully');
|
|
}
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.detail || 'Failed to upload image');
|
|
} finally {
|
|
setUploadingImages({ ...uploadingImages, [uploadKey]: false });
|
|
}
|
|
};
|
|
|
|
const ImageUploadButton: React.FC<{
|
|
sectionIndex: number;
|
|
field: 'image' | 'images';
|
|
currentValue?: string;
|
|
imageIndex?: number;
|
|
label?: string;
|
|
}> = ({ sectionIndex, field, currentValue, imageIndex, label = 'Upload Image' }) => {
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const uploadKey = `${sectionIndex}-${field}-${imageIndex ?? ''}`;
|
|
const isUploading = uploadingImages[uploadKey];
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
{currentValue && (
|
|
<div className="relative group">
|
|
<img src={currentValue} alt="Preview" className="w-full h-48 object-cover rounded-lg border-2 border-gray-300" />
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const updatedSections = [...(formData.sections || [])];
|
|
if (field === 'image') {
|
|
updatedSections[sectionIndex] = { ...updatedSections[sectionIndex], image: '' };
|
|
} else if (field === 'images' && imageIndex !== undefined) {
|
|
const currentImages = updatedSections[sectionIndex].images || [];
|
|
currentImages.splice(imageIndex, 1);
|
|
updatedSections[sectionIndex] = { ...updatedSections[sectionIndex], images: currentImages };
|
|
}
|
|
setFormData({ ...formData, sections: updatedSections });
|
|
}}
|
|
className="absolute top-2 right-2 p-2 bg-red-500 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
<input
|
|
type="file"
|
|
ref={fileInputRef}
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
handleImageUpload(sectionIndex, field, file, imageIndex);
|
|
}
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
}}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={isUploading}
|
|
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-white rounded-lg hover:from-[#f5d76e] hover:to-[#d4af37] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{isUploading ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
<span>Uploading...</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Upload className="w-4 h-4" />
|
|
<span>{label}</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
if (loading && posts.length === 0) {
|
|
return <Loading />;
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 p-4 sm:p-6 lg:p-8">
|
|
<div className="max-w-7xl mx-auto">
|
|
{/* Header */}
|
|
<div className="mb-6 sm:mb-8">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">Blog Management</h1>
|
|
<p className="text-gray-600 mt-1">Manage your blog posts and content</p>
|
|
</div>
|
|
<button
|
|
onClick={() => handleOpenModal()}
|
|
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-white rounded-lg hover:from-[#f5d76e] hover:to-[#d4af37] transition-all shadow-lg"
|
|
>
|
|
<Plus className="w-5 h-5" />
|
|
<span>New Post</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
|
|
<div className="flex flex-col sm:flex-row gap-4">
|
|
<div className="flex-1">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search posts..."
|
|
value={filters.search}
|
|
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-transparent"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<select
|
|
value={filters.published === undefined ? 'all' : filters.published ? 'published' : 'draft'}
|
|
onChange={(e) => {
|
|
const value = e.target.value;
|
|
setFilters({
|
|
...filters,
|
|
published: value === 'all' ? undefined : value === 'published',
|
|
});
|
|
}}
|
|
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-transparent"
|
|
>
|
|
<option value="all">All Posts</option>
|
|
<option value="published">Published</option>
|
|
<option value="draft">Drafts</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Posts Table */}
|
|
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Title</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Author</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Published</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Views</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
{posts.map((post) => (
|
|
<tr key={post.id} className="hover:bg-gray-50">
|
|
<td className="px-4 py-4">
|
|
<div className="text-sm font-medium text-gray-900">{post.title}</div>
|
|
{post.excerpt && (
|
|
<div className="text-sm text-gray-500 mt-1 line-clamp-1">{post.excerpt}</div>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-4 text-sm text-gray-500">{post.author_name || 'Unknown'}</td>
|
|
<td className="px-4 py-4">
|
|
<span
|
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
|
post.is_published
|
|
? 'bg-green-100 text-green-800'
|
|
: 'bg-gray-100 text-gray-800'
|
|
}`}
|
|
>
|
|
{post.is_published ? (
|
|
<>
|
|
<Eye className="w-3 h-3 mr-1" />
|
|
Published
|
|
</>
|
|
) : (
|
|
<>
|
|
<EyeOff className="w-3 h-3 mr-1" />
|
|
Draft
|
|
</>
|
|
)}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-4 text-sm text-gray-500">{formatDate(post.published_at)}</td>
|
|
<td className="px-4 py-4 text-sm text-gray-500">{post.views_count}</td>
|
|
<td className="px-4 py-4 text-sm font-medium">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => handleOpenModal(post)}
|
|
className="text-[#d4af37] hover:text-[#c9a227]"
|
|
>
|
|
<Edit className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => setDeleteConfirm({ show: true, id: post.id })}
|
|
className="text-red-600 hover:text-red-800"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{posts.length === 0 && !loading && (
|
|
<div className="text-center py-12">
|
|
<p className="text-gray-500">No blog posts found</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="mt-6">
|
|
<Pagination
|
|
currentPage={currentPage}
|
|
totalPages={totalPages}
|
|
onPageChange={setCurrentPage}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Create/Edit Modal */}
|
|
{showModal && (
|
|
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-3 sm:p-4 animate-fade-in">
|
|
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-4xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden border border-slate-200/50 animate-scale-in">
|
|
<div className="sticky top-0 bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-4 sm:px-6 py-4 sm:py-5 border-b border-slate-700 z-10">
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-amber-100">
|
|
{editingPost ? 'Edit Blog Post' : 'Create Blog Post'}
|
|
</h2>
|
|
<p className="text-amber-200/80 text-xs sm:text-sm font-light mt-1">
|
|
{editingPost ? 'Modify blog post content' : 'Create a new blog post'}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={handleCloseModal}
|
|
className="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center rounded-xl text-amber-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-amber-400"
|
|
>
|
|
<X className="w-5 h-5 sm:w-6 sm:h-6" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
|
|
<form onSubmit={handleSubmit} className="p-4 sm:p-6 space-y-5 sm:space-y-6">
|
|
<div>
|
|
<label className="block text-xs sm:text-sm font-semibold text-slate-600 uppercase tracking-wider mb-2">Title *</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
value={formData.title}
|
|
onChange={(e) => handleTitleChange(e.target.value)}
|
|
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
|
placeholder="Enter blog post title"
|
|
/>
|
|
<p className="mt-1 text-xs text-slate-500">Slug will be auto-generated from title</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Slug
|
|
<span className="ml-2 text-xs text-gray-500 font-normal">(Optional - auto-generated)</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.slug}
|
|
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
|
|
placeholder="Auto-generated from title"
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-transparent bg-gray-50"
|
|
/>
|
|
<p className="mt-1 text-xs text-gray-500">Leave empty to auto-generate, or customize the URL slug</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Excerpt</label>
|
|
<textarea
|
|
value={formData.excerpt}
|
|
onChange={(e) => setFormData({ ...formData, excerpt: e.target.value })}
|
|
rows={3}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-transparent"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Content *</label>
|
|
<textarea
|
|
required
|
|
value={formData.content}
|
|
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
|
rows={10}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-transparent"
|
|
/>
|
|
</div>
|
|
|
|
{/* Luxury Section Builder */}
|
|
<div className="border-t border-gray-200 pt-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div>
|
|
<label className="block text-lg font-semibold text-gray-900 mb-1">Content Sections</label>
|
|
<p className="text-sm text-gray-500">Add luxury sections to enhance your blog post</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowSectionBuilder(!showSectionBuilder)}
|
|
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-white rounded-lg hover:from-[#f5d76e] hover:to-[#d4af37] transition-all shadow-lg"
|
|
>
|
|
<Sparkles className="w-4 h-4" />
|
|
<span>{showSectionBuilder ? 'Hide' : 'Show'} Section Builder</span>
|
|
</button>
|
|
</div>
|
|
|
|
{showSectionBuilder && (
|
|
<div className="space-y-4">
|
|
{/* Add Section Buttons */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
|
{[
|
|
{ type: 'hero' as const, icon: Sparkles, label: 'Hero' },
|
|
{ type: 'text' as const, icon: Type, label: 'Text' },
|
|
{ type: 'image' as const, icon: ImageIcon, label: 'Image' },
|
|
{ type: 'gallery' as const, icon: ImageIcon, label: 'Gallery' },
|
|
{ type: 'quote' as const, icon: Quote, label: 'Quote' },
|
|
{ type: 'features' as const, icon: List, label: 'Features' },
|
|
{ type: 'cta' as const, icon: ArrowRight, label: 'CTA' },
|
|
{ type: 'video' as const, icon: Video, label: 'Video' },
|
|
].map(({ type, icon: Icon, label }) => (
|
|
<button
|
|
key={type}
|
|
type="button"
|
|
onClick={() => addSection(type)}
|
|
className="flex flex-col items-center gap-2 p-4 bg-gradient-to-br from-gray-50 to-white border-2 border-gray-200 rounded-xl hover:border-[#d4af37] hover:shadow-lg transition-all group"
|
|
>
|
|
<Icon className="w-6 h-6 text-gray-600 group-hover:text-[#d4af37] transition-colors" />
|
|
<span className="text-sm font-medium text-gray-700">{label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Sections List */}
|
|
{formData.sections && formData.sections.length > 0 && (
|
|
<div className="space-y-4">
|
|
{formData.sections.map((section, index) => (
|
|
<div
|
|
key={index}
|
|
className="bg-gradient-to-br from-gray-50 to-white border-2 border-gray-200 rounded-xl p-6 shadow-sm"
|
|
>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-3">
|
|
<GripVertical className="w-5 h-5 text-gray-400" />
|
|
<span className="px-3 py-1 bg-[#d4af37]/10 text-[#d4af37] rounded-full text-sm font-semibold uppercase">
|
|
{section.type}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => moveSection(index, 'up')}
|
|
disabled={index === 0}
|
|
className="p-2 text-gray-400 hover:text-[#d4af37] disabled:opacity-30 disabled:cursor-not-allowed"
|
|
>
|
|
<MoveUp className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => moveSection(index, 'down')}
|
|
disabled={index === (formData.sections?.length || 0) - 1}
|
|
className="p-2 text-gray-400 hover:text-[#d4af37] disabled:opacity-30 disabled:cursor-not-allowed"
|
|
>
|
|
<MoveDown className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeSection(index)}
|
|
className="p-2 text-red-400 hover:text-red-600"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Section Fields */}
|
|
<div className="space-y-4">
|
|
{section.type === 'hero' && (
|
|
<>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Title</label>
|
|
<input
|
|
type="text"
|
|
value={section.title || ''}
|
|
onChange={(e) => updateSection(index, { title: e.target.value })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37]"
|
|
placeholder="Hero Title"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Content</label>
|
|
<textarea
|
|
value={section.content || ''}
|
|
onChange={(e) => updateSection(index, { content: e.target.value })}
|
|
rows={3}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37]"
|
|
placeholder="Hero content"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Background Image</label>
|
|
<ImageUploadButton
|
|
sectionIndex={index}
|
|
field="image"
|
|
currentValue={section.image}
|
|
label="Upload Background Image"
|
|
/>
|
|
<div className="mt-2">
|
|
<label className="block text-xs text-gray-500 mb-1">Or enter URL manually</label>
|
|
<input
|
|
type="url"
|
|
value={section.image || ''}
|
|
onChange={(e) => updateSection(index, { image: e.target.value })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] text-sm"
|
|
placeholder="https://..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{section.type === 'text' && (
|
|
<>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Title (Optional)</label>
|
|
<input
|
|
type="text"
|
|
value={section.title || ''}
|
|
onChange={(e) => updateSection(index, { title: e.target.value })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37]"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Content</label>
|
|
<textarea
|
|
value={section.content || ''}
|
|
onChange={(e) => updateSection(index, { content: e.target.value })}
|
|
rows={6}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37]"
|
|
placeholder="Enter your text content (HTML supported). You can use <img src='URL' /> to add images."
|
|
/>
|
|
<div className="mt-2">
|
|
<p className="text-xs text-gray-500 mb-2">Tip: Add images in HTML format: <img src="URL" alt="description" /></p>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const imageUrl = prompt('Enter image URL:');
|
|
if (imageUrl) {
|
|
const currentContent = section.content || '';
|
|
const imgTag = `<img src="${imageUrl}" alt="Image" class="rounded-lg my-4" />`;
|
|
updateSection(index, { content: currentContent + '\n' + imgTag });
|
|
}
|
|
}}
|
|
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
|
|
>
|
|
<ImageIcon className="w-4 h-4" />
|
|
Insert Image Tag
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Alignment</label>
|
|
<select
|
|
value={section.alignment || 'left'}
|
|
onChange={(e) => updateSection(index, { alignment: e.target.value as 'left' | 'center' | 'right' })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37]"
|
|
>
|
|
<option value="left">Left</option>
|
|
<option value="center">Center</option>
|
|
<option value="right">Right</option>
|
|
</select>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{section.type === 'image' && (
|
|
<>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Image</label>
|
|
<ImageUploadButton
|
|
sectionIndex={index}
|
|
field="image"
|
|
currentValue={section.image}
|
|
label="Upload Image"
|
|
/>
|
|
<div className="mt-2">
|
|
<label className="block text-xs text-gray-500 mb-1">Or enter URL manually</label>
|
|
<input
|
|
type="url"
|
|
value={section.image || ''}
|
|
onChange={(e) => updateSection(index, { image: e.target.value })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] text-sm"
|
|
placeholder="https://..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Caption (Optional)</label>
|
|
<input
|
|
type="text"
|
|
value={section.title || ''}
|
|
onChange={(e) => updateSection(index, { title: e.target.value })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37]"
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{section.type === 'gallery' && (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Gallery Images</label>
|
|
<ImageUploadButton
|
|
sectionIndex={index}
|
|
field="images"
|
|
label="Add Image to Gallery"
|
|
/>
|
|
</div>
|
|
{(section.images || []).length > 0 && (
|
|
<div className="space-y-3">
|
|
{section.images?.map((img, imgIndex) => (
|
|
<div key={imgIndex} className="border-2 border-gray-200 rounded-lg p-4">
|
|
<div className="flex items-start gap-4">
|
|
<img src={img} alt={`Gallery ${imgIndex + 1}`} className="w-24 h-24 object-cover rounded-lg" />
|
|
<div className="flex-1">
|
|
<input
|
|
type="url"
|
|
value={img}
|
|
onChange={(e) => {
|
|
const updatedImages = [...(section.images || [])];
|
|
updatedImages[imgIndex] = e.target.value;
|
|
updateSection(index, { images: updatedImages });
|
|
}}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] text-sm mb-2"
|
|
placeholder="Image URL"
|
|
/>
|
|
<div className="flex gap-2">
|
|
<ImageUploadButton
|
|
sectionIndex={index}
|
|
field="images"
|
|
currentValue={img}
|
|
imageIndex={imgIndex}
|
|
label="Replace"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const updatedImages = [...(section.images || [])];
|
|
updatedImages.splice(imgIndex, 1);
|
|
updateSection(index, { images: updatedImages });
|
|
}}
|
|
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 text-sm"
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div>
|
|
<label className="block text-xs text-gray-500 mb-1">Or paste URLs (one per line)</label>
|
|
<textarea
|
|
value={(section.images || []).join('\n')}
|
|
onChange={(e) => updateSection(index, { images: e.target.value.split('\n').filter(url => url.trim()) })}
|
|
rows={4}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] text-sm"
|
|
placeholder="https://image1.com/... https://image2.com/..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{section.type === 'quote' && (
|
|
<>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Quote Text</label>
|
|
<textarea
|
|
value={section.quote || ''}
|
|
onChange={(e) => updateSection(index, { quote: e.target.value })}
|
|
rows={4}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37]"
|
|
placeholder="Enter the quote"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Author (Optional)</label>
|
|
<input
|
|
type="text"
|
|
value={section.author || ''}
|
|
onChange={(e) => updateSection(index, { author: e.target.value })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37]"
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{section.type === 'features' && (
|
|
<div className="space-y-4">
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Features</label>
|
|
<p className="text-xs text-gray-500 mb-3">Add features one by one. Each feature needs a title and description.</p>
|
|
|
|
{(section.features || []).map((feature, featureIndex) => (
|
|
<div key={featureIndex} className="p-4 bg-gray-50 rounded-lg border border-gray-200">
|
|
<div className="flex items-start justify-between mb-3">
|
|
<span className="text-sm font-medium text-gray-700">Feature {featureIndex + 1}</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const updatedFeatures = [...(section.features || [])];
|
|
updatedFeatures.splice(featureIndex, 1);
|
|
updateSection(index, { features: updatedFeatures });
|
|
}}
|
|
className="text-red-600 hover:text-red-800"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<input
|
|
type="text"
|
|
value={feature.title || ''}
|
|
onChange={(e) => {
|
|
const updatedFeatures = [...(section.features || [])];
|
|
updatedFeatures[featureIndex] = { ...updatedFeatures[featureIndex], title: e.target.value };
|
|
updateSection(index, { features: updatedFeatures });
|
|
}}
|
|
placeholder="Feature title"
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] text-sm"
|
|
/>
|
|
<textarea
|
|
value={feature.description || ''}
|
|
onChange={(e) => {
|
|
const updatedFeatures = [...(section.features || [])];
|
|
updatedFeatures[featureIndex] = { ...updatedFeatures[featureIndex], description: e.target.value };
|
|
updateSection(index, { features: updatedFeatures });
|
|
}}
|
|
placeholder="Feature description"
|
|
rows={2}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] text-sm"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={feature.icon || ''}
|
|
onChange={(e) => {
|
|
const updatedFeatures = [...(section.features || [])];
|
|
updatedFeatures[featureIndex] = { ...updatedFeatures[featureIndex], icon: e.target.value };
|
|
updateSection(index, { features: updatedFeatures });
|
|
}}
|
|
placeholder="Icon URL (optional)"
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const updatedFeatures = [...(section.features || []), { title: '', description: '' }];
|
|
updateSection(index, { features: updatedFeatures });
|
|
}}
|
|
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
<span>Add Feature</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{section.type === 'cta' && (
|
|
<>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Title</label>
|
|
<input
|
|
type="text"
|
|
value={section.title || ''}
|
|
onChange={(e) => updateSection(index, { title: e.target.value })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37]"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Content</label>
|
|
<textarea
|
|
value={section.content || ''}
|
|
onChange={(e) => updateSection(index, { content: e.target.value })}
|
|
rows={3}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37]"
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Button Text</label>
|
|
<input
|
|
type="text"
|
|
value={section.cta_text || ''}
|
|
onChange={(e) => updateSection(index, { cta_text: e.target.value })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37]"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Button Link</label>
|
|
<input
|
|
type="url"
|
|
value={section.cta_link || ''}
|
|
onChange={(e) => updateSection(index, { cta_link: e.target.value })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37]"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{section.type === 'video' && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Video URL (YouTube, Vimeo, etc.)</label>
|
|
<input
|
|
type="url"
|
|
value={section.video_url || ''}
|
|
onChange={(e) => updateSection(index, { video_url: e.target.value })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37]"
|
|
placeholder="https://youtube.com/watch?v=..."
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Featured Image URL</label>
|
|
<input
|
|
type="url"
|
|
value={formData.featured_image}
|
|
onChange={(e) => setFormData({ ...formData, featured_image: e.target.value })}
|
|
placeholder="https://images.unsplash.com/..."
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-transparent"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Tags</label>
|
|
<div className="flex gap-2 mb-2">
|
|
<input
|
|
type="text"
|
|
value={tagInput}
|
|
onChange={(e) => setTagInput(e.target.value)}
|
|
onKeyPress={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
handleAddTag();
|
|
}
|
|
}}
|
|
placeholder="Add a tag and press Enter"
|
|
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-transparent"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={handleAddTag}
|
|
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
|
|
>
|
|
Add
|
|
</button>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{formData.tags?.map((tag) => (
|
|
<span
|
|
key={tag}
|
|
className="inline-flex items-center gap-1 px-3 py-1 bg-[#d4af37]/10 text-[#d4af37] rounded-full text-sm"
|
|
>
|
|
{tag}
|
|
<button
|
|
type="button"
|
|
onClick={() => handleRemoveTag(tag)}
|
|
className="hover:text-[#c9a227]"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Meta Title</label>
|
|
<input
|
|
type="text"
|
|
value={formData.meta_title}
|
|
onChange={(e) => setFormData({ ...formData, meta_title: e.target.value })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-transparent"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Meta Keywords</label>
|
|
<input
|
|
type="text"
|
|
value={formData.meta_keywords}
|
|
onChange={(e) => setFormData({ ...formData, meta_keywords: e.target.value })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-transparent"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Meta Description</label>
|
|
<textarea
|
|
value={formData.meta_description}
|
|
onChange={(e) => setFormData({ ...formData, meta_description: e.target.value })}
|
|
rows={3}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-transparent"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4">
|
|
<label className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.is_published}
|
|
onChange={(e) => setFormData({ ...formData, is_published: e.target.checked })}
|
|
className="w-4 h-4 text-[#d4af37] border-gray-300 rounded focus:ring-[#d4af37]"
|
|
/>
|
|
<span className="text-sm font-medium text-gray-700">Publish</span>
|
|
</label>
|
|
{formData.is_published && (
|
|
<div className="flex-1">
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Publish Date</label>
|
|
<input
|
|
type="datetime-local"
|
|
value={formData.published_at ? new Date(formData.published_at).toISOString().slice(0, 16) : ''}
|
|
onChange={(e) => setFormData({ ...formData, published_at: e.target.value ? new Date(e.target.value).toISOString() : undefined })}
|
|
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-transparent"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-col sm:flex-row justify-end gap-3 pt-4 border-t border-slate-200">
|
|
<button
|
|
type="button"
|
|
onClick={handleCloseModal}
|
|
className="w-full sm:w-auto px-6 py-3 border-2 border-slate-300 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition-all duration-200 shadow-sm hover:shadow-md"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={saving}
|
|
className="w-full sm:w-auto px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
>
|
|
{saving && <Loader2 className="w-5 h-5 animate-spin" />}
|
|
{editingPost ? 'Update' : 'Create'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Delete Confirmation */}
|
|
<ConfirmationDialog
|
|
isOpen={deleteConfirm.show}
|
|
onClose={() => setDeleteConfirm({ show: false, id: null })}
|
|
onConfirm={handleDelete}
|
|
title="Delete Blog Post"
|
|
message="Are you sure you want to delete this blog post? This action cannot be undone."
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default BlogManagementPage;
|
|
|