
Article related with how blogs get uploads
Admin·July 8, 2026·42 min read
import React, { useEffect, useState, useRef } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { fetchCategoriesTree } from '../../lib/db';
import { CategoryWithChildren } from '../../types';
import {
Blog, BlogListItem, fetchBlogsList, fetchBlogById,
createBlog, updateBlog, deleteBlog, checkSlugExists,
} from '../../lib/blogDb';
import { uploadToCloudinary } from '../../lib/cloudinaryConfig';
import {
Plus, Edit2, Trash2, Eye, EyeOff, Save, ArrowLeft,
Image as ImageIcon, Video, Paperclip, Tag, Search,
Calendar, FileText, Globe, Bold, Italic, Underline,
List, ListOrdered, Quote, Link2, Code, Heading1,
Heading2, Heading3, Loader2, ChevronLeft, ChevronRight,
X, Strikethrough, ExternalLink,
} from 'lucide-react';
import { toast } from 'sonner';
import Modal from '../../components/ui/Modal';
const inputCls =
'w-full px-4 py-2.5 rounded-xl border border-gray-200 dark:border-white/[0.08] ' +
'bg-white dark:bg-[#111d35] text-gray-900 dark:text-white ' +
'placeholder-gray-400 dark:placeholder-gray-500 ' +
'focus:outline-none focus:ring-2 focus:ring-violet-500/60 transition text-sm';
const labelCls = 'block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1.5';
const PAGE_SIZE = 10;
const toSlug = (title: string) =>
title.toLowerCase().trim().replace(/[^\w\s-]/g, '').replace(/[\s_-]+/g, '-').replace(/^-+|-+$/g, '');
const estimateReadTime = (html: string) => {
const text = html.replace(/<[^>]*>/g, ' ');
const words = text.split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.ceil(words / 200));
};
type View = 'list' | 'editor';
const ManageBlogs: React.FC = () => {
const { user } = useAuth();
const [view, setView] = useState<View>('list');
const [blogs, setBlogs] = useState<BlogListItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [search, setSearch] = useState('');
const [categoryTree, setCategoryTree] = useState<CategoryWithChildren[]>([]);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [previewBlog, setPreviewBlog] = useState<Blog | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [form, setForm] = useState({
title: '', slug: '', excerpt: '',
cover_image_url: '', video_url: '',
attachment_url: '', attachment_name: '',
meta_title: '', meta_description: '',
tags: '', category_id: '', subcategory_id: '',
});
const [slugManual, setSlugManual] = useState(false);
const editorRef = useRef<HTMLDivElement>(null);
const [contentHtml, setContentHtml] = useState('');
const [uploadingCover, setUploadingCover] = useState(false);
const [uploadingVideo, setUploadingVideo] = useState(false);
const [uploadingAttachment, setUploadingAttachment] = useState(false);
const [uploadingInlineImage, setUploadingInlineImage] = useState(false);
const coverInputRef = useRef<HTMLInputElement>(null);
const videoInputRef = useRef<HTMLInputElement>(null);
const attachmentInputRef = useRef<HTMLInputElement>(null);
const inlineImageInputRef = useRef<HTMLInputElement>(null);
useEffect(() => { loadList(); loadCategories(); }, [page]);
const loadList = async () => {
setLoading(true);
const { blogs: data, total: count } = await fetchBlogsList(page, PAGE_SIZE, {
search: search || undefined,
});
setBlogs(data);
setTotal(count);
setLoading(false);
};
const loadCategories = async () => {
const tree = await fetchCategoriesTree();
setCategoryTree(tree);
};
const handleSearch = () => { setPage(0); loadList(); };
const openNew = () => {
setEditingId(null);
setSlugManual(false);
setForm({
title: '', slug: '', excerpt: '',
cover_image_url: '', video_url: '',
attachment_url: '', attachment_name: '',
meta_title: '', meta_description: '',
tags: '', category_id: '', subcategory_id: '',
});
setContentHtml('');
setView('editor');
setTimeout(() => { if (editorRef.current) editorRef.current.innerHTML = ''; }, 0);
};
const openEdit = async (id: string) => {
setLoading(true);
const blog = await fetchBlogById(id);
setLoading(false);
if (!blog) { toast.error('Failed to load blog'); return; }
setEditingId(blog.id);
setSlugManual(true);
setForm({
title: blog.title,
slug: blog.slug,
excerpt: blog.excerpt || '',
cover_image_url: blog.cover_image_url || '',
video_url: blog.video_url || '',
attachment_url: blog.attachment_url || '',
attachment_name: blog.attachment_name || '',
meta_title: blog.meta_title || '',
meta_description: blog.meta_description || '',
tags: (blog.tags || []).join(', '),
category_id: blog.category_id || '',
subcategory_id: blog.subcategory_id || '',
});
setContentHtml(blog.content || '');
setView('editor');
setTimeout(() => {
if (editorRef.current) editorRef.current.innerHTML = blog.content || '';
}, 0);
};
const openPreview = async (id: string) => {
setPreviewLoading(true);
const blog = await fetchBlogById(id);
setPreviewLoading(false);
if (!blog) { toast.error('Failed to load preview'); return; }
setPreviewBlog(blog);
};
const handleTitleChange = (title: string) => {
setForm((p) => ({
...p,
title,
slug: slugManual ? p.slug : toSlug(title),
meta_title: p.meta_title || title,
}));
};
const subsForParent = (parentId: string) =>
categoryTree.find((c) => c.id === parentId)?.children || [];
const exec = (cmd: string, value?: string) => {
document.execCommand(cmd, false, value);
editorRef.current?.focus();
setContentHtml(editorRef.current?.innerHTML || '');
};
const insertLink = () => {
const url = prompt('Enter URL:');
if (url) exec('createLink', url);
};
const handleEditorInput = () => {
setContentHtml(editorRef.current?.innerHTML || '');
};
const handleCoverUpload = async (file: File) => {
setUploadingCover(true);
const result = await uploadToCloudinary(file, 'image');
setUploadingCover(false);
if (result) { setForm((p) => ({ ...p, cover_image_url: result.url })); toast.success('Cover image uploaded!'); }
else toast.error('Upload failed — check Cloudinary preset config');
};
const handleVideoUpload = async (file: File) => {
setUploadingVideo(true);
const result = await uploadToCloudinary(file, 'video');
setUploadingVideo(false);
if (result) { setForm((p) => ({ ...p, video_url: result.url })); toast.success('Video uploaded!'); }
else toast.error('Upload failed — check Cloudinary preset config');
};
const handleAttachmentUpload = async (file: File) => {
setUploadingAttachment(true);
const result = await uploadToCloudinary(file, 'raw');
setUploadingAttachment(false);
if (result) {
setForm((p) => ({ ...p, attachment_url: result.url, attachment_name: result.name }));
toast.success('File uploaded!');
} else toast.error('Upload failed — check Cloudinary preset config');
};
const handleInlineImageUpload = async (file: File) => {
setUploadingInlineImage(true);
const result = await uploadToCloudinary(file, 'image');
setUploadingInlineImage(false);
if (result) {
exec('insertImage', result.url);
toast.success('Image inserted!');
} else toast.error('Upload failed — check Cloudinary preset config');
};
const handleSave = async (publish: boolean) => {
if (!form.title.trim()) { toast.error('Title is required'); return; }
if (!form.slug.trim()) { toast.error('Slug is required'); return; }
if (!contentHtml.trim() || contentHtml === '<br>') { toast.error('Content is required'); return; }
const slugTaken = await checkSlugExists(form.slug.trim(), editingId || undefined);
if (slugTaken) { toast.error('This slug is already used by another post'); return; }
setSaving(true);
const textOnly = contentHtml.replace(/<[^>]*>/g, ' ').trim();
const payload = {
title: form.title.trim(),
slug: form.slug.trim(),
content: contentHtml,
excerpt: form.excerpt.trim() || textOnly.slice(0, 200) + '…',
cover_image_url: form.cover_image_url.trim() || null,
video_url: form.video_url.trim() || null,
attachment_url: form.attachment_url.trim() || null,
attachment_name: form.attachment_name.trim() || null,
meta_title: form.meta_title.trim() || form.title.trim(),
meta_description: form.meta_description.trim() || '',
tags: form.tags.split(',').map((t) => t.trim()).filter(Boolean),
category_id: form.category_id || null,
subcategory_id: form.subcategory_id || null,
is_published: publish,
published_at: publish ? new Date().toISOString() : null,
author_name: user?.name || 'Admin',
read_time_minutes: estimateReadTime(contentHtml),
};
let result;
if (editingId) result = await updateBlog(editingId, payload);
else result = await createBlog(payload as any);
setSaving(false);
if (result) {
toast.success(publish ? 'Blog published!' : 'Saved as draft');
setView('list');
loadList();
} else {
toast.error('Failed to save blog');
}
};
const handleDelete = async () => {
if (!deleteId) return;
const ok = await deleteBlog(deleteId);
if (ok) { toast.success('Blog deleted'); setDeleteId(null); loadList(); }
else toast.error('Failed to delete');
};
const handleTogglePublish = async (blog: BlogListItem) => {
const updated = await updateBlog(blog.id, {
is_published: !blog.is_published,
published_at: !blog.is_published ? new Date().toISOString() : null,
});
if (updated) { toast.success(blog.is_published ? 'Unpublished' : 'Published!'); loadList(); }
else toast.error('Failed to update');
};
const totalPages = Math.ceil(total / PAGE_SIZE);
const ToolBtn: React.FC<{ icon: React.ReactNode; onClick: () => void; title: string }> = ({ icon, onClick, title }) => (
<button
type="button"
onClick={onClick}
title={title}
className="p-2 rounded-lg text-gray-600 dark:text-gray-300
hover:bg-gray-100 dark:hover:bg-white/[0.08]
border border-transparent hover:border-gray-200 dark:hover:border-white/[0.08] transition"
>
{icon}
</button>
);
if (view === 'editor') {
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-3">
<button
onClick={() => setView('list')}
className="p-2 rounded-xl border border-gray-200 dark:border-white/[0.08]
text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/[0.05] transition"
>
<ArrowLeft className="w-4 h-4" />
</button>
<div>
<h1 className="text-xl font-bold text-gray-800 dark:text-white">
{editingId ? 'Edit Article' : 'New Article'}
</h1>
{contentHtml && (
<p className="text-xs text-gray-400 mt-0.5">~{estimateReadTime(contentHtml)} min read</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setPreviewBlog({
id: editingId || 'preview',
title: form.title || 'Untitled',
slug: form.slug || 'preview',
content: contentHtml,
excerpt: form.excerpt,
cover_image_url: form.cover_image_url || null,
video_url: form.video_url || null,
attachment_url: form.attachment_url || null,
attachment_name: form.attachment_name || null,
meta_title: form.meta_title,
meta_description: form.meta_description,
tags: form.tags.split(',').map((t) => t.trim()).filter(Boolean),
category_id: form.category_id || null,
subcategory_id: form.subcategory_id || null,
is_published: false,
published_at: null,
author_name: user?.name || 'Admin',
read_time_minutes: estimateReadTime(contentHtml),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl border
border-gray-200 dark:border-white/[0.08]
text-gray-600 dark:text-gray-400
hover:bg-gray-50 dark:hover:bg-white/[0.05]
transition text-sm font-medium"
>
<ExternalLink className="w-4 h-4" /> Preview
</button>
<button
onClick={() => handleSave(false)}
disabled={saving}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl border
border-gray-200 dark:border-white/[0.08]
text-gray-600 dark:text-gray-400
hover:bg-gray-50 dark:hover:bg-white/[0.05]
transition text-sm font-medium disabled:opacity-50"
>
<Save className="w-4 h-4" /> Save Draft
</button>
<button
onClick={() => handleSave(true)}
disabled={saving}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl
bg-violet-600 hover:bg-violet-700 text-white
transition text-sm font-medium shadow-lg shadow-violet-900/30 disabled:opacity-50"
>
<Globe className="w-4 h-4" />
{saving ? 'Saving…' : 'Publish'}
</button>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div className="xl:col-span-2 space-y-4">
<div>
<label className={labelCls}>Article Title <span className="text-red-400">*</span></label>
<input
type="text" value={form.title}
onChange={(e) => handleTitleChange(e.target.value)}
className={inputCls + ' text-lg font-semibold'}
placeholder="Write an engaging title…"
/>
</div>
<div>
<label className={labelCls}>URL Slug <span className="text-red-400">*</span></label>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400 flex-shrink-0">/blog/</span>
<input
type="text" value={form.slug}
onChange={(e) => { setForm((p) => ({ ...p, slug: e.target.value })); setSlugManual(true); }}
className={inputCls}
placeholder="my-article-title"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}>Category</label>
<select
value={form.category_id}
onChange={(e) => setForm((p) => ({ ...p, category_id: e.target.value, subcategory_id: '' }))}
className={inputCls}
>
<option value="">None</option>
{categoryTree.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div>
<label className={labelCls}>Subcategory</label>
<select
value={form.subcategory_id}
onChange={(e) => setForm((p) => ({ ...p, subcategory_id: e.target.value }))}
disabled={!form.category_id}
className={inputCls + (!form.category_id ? ' opacity-50 cursor-not-allowed' : '')}
>
<option value="">None</option>
{subsForParent(form.category_id).map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
</div>
<div>
<label className={labelCls}>Excerpt <span className="text-gray-400 normal-case font-normal">(auto-generated if empty)</span></label>
<textarea
value={form.excerpt}
onChange={(e) => setForm((p) => ({ ...p, excerpt: e.target.value }))}
className={inputCls + ' resize-none'} rows={2}
placeholder="Short description shown in listings…"
/>
</div>
<div>
<label className={labelCls}>Content <span className="text-red-400">*</span></label>
<div className="flex flex-wrap gap-1 p-2 rounded-t-xl border border-b-0
border-gray-200 dark:border-white/[0.08]
bg-gray-50 dark:bg-[#0a1020]">
<ToolBtn icon={<Heading1 className="w-4 h-4" />} onClick={() => exec('formatBlock', 'H1')} title="Heading 1" />
<ToolBtn icon={<Heading2 className="w-4 h-4" />} onClick={() => exec('formatBlock', 'H2')} title="Heading 2" />
<ToolBtn icon={<Heading3 className="w-4 h-4" />} onClick={() => exec('formatBlock', 'H3')} title="Heading 3" />
<div className="w-px bg-gray-200 dark:bg-white/[0.08] mx-1" />
<ToolBtn icon={<Bold className="w-4 h-4" />} onClick={() => exec('bold')} title="Bold" />
<ToolBtn icon={<Italic className="w-4 h-4" />} onClick={() => exec('italic')} title="Italic" />
<ToolBtn icon={<Underline className="w-4 h-4" />} onClick={() => exec('underline')} title="Underline" />
<ToolBtn icon={<Strikethrough className="w-4 h-4" />} onClick={() => exec('strikeThrough')} title="Strikethrough" />
<div className="w-px bg-gray-200 dark:bg-white/[0.08] mx-1" />
<ToolBtn icon={<List className="w-4 h-4" />} onClick={() => exec('insertUnorderedList')} title="Bullet list" />
<ToolBtn icon={<ListOrdered className="w-4 h-4" />} onClick={() => exec('insertOrderedList')} title="Numbered list" />
<ToolBtn icon={<Quote className="w-4 h-4" />} onClick={() => exec('formatBlock', 'BLOCKQUOTE')} title="Quote" />
<ToolBtn icon={<Code className="w-4 h-4" />} onClick={() => exec('formatBlock', 'PRE')} title="Code block" />
<div className="w-px bg-gray-200 dark:bg-white/[0.08] mx-1" />
<ToolBtn icon={<Link2 className="w-4 h-4" />} onClick={insertLink} title="Insert link" />
<ToolBtn
icon={uploadingInlineImage ? <Loader2 className="w-4 h-4 animate-spin" /> : <ImageIcon className="w-4 h-4" />}
onClick={() => inlineImageInputRef.current?.click()}
title="Insert image"
/>
<input
ref={inlineImageInputRef} type="file" accept="image/*" className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleInlineImageUpload(f); e.target.value = ''; }}
/>
</div>
<div
ref={editorRef}
contentEditable
onInput={handleEditorInput}
className="w-full min-h-[420px] px-4 py-4 rounded-b-xl border
border-gray-200 dark:border-white/[0.08]
bg-white dark:bg-[#111d35] text-gray-900 dark:text-white
focus:outline-none focus:ring-2 focus:ring-violet-500/60 transition
prose prose-sm dark:prose-invert max-w-none
[&_h1]:text-2xl [&_h1]:font-bold [&_h1]:mt-4 [&_h1]:mb-2
[&_h2]:text-xl [&_h2]:font-bold [&_h2]:mt-4 [&_h2]:mb-2
[&_h3]:text-lg [&_h3]:font-bold [&_h3]:mt-3 [&_h3]:mb-2
[&_blockquote]:border-l-4 [&_blockquote]:border-violet-400 [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:text-gray-500
[&_pre]:bg-gray-100 dark:[&_pre]:bg-black/30 [&_pre]:p-3 [&_pre]:rounded-lg [&_pre]:font-mono [&_pre]:text-sm
[&_ul]:list-disc [&_ul]:pl-6 [&_ol]:list-decimal [&_ol]:pl-6
[&_a]:text-violet-500 [&_a]:underline
[&_img]:rounded-xl [&_img]:my-3 [&_img]:max-w-full"
style={{ scrollbarWidth: 'thin', scrollbarColor: 'rgba(139,92,246,0.3) transparent' }}
data-placeholder="Start writing your article…"
suppressContentEditableWarning
/>
<p className="text-[11px] text-gray-400 mt-1">
Rich text editor — formatting is preserved exactly as written and shown identically to readers
</p>
</div>
</div>
<div className="space-y-4">
<div className="bg-white dark:bg-[#0d1526] rounded-2xl border border-gray-100 dark:border-white/[0.06] p-4">
<h3 className="text-xs font-bold text-gray-700 dark:text-white uppercase tracking-wide mb-3 flex items-center gap-2">
<ImageIcon className="w-3.5 h-3.5" /> Cover Image
</h3>
<button
onClick={() => coverInputRef.current?.click()}
disabled={uploadingCover}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl
border-2 border-dashed border-gray-200 dark:border-white/[0.1]
hover:border-violet-400 dark:hover:border-violet-500 transition text-sm
text-gray-500 dark:text-gray-400 disabled:opacity-50"
>
{uploadingCover
? <><Loader2 className="w-4 h-4 animate-spin" /> Uploading…</>
: <><ImageIcon className="w-4 h-4" /> Upload Cover</>
}
</button>
<input
ref={coverInputRef} type="file" accept="image/*" className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleCoverUpload(f); e.target.value = ''; }}
/>
{form.cover_image_url && (
<div className="relative mt-3">
<img src={form.cover_image_url} alt="Cover" className="w-full h-32 object-cover rounded-xl" />
<button
onClick={() => setForm((p) => ({ ...p, cover_image_url: '' }))}
className="absolute top-1.5 right-1.5 p-1 rounded-lg bg-black/60 text-white hover:bg-black/80 transition"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
<div className="bg-white dark:bg-[#0d1526] rounded-2xl border border-gray-100 dark:border-white/[0.06] p-4">
<h3 className="text-xs font-bold text-gray-700 dark:text-white uppercase tracking-wide mb-3 flex items-center gap-2">
<Video className="w-3.5 h-3.5" /> Video (optional)
</h3>
<button
onClick={() => videoInputRef.current?.click()}
disabled={uploadingVideo}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl
border-2 border-dashed border-gray-200 dark:border-white/[0.1]
hover:border-violet-400 dark:hover:border-violet-500 transition text-sm
text-gray-500 dark:text-gray-400 disabled:opacity-50"
>
{uploadingVideo
? <><Loader2 className="w-4 h-4 animate-spin" /> Uploading…</>
: <><Video className="w-4 h-4" /> Upload Video</>
}
</button>
<input
ref={videoInputRef} type="file" accept="video/*" className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleVideoUpload(f); e.target.value = ''; }}
/>
{form.video_url && (
<div className="mt-3 flex items-center justify-between text-xs bg-gray-50 dark:bg-white/[0.03] rounded-lg px-3 py-2">
<span className="truncate text-gray-600 dark:text-gray-300">Video attached ✓</span>
<button onClick={() => setForm((p) => ({ ...p, video_url: '' }))} className="text-red-400 hover:text-red-500 flex-shrink-0 ml-2">
<X className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
<div className="bg-white dark:bg-[#0d1526] rounded-2xl border border-gray-100 dark:border-white/[0.06] p-4">
<h3 className="text-xs font-bold text-gray-700 dark:text-white uppercase tracking-wide mb-3 flex items-center gap-2">
<Paperclip className="w-3.5 h-3.5" /> Downloadable File (optional)
</h3>
<button
onClick={() => attachmentInputRef.current?.click()}
disabled={uploadingAttachment}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl
border-2 border-dashed border-gray-200 dark:border-white/[0.1]
hover:border-violet-400 dark:hover:border-violet-500 transition text-sm
text-gray-500 dark:text-gray-400 disabled:opacity-50"
>
{uploadingAttachment
? <><Loader2 className="w-4 h-4 animate-spin" /> Uploading…</>
: <><Paperclip className="w-4 h-4" /> Upload PDF / File</>
}
</button>
<input
ref={attachmentInputRef} type="file" className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleAttachmentUpload(f); e.target.value = ''; }}
/>
{form.attachment_url && (
<div className="mt-3 flex items-center justify-between text-xs bg-gray-50 dark:bg-white/[0.03] rounded-lg px-3 py-2">
<span className="truncate text-gray-600 dark:text-gray-300">{form.attachment_name}</span>
<button onClick={() => setForm((p) => ({ ...p, attachment_url: '', attachment_name: '' }))} className="text-red-400 hover:text-red-500 flex-shrink-0 ml-2">
<X className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
<div className="bg-white dark:bg-[#0d1526] rounded-2xl border border-gray-100 dark:border-white/[0.06] p-4">
<h3 className="text-xs font-bold text-gray-700 dark:text-white uppercase tracking-wide mb-3 flex items-center gap-2">
<Tag className="w-3.5 h-3.5" /> Tags
</h3>
<input
type="text" value={form.tags}
onChange={(e) => setForm((p) => ({ ...p, tags: e.target.value }))}
className={inputCls}
placeholder="math, tips, exam-prep"
/>
{form.tags && (
<div className="flex flex-wrap gap-1.5 mt-2">
{form.tags.split(',').map((t) => t.trim()).filter(Boolean).map((tag) => (
<span key={tag} className="text-[11px] px-2 py-0.5 rounded-full
bg-violet-100 dark:bg-violet-500/20 text-violet-700 dark:text-violet-400 font-medium">
{tag}
</span>
))}
</div>
)}
</div>
<div className="bg-white dark:bg-[#0d1526] rounded-2xl border border-gray-100 dark:border-white/[0.06] p-4">
<h3 className="text-xs font-bold text-gray-700 dark:text-white uppercase tracking-wide mb-3 flex items-center gap-2">
<Globe className="w-3.5 h-3.5" /> SEO Settings
</h3>
<div className="space-y-3">
<div>
<label className={labelCls}>Meta Title</label>
<input
type="text" value={form.meta_title}
onChange={(e) => setForm((p) => ({ ...p, meta_title: e.target.value }))}
className={inputCls} placeholder={form.title || 'SEO title'} maxLength={60}
/>
<div className="flex justify-between mt-1">
<p className="text-[11px] text-gray-400">Ideal: 50–60 chars</p>
<span className={`text-[11px] font-medium ${form.meta_title.length > 60 ? 'text-red-400' : 'text-gray-400'}`}>
{form.meta_title.length}/60
</span>
</div>
</div>
<div>
<label className={labelCls}>Meta Description</label>
<textarea
value={form.meta_description}
onChange={(e) => setForm((p) => ({ ...p, meta_description: e.target.value }))}
className={inputCls + ' resize-none'} rows={3} maxLength={160}
placeholder="Compelling description for search engines…"
/>
<div className="flex justify-between mt-1">
<p className="text-[11px] text-gray-400">Ideal: 120–160 chars</p>
<span className={`text-[11px] font-medium ${form.meta_description.length > 160 ? 'text-red-400' : 'text-gray-400'}`}>
{form.meta_description.length}/160
</span>
</div>
</div>
</div>
<div className="mt-4 p-3 rounded-xl bg-gray-50 dark:bg-[#060b17] border border-gray-200 dark:border-white/[0.06]">
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide mb-2">Google Preview</p>
<p className="text-blue-600 dark:text-blue-400 text-sm font-medium truncate">
{form.meta_title || form.title || 'Article Title'}
</p>
<p className="text-[11px] text-green-600 dark:text-green-400 mt-0.5">
{typeof window !== 'undefined' ? window.location.origin.replace(/^https?:\/\//, '') : 'yourdomain.com'}/blog/{form.slug || 'your-slug'}
</p>
<p className="text-[11px] text-gray-500 dark:text-gray-400 mt-0.5 line-clamp-2">
{form.meta_description || form.excerpt || 'Meta description appears here…'}
</p>
</div>
</div>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Manage Blogs</h1>
<p className="text-sm text-gray-400 mt-0.5">
{total} article{total !== 1 ? 's' : ''} total
</p>
</div>
<button
onClick={openNew}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl
bg-violet-600 hover:bg-violet-700 text-white
transition text-sm font-medium shadow-lg shadow-violet-900/30"
>
<Plus className="w-4 h-4" /> New Article
</button>
</div>
<div className="relative">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text" value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="Search by title…"
className={inputCls + ' pl-10'}
/>
</div>
{loading ? (
<div className="flex justify-center py-16">
<div className="w-10 h-10 border-[3px] border-violet-500/30 border-t-violet-500 rounded-full animate-spin" />
</div>
) : blogs.length === 0 ? (
<div className="text-center py-20">
<FileText className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" />
<p className="text-gray-500 dark:text-gray-400 text-sm">
{search ? 'No articles match your search.' : 'No articles yet. Create your first one!'}
</p>
</div>
) : (
<>
<div className="space-y-3">
{blogs.map((blog) => (
<div
key={blog.id}
className="bg-white dark:bg-[#0d1526] rounded-2xl border border-gray-100 dark:border-white/[0.06]
p-4 flex flex-col sm:flex-row gap-4 hover:shadow-md dark:hover:shadow-violet-900/10 transition"
>
{blog.cover_image_url && (
<img src={blog.cover_image_url} alt={blog.title}
className="w-full sm:w-28 h-20 object-cover rounded-xl flex-shrink-0" loading="lazy" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-1">
<h3 className="text-sm font-bold text-gray-800 dark:text-white line-clamp-1">{blog.title}</h3>
<span className={`text-[11px] px-2 py-0.5 rounded-full font-semibold flex-shrink-0 ${
blog.is_published
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400'
: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400'
}`}>
{blog.is_published ? 'Published' : 'Draft'}
</span>
</div>
<p className="text-xs text-gray-400 font-mono mb-1">/blog/{blog.slug}</p>
{blog.excerpt && <p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2 mb-2">{blog.excerpt}</p>}
<div className="flex items-center gap-3 flex-wrap">
{(blog.tags || []).slice(0, 3).map((tag) => (
<span key={tag} className="text-[11px] px-2 py-0.5 rounded-full
bg-violet-100 dark:bg-violet-500/20 text-violet-700 dark:text-violet-400 font-medium">
{tag}
</span>
))}
<span className="text-[11px] text-gray-400 flex items-center gap-1 ml-auto">
<Calendar className="w-3 h-3" />
{new Date(blog.updated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
</div>
</div>
<div className="flex sm:flex-col gap-1.5 flex-shrink-0 sm:w-9">
<button onClick={() => openPreview(blog.id)}
className="w-9 h-9 flex items-center justify-center rounded-lg text-violet-500 hover:bg-violet-50 dark:hover:bg-violet-500/10 transition" title="Preview">
<ExternalLink className="w-4 h-4" />
</button>
<button onClick={() => openEdit(blog.id)}
className="w-9 h-9 flex items-center justify-center rounded-lg text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-500/10 transition" title="Edit">
<Edit2 className="w-4 h-4" />
</button>
<button onClick={() => handleTogglePublish(blog)}
className="w-9 h-9 flex items-center justify-center rounded-lg text-emerald-500 hover:bg-emerald-50 dark:hover:bg-emerald-500/10 transition" title={blog.is_published ? 'Unpublish' : 'Publish'}>
{blog.is_published ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
<button onClick={() => setDeleteId(blog.id)}
className="w-9 h-9 flex items-center justify-center rounded-lg text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10 transition" title="Delete">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between pt-2">
<p className="text-xs text-gray-400">
Page {page + 1} of {totalPages} · {total} total articles
</p>
<div className="flex gap-2">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="p-2 rounded-lg border border-gray-200 dark:border-white/[0.08]
text-gray-500 dark:text-gray-400 disabled:opacity-30 disabled:cursor-not-allowed
hover:bg-gray-50 dark:hover:bg-white/[0.05] transition"
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="p-2 rounded-lg border border-gray-200 dark:border-white/[0.08]
text-gray-500 dark:text-gray-400 disabled:opacity-30 disabled:cursor-not-allowed
hover:bg-gray-50 dark:hover:bg-white/[0.05] transition"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</>
)}
<Modal isOpen={!!deleteId} onClose={() => setDeleteId(null)} title="Delete Article">
<div className="text-center py-2">
<div className="w-14 h-14 bg-red-100 dark:bg-red-500/15 rounded-full flex items-center justify-center mx-auto mb-4">
<Trash2 className="w-6 h-6 text-red-500" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6 max-w-xs mx-auto">
This will permanently delete this article. This cannot be undone.
</p>
<div className="flex justify-center gap-3">
<button onClick={() => setDeleteId(null)} className="px-5 py-2.5 rounded-xl border border-gray-200 dark:border-white/10 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/[0.05] transition text-sm">
Cancel
</button>
<button onClick={handleDelete} className="px-5 py-2.5 rounded-xl bg-red-600 hover:bg-red-700 text-white text-sm font-medium transition">
Delete
</button>
</div>
</div>
</Modal>
{/* ── Full WordPress-style Preview Modal ── */}
{previewBlog && (
<div className="fixed inset-0 z-[100] flex items-start justify-center overflow-y-auto bg-black/70 backdrop-blur-sm p-4 sm:p-8">
<div className="w-full max-w-3xl bg-white dark:bg-[#0d1526] rounded-2xl shadow-2xl my-4 overflow-hidden">
{/* Preview toolbar */}
<div className="sticky top-0 z-10 flex items-center justify-between px-5 py-3
bg-gray-900 dark:bg-black text-white">
<div className="flex items-center gap-2 text-xs">
<ExternalLink className="w-3.5 h-3.5" />
<span className="font-medium">Live Preview</span>
<span className="text-gray-400">— exactly how readers will see this article</span>
</div>
<button
onClick={() => setPreviewBlog(null)}
className="p-1.5 rounded-lg hover:bg-white/10 transition"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Rendered article — same structure as public blog page will use */}
<article className="px-6 sm:px-10 py-8">
{previewBlog.cover_image_url && (
<img
src={previewBlog.cover_image_url}
alt={previewBlog.title}
className="w-full h-56 sm:h-72 object-cover rounded-2xl mb-6"
/>
)}
{/* Category / tags */}
<div className="flex items-center gap-2 flex-wrap mb-3">
{(previewBlog.tags || []).map((tag) => (
<span key={tag} className="text-[11px] px-2.5 py-1 rounded-full
bg-violet-100 dark:bg-violet-500/20 text-violet-700 dark:text-violet-400 font-semibold">
{tag}
</span>
))}
</div>
{/* Title */}
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-3 leading-tight">
{previewBlog.title || 'Untitled Article'}
</h1>
{/* Meta row */}
<div className="flex items-center gap-3 text-sm text-gray-500 dark:text-gray-400 mb-8 pb-6
border-b border-gray-100 dark:border-white/[0.08]">
<span>{previewBlog.author_name || 'Admin'}</span>
<span>·</span>
<span>{new Date(previewBlog.updated_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}</span>
<span>·</span>
<span>{previewBlog.read_time_minutes} min read</span>
</div>
{/* Video if present */}
{previewBlog.video_url && (
<video controls className="w-full rounded-2xl mb-6">
<source src={previewBlog.video_url} />
</video>
)}
{/* Main content — rendered exactly as written */}
<div
className="prose prose-lg dark:prose-invert max-w-none
[&_h1]:text-2xl [&_h1]:font-bold [&_h1]:mt-6 [&_h1]:mb-3
[&_h2]:text-xl [&_h2]:font-bold [&_h2]:mt-6 [&_h2]:mb-3
[&_h3]:text-lg [&_h3]:font-bold [&_h3]:mt-4 [&_h3]:mb-2
[&_p]:mb-4 [&_p]:leading-relaxed
[&_blockquote]:border-l-4 [&_blockquote]:border-violet-400 [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:text-gray-500 [&_blockquote]:my-4
[&_pre]:bg-gray-100 dark:[&_pre]:bg-black/30 [&_pre]:p-4 [&_pre]:rounded-xl [&_pre]:font-mono [&_pre]:text-sm [&_pre]:my-4 [&_pre]:overflow-x-auto
[&_ul]:list-disc [&_ul]:pl-6 [&_ul]:mb-4 [&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:mb-4
[&_a]:text-violet-500 [&_a]:underline
[&_img]:rounded-xl [&_img]:my-4 [&_img]:max-w-full"
dangerouslySetInnerHTML={{ __html: previewBlog.content || '<p class="text-gray-400 italic">No content yet…</p>' }}
/>
{/* Downloadable attachment */}
{previewBlog.attachment_url && (
<a
href={previewBlog.attachment_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 mt-8 p-4 rounded-xl
bg-violet-50 dark:bg-violet-500/10
border border-violet-200 dark:border-violet-500/25
hover:bg-violet-100 dark:hover:bg-violet-500/15 transition"
>
<Paperclip className="w-5 h-5 text-violet-500 flex-shrink-0" />
<div>
<p className="text-sm font-semibold text-violet-700 dark:text-violet-300">Download Attachment</p>
<p className="text-xs text-violet-500 dark:text-violet-400">{previewBlog.attachment_name}</p>
</div>
</a>
)}
</article>
</div>
</div>
)}
</div>
);
};
export default ManageBlogs;