Add admin panel
This commit is contained in:
261
frontend/src/pages/admin/AdminContentPage.tsx
Normal file
261
frontend/src/pages/admin/AdminContentPage.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { adminApi } from '@/api'
|
||||
import type { StaticContent } from '@/types'
|
||||
import { useToast } from '@/store/toast'
|
||||
import { NeonButton } from '@/components/ui'
|
||||
import { FileText, Plus, Pencil, X, Save, Code } from 'lucide-react'
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function AdminContentPage() {
|
||||
const [contents, setContents] = useState<StaticContent[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editing, setEditing] = useState<StaticContent | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Form state
|
||||
const [formKey, setFormKey] = useState('')
|
||||
const [formTitle, setFormTitle] = useState('')
|
||||
const [formContent, setFormContent] = useState('')
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
loadContents()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const loadContents = async () => {
|
||||
try {
|
||||
const data = await adminApi.listContent()
|
||||
setContents(data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load contents:', err)
|
||||
toast.error('Ошибка загрузки контента')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (content: StaticContent) => {
|
||||
setEditing(content)
|
||||
setFormKey(content.key)
|
||||
setFormTitle(content.title)
|
||||
setFormContent(content.content)
|
||||
setCreating(false)
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
setCreating(true)
|
||||
setEditing(null)
|
||||
setFormKey('')
|
||||
setFormTitle('')
|
||||
setFormContent('')
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditing(null)
|
||||
setCreating(false)
|
||||
setFormKey('')
|
||||
setFormTitle('')
|
||||
setFormContent('')
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formTitle.trim() || !formContent.trim()) {
|
||||
toast.error('Заполните все поля')
|
||||
return
|
||||
}
|
||||
|
||||
if (creating && !formKey.trim()) {
|
||||
toast.error('Введите ключ')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
if (creating) {
|
||||
const newContent = await adminApi.createContent(formKey, formTitle, formContent)
|
||||
setContents([...contents, newContent])
|
||||
toast.success('Контент создан')
|
||||
} else if (editing) {
|
||||
const updated = await adminApi.updateContent(editing.key, formTitle, formContent)
|
||||
setContents(contents.map(c => c.id === updated.id ? updated : c))
|
||||
toast.success('Контент обновлён')
|
||||
}
|
||||
handleCancel()
|
||||
} catch (err) {
|
||||
console.error('Failed to save content:', err)
|
||||
toast.error('Ошибка сохранения')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-neon-500/20 border border-neon-500/30">
|
||||
<FileText className="w-6 h-6 text-neon-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">Статический контент</h1>
|
||||
</div>
|
||||
<NeonButton onClick={handleCreate} icon={<Plus className="w-4 h-4" />}>
|
||||
Добавить
|
||||
</NeonButton>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Content List */}
|
||||
<div className="space-y-4">
|
||||
{contents.length === 0 ? (
|
||||
<div className="glass rounded-xl border border-dark-600 p-8 text-center">
|
||||
<FileText className="w-12 h-12 text-gray-600 mx-auto mb-3" />
|
||||
<p className="text-gray-400">Нет статического контента</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Создайте первую страницу</p>
|
||||
</div>
|
||||
) : (
|
||||
contents.map((content) => (
|
||||
<div
|
||||
key={content.id}
|
||||
className={`glass rounded-xl border p-5 cursor-pointer transition-all duration-200 ${
|
||||
editing?.id === content.id
|
||||
? 'border-accent-500/50 shadow-[0_0_15px_rgba(139,92,246,0.15)]'
|
||||
: 'border-dark-600 hover:border-dark-500'
|
||||
}`}
|
||||
onClick={() => handleEdit(content)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Code className="w-4 h-4 text-neon-400" />
|
||||
<p className="text-sm text-neon-400 font-mono">{content.key}</p>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white truncate">{content.title}</h3>
|
||||
<p className="text-sm text-gray-400 mt-2 line-clamp-2">
|
||||
{content.content.replace(/<[^>]*>/g, '').slice(0, 100)}...
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEdit(content)
|
||||
}}
|
||||
className="p-2 text-gray-400 hover:text-accent-400 hover:bg-accent-500/10 rounded-lg transition-colors ml-3"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-4 pt-3 border-t border-dark-600">
|
||||
Обновлено: {formatDate(content.updated_at)}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
{(editing || creating) && (
|
||||
<div className="glass rounded-xl border border-dark-600 p-6 sticky top-6 h-fit">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
{creating ? (
|
||||
<>
|
||||
<Plus className="w-5 h-5 text-neon-400" />
|
||||
Новый контент
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pencil className="w-5 h-5 text-accent-400" />
|
||||
Редактирование
|
||||
</>
|
||||
)}
|
||||
</h2>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="p-2 text-gray-400 hover:text-white hover:bg-dark-600/50 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{creating && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Ключ
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formKey}
|
||||
onChange={(e) => setFormKey(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ''))}
|
||||
placeholder="about-page"
|
||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white font-mono placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1.5">
|
||||
Только буквы, цифры, дефисы и подчеркивания
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Заголовок
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formTitle}
|
||||
onChange={(e) => setFormTitle(e.target.value)}
|
||||
placeholder="Заголовок страницы"
|
||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Содержимое (HTML)
|
||||
</label>
|
||||
<textarea
|
||||
value={formContent}
|
||||
onChange={(e) => setFormContent(e.target.value)}
|
||||
rows={14}
|
||||
placeholder="<p>HTML контент...</p>"
|
||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 font-mono text-sm resize-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NeonButton
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
isLoading={saving}
|
||||
icon={<Save className="w-4 h-4" />}
|
||||
className="w-full"
|
||||
>
|
||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||
</NeonButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user