287 lines
12 KiB
TypeScript
287 lines
12 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import type { BlogPost } from '../../../types';
|
|
import { apiClient } from '../../../lib/api-client';
|
|
import { useAuth } from '../../auth/store/AuthContext';
|
|
import { BookOpen, User, Calendar, Clock, Plus, X, Sparkles, Send } from 'lucide-react';
|
|
|
|
export const BlogCatalog: React.FC = () => {
|
|
const { user } = useAuth();
|
|
const [posts, setPosts] = useState<BlogPost[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const isAdmin = user?.role === 'ADMIN';
|
|
|
|
// CMS modal state
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [title, setTitle] = useState('');
|
|
const [content, setContent] = useState('');
|
|
const [tagsInput, setTagsInput] = useState('');
|
|
const [thumbnailUrl, setThumbnailUrl] = useState('');
|
|
const [status, setStatus] = useState<'draft' | 'published'>('draft');
|
|
const [formError, setFormError] = useState('');
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
const fetchPosts = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const response = await apiClient.get<BlogPost[]>('/blog');
|
|
setPosts(response.data);
|
|
} catch (err) {
|
|
console.error(err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchPosts();
|
|
}, []);
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setFormError('');
|
|
|
|
if (!title.trim() || !content.trim()) {
|
|
setFormError('Title and content are required.');
|
|
return;
|
|
}
|
|
|
|
setSubmitting(true);
|
|
try {
|
|
const tags = tagsInput.split(',').map(t => t.trim()).filter(t => t.length > 0);
|
|
const payload = {
|
|
title,
|
|
content,
|
|
tags,
|
|
thumbnailUrl: thumbnailUrl.trim() || undefined,
|
|
status,
|
|
author: 'Technical Architect',
|
|
};
|
|
|
|
const response = await apiClient.post<BlogPost>('/blog/new', payload);
|
|
setPosts(prev => [response.data, ...prev]);
|
|
|
|
setTitle('');
|
|
setContent('');
|
|
setTagsInput('');
|
|
setThumbnailUrl('');
|
|
setStatus('draft');
|
|
setIsOpen(false);
|
|
} catch (err) {
|
|
setFormError('Failed to publish article.');
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6 text-ink-900">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-xl font-bold text-ink-800">Engineering Blog & Insights</h2>
|
|
<p className="text-sm text-ink-600">Deep-dives into RISC-V pipelining, CodeNuk scaffolding practices, and edge security optimizations.</p>
|
|
</div>
|
|
{isAdmin && (
|
|
<button
|
|
onClick={() => setIsOpen(true)}
|
|
className="flex items-center gap-1.5 rounded-lg bg-ink-900 px-4 py-2.5 text-xs font-semibold text-ink-0 hover:bg-ink-800 transition-all shadow-sm"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Write Post
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="grid gap-6 md:grid-cols-2">
|
|
{[1, 2].map(n => (
|
|
<div key={n} className="animate-pulse rounded-xl border border-ink-200 bg-ink-0 p-5 space-y-4">
|
|
<div className="h-48 rounded-lg bg-ink-100" />
|
|
<div className="h-4 w-3/4 rounded bg-ink-100" />
|
|
<div className="h-20 rounded bg-ink-100" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : posts.length === 0 ? (
|
|
<div className="rounded-xl border border-ink-200 bg-ink-0 p-12 text-center">
|
|
<BookOpen className="h-8 w-8 text-ink-300 mx-auto mb-2" />
|
|
<p className="text-sm text-ink-600">No blog posts published yet.</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-6 md:grid-cols-2">
|
|
{posts.map(post => (
|
|
<article
|
|
key={post.id}
|
|
className="rounded-xl border border-ink-200 bg-ink-0 shadow-sm overflow-hidden flex flex-col hover:border-ink-400 transition-all duration-300"
|
|
>
|
|
<div className="h-48 w-full bg-ink-100 relative">
|
|
<img
|
|
src={post.thumbnailUrl}
|
|
alt={post.title}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
{isAdmin && (
|
|
<span className={`absolute right-3 top-3 rounded-full border px-2.5 py-0.5 text-[9px] font-bold uppercase tracking-wider ${
|
|
post.status === 'published' ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20' : 'bg-ink-100 text-ink-700 border-ink-200'
|
|
}`}>
|
|
{post.status}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="p-5 flex-1 flex flex-col justify-between space-y-4">
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-3 text-[10px] font-bold text-ink-600 uppercase tracking-wide">
|
|
<span className="flex items-center gap-1">
|
|
<User className="h-3.5 w-3.5" />
|
|
{post.author}
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<Calendar className="h-3.5 w-3.5" />
|
|
{post.publishDate}
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<Clock className="h-3.5 w-3.5" />
|
|
{post.readTime}
|
|
</span>
|
|
</div>
|
|
<h3 className="text-base font-bold text-ink-800 leading-snug line-clamp-1">{post.title}</h3>
|
|
<p className="text-xs text-ink-600 leading-relaxed line-clamp-3">{post.content}</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-1.5 pt-2 border-t border-ink-100">
|
|
{post.tags.map(t => (
|
|
<span key={t} className="rounded bg-ink-50 px-2 py-0.5 text-[9px] font-semibold text-ink-700 border border-ink-200">
|
|
#{t}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Post Creator Modal */}
|
|
{isOpen && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/20 backdrop-blur-sm p-4 animate-fade-in">
|
|
<div className="w-full max-w-lg rounded-2xl border border-ink-200 bg-ink-0 p-6 shadow-xl max-h-[90vh] overflow-y-auto relative animate-scale-up">
|
|
<button
|
|
onClick={() => setIsOpen(false)}
|
|
className="absolute right-4 top-4 rounded-full p-1.5 text-ink-600 hover:bg-ink-100 hover:text-ink-800 transition-colors"
|
|
>
|
|
<X className="h-5 w-5" />
|
|
</button>
|
|
|
|
<div className="mb-5">
|
|
<h3 className="text-lg font-bold text-ink-800 flex items-center gap-2">
|
|
<Sparkles className="h-5 w-5 text-ink-900" />
|
|
Write Blog Article
|
|
</h3>
|
|
<p className="text-xs text-ink-600">Draft or publish a technical write-up for the developer channel.</p>
|
|
</div>
|
|
|
|
{formError && (
|
|
<div className="mb-4 rounded-lg bg-red-500/10 border border-red-500/20 p-3 text-xs font-semibold text-red-600 dark:text-red-400">
|
|
{formError}
|
|
</div>
|
|
)}
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div>
|
|
<label className="block text-xs font-semibold text-ink-700 mb-1">Article Title</label>
|
|
<input
|
|
type="text"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
placeholder="e.g. Optimizing Pipeline Hazards in RV64GC Core Designs"
|
|
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-ink-900/50 focus:outline-none bg-ink-50 text-ink-900 placeholder-ink-400"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-semibold text-ink-700 mb-1">Content (Markdown supported)</label>
|
|
<textarea
|
|
value={content}
|
|
onChange={(e) => setContent(e.target.value)}
|
|
placeholder="Write the full post text..."
|
|
rows={6}
|
|
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-ink-900/50 focus:outline-none resize-y bg-ink-50 text-ink-900 placeholder-ink-400"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-semibold text-ink-700 mb-1">Tags (comma-separated)</label>
|
|
<input
|
|
type="text"
|
|
value={tagsInput}
|
|
onChange={(e) => setTagsInput(e.target.value)}
|
|
placeholder="RISC-V, RTL-Design, Edge-Compute"
|
|
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-ink-900/50 focus:outline-none bg-ink-50 text-ink-900 placeholder-ink-400"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-semibold text-ink-700 mb-1">Article Cover Photo URL</label>
|
|
<input
|
|
type="url"
|
|
value={thumbnailUrl}
|
|
onChange={(e) => setThumbnailUrl(e.target.value)}
|
|
placeholder="https://images.unsplash.com/..."
|
|
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-ink-900/50 focus:outline-none bg-ink-50 text-ink-900 placeholder-ink-400"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-semibold text-ink-700 mb-1">Publish Status</label>
|
|
<div className="flex gap-4 mt-2">
|
|
<label className="flex items-center gap-1.5 text-xs text-ink-800 font-semibold cursor-pointer">
|
|
<input
|
|
type="radio"
|
|
name="blogStatus"
|
|
checked={status === 'draft'}
|
|
onChange={() => setStatus('draft')}
|
|
className="text-ink-900 focus:ring-ink-900/20"
|
|
/>
|
|
Draft
|
|
</label>
|
|
<label className="flex items-center gap-1.5 text-xs text-ink-800 font-semibold cursor-pointer">
|
|
<input
|
|
type="radio"
|
|
name="blogStatus"
|
|
checked={status === 'published'}
|
|
onChange={() => setStatus('published')}
|
|
className="text-ink-900 focus:ring-ink-900/20"
|
|
/>
|
|
Published
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t border-ink-100 pt-4 flex justify-end gap-3 mt-6">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsOpen(false)}
|
|
className="rounded-lg border border-ink-200 bg-ink-0 px-4 py-2 text-sm font-semibold text-ink-600 hover:bg-ink-50"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={submitting}
|
|
className="rounded-lg bg-ink-900 px-5 py-2 text-sm font-semibold text-ink-0 hover:bg-ink-800 flex items-center gap-1.5"
|
|
>
|
|
<Send className="h-4 w-4" />
|
|
{submitting ? 'Publishing...' : 'Publish Article'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
export default BlogCatalog;
|