Files
homaroma/apps/admin/src/pages/ArticleEditPage.tsx
T

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>
)
}