222 lines
8.7 KiB
TypeScript
222 lines
8.7 KiB
TypeScript
import { useState, useEffect, useMemo, useRef } from 'react'
|
|
import { useParams, useNavigate, Link } from 'react-router-dom'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { API_BASE } from '@homaroma/types'
|
|
import { getAuthHeaders } from '../utils/auth'
|
|
import { ArrowLeft, Save, Loader2, Image as ImageIcon } from 'lucide-react'
|
|
import ReactQuill from 'react-quill-new'
|
|
import 'react-quill-new/dist/quill.snow.css'
|
|
import MediaLibraryModal from '../components/MediaLibraryModal'
|
|
|
|
export default function ArticleEditPage() {
|
|
const { t } = useTranslation()
|
|
const { id } = useParams<{ id: string }>()
|
|
const isNew = id === 'new'
|
|
const navigate = useNavigate()
|
|
const quillRef = useRef<any>(null)
|
|
|
|
const [loading, setLoading] = useState(!isNew)
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
const [title, setTitle] = useState('')
|
|
const [slug, setSlug] = useState('')
|
|
const [coverImage, setCoverImage] = useState('')
|
|
const [content, setContent] = useState('')
|
|
const [isPublished, setIsPublished] = useState(false)
|
|
|
|
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
|
|
const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (isNew) return
|
|
fetch(`${API_BASE}/articles/id/${id}`)
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
setTitle(data.title)
|
|
setSlug(data.slug)
|
|
setCoverImage(data.coverImage || '')
|
|
setContent(data.content)
|
|
setIsPublished(data.isPublished)
|
|
})
|
|
.catch(console.error)
|
|
.finally(() => setLoading(false))
|
|
}, [id, isNew])
|
|
|
|
// Automatically generate slug from title if it's new
|
|
useEffect(() => {
|
|
if (isNew && title && !slug.includes('-')) {
|
|
setSlug(title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, ''))
|
|
}
|
|
}, [title, isNew])
|
|
|
|
const openMediaModal = (onSelect: (url: string) => void) => {
|
|
setOnSelectMedia(() => onSelect)
|
|
setIsMediaModalOpen(true)
|
|
}
|
|
const handleSave = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
setSaving(true)
|
|
try {
|
|
const payload = { title, slug, coverImage, content, isPublished }
|
|
const res = await fetch(`${API_BASE}/articles${isNew ? '' : `/${id}`}`, {
|
|
method: isNew ? 'POST' : 'PUT',
|
|
headers: getAuthHeaders(),
|
|
body: JSON.stringify(payload)
|
|
})
|
|
if (!res.ok) throw new Error(await res.text())
|
|
navigate('/articles')
|
|
} catch (err) {
|
|
console.error(err)
|
|
alert('Failed to save article. Make sure the slug is unique.')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const imageHandler = () => {
|
|
openMediaModal((url) => {
|
|
const quill = quillRef.current?.getEditor()
|
|
if (quill) {
|
|
const range = quill.getSelection(true)
|
|
quill.insertEmbed(range?.index || 0, 'image', url)
|
|
}
|
|
})
|
|
}
|
|
|
|
const modules = useMemo(() => ({
|
|
toolbar: {
|
|
container: [
|
|
[{ 'header': [1, 2, 3, false] }],
|
|
['bold', 'italic', 'underline', 'strike', 'blockquote'],
|
|
[{'list': 'ordered'}, {'list': 'bullet'}],
|
|
['link', 'image'],
|
|
['clean']
|
|
],
|
|
handlers: {
|
|
image: imageHandler
|
|
}
|
|
}
|
|
}), [])
|
|
|
|
if (loading) return <div className="p-8 text-center"><Loader2 className="w-8 h-8 animate-spin mx-auto text-cyan-glow" /></div>
|
|
|
|
return (
|
|
<div className="max-w-4xl space-y-8 animate-fade-in relative pb-20">
|
|
<div className="flex items-center gap-4 border-b border-white/5 pb-6">
|
|
<Link to="/articles" className="p-2 glass rounded-full hover:bg-white/10 transition-colors">
|
|
<ArrowLeft className="w-5 h-5 text-slate-400" />
|
|
</Link>
|
|
<div>
|
|
<h1 className="text-2xl font-bold">{isNew ? 'New Article' : 'Edit Article'}</h1>
|
|
<p className="text-slate-500 text-sm">Write engaging guides for your audience.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<form onSubmit={handleSave} className="space-y-6">
|
|
<div className="grid md:grid-cols-3 gap-6">
|
|
<div className="md:col-span-2 space-y-6">
|
|
<div className="space-y-2">
|
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">Post Title</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
value={title}
|
|
onChange={e => setTitle(e.target.value)}
|
|
className="admin-input w-full text-lg font-bold"
|
|
placeholder="e.g. 5 Benefits of Lavender Oil"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">URL Slug</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
value={slug}
|
|
onChange={e => setSlug(e.target.value)}
|
|
className="admin-input w-full font-mono text-sm text-cyan-glow"
|
|
placeholder="e.g. benefits-of-lavender"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">Cover Image</label>
|
|
<div
|
|
className="relative border-2 border-dashed border-white/10 rounded-xl bg-night-950 hover:border-cyan-glow/50 transition-colors group cursor-pointer overflow-hidden aspect-[4/3] flex flex-col items-center justify-center text-center"
|
|
onClick={() => openMediaModal((url) => setCoverImage(url))}
|
|
>
|
|
{coverImage ? (
|
|
<img src={coverImage} className="w-full h-full object-cover opacity-80 group-hover:opacity-100 transition-opacity" alt="Cover" />
|
|
) : (
|
|
<div className="space-y-2 p-4">
|
|
<ImageIcon className="w-8 h-8 text-slate-500 mx-auto group-hover:text-cyan-glow transition-colors" />
|
|
<p className="text-[10px] text-slate-500 uppercase tracking-widest group-hover:text-cyan-glow transition-colors">Select Cover Image</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">Content Body</label>
|
|
<div className="bg-white text-black rounded-lg overflow-hidden prose-editor-container">
|
|
<ReactQuill
|
|
ref={quillRef}
|
|
theme="snow"
|
|
value={content}
|
|
onChange={setContent}
|
|
modules={modules}
|
|
className="min-h-[400px]"
|
|
/>
|
|
</div>
|
|
<style>{`
|
|
.ql-editor { min-height: 400px; font-size: 16px; }
|
|
`}</style>
|
|
</div>
|
|
|
|
<div className="fixed bottom-0 right-0 left-64 p-4 glass border-t border-white/5 flex justify-end gap-4 z-10 hidden md:flex">
|
|
<label className="flex items-center gap-3 mr-auto cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={isPublished}
|
|
onChange={e => setIsPublished(e.target.checked)}
|
|
className="w-5 h-5 rounded border-white/20 bg-night-950 text-cyan-glow focus:ring-cyan-glow"
|
|
/>
|
|
<span className="text-sm font-bold uppercase tracking-widest">Publish to Website</span>
|
|
</label>
|
|
<Link to="/articles" className="btn-secondary px-8 py-3 font-bold text-sm">{t('common.cancel', 'Cancel')}</Link>
|
|
<button type="submit" disabled={saving} className="btn-primary px-10 py-3 flex items-center gap-2 text-sm font-bold cursor-pointer">
|
|
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
|
{saving ? t('common.saving', 'Saving...') : t('common.save', 'Save Article')}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Mobile Actions */}
|
|
<div className="md:hidden space-y-4 pt-8">
|
|
<label className="flex items-center gap-3 p-4 bg-white/5 border border-white/10 rounded-xl">
|
|
<input
|
|
type="checkbox"
|
|
checked={isPublished}
|
|
onChange={e => setIsPublished(e.target.checked)}
|
|
className="w-5 h-5 rounded"
|
|
/>
|
|
<span className="text-sm font-bold uppercase">Publish to Website</span>
|
|
</label>
|
|
<button type="submit" disabled={saving} className="btn-primary w-full py-4 text-center justify-center flex items-center gap-2">
|
|
{saving ? <Loader2 className="w-5 h-5 animate-spin" /> : <Save className="w-5 h-5" />}
|
|
Save Article
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
<MediaLibraryModal
|
|
isOpen={isMediaModalOpen}
|
|
onClose={() => setIsMediaModalOpen(false)}
|
|
onSelect={(url) => {
|
|
if (onSelectMedia) onSelectMedia(url)
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|