301 lines
11 KiB
TypeScript
301 lines
11 KiB
TypeScript
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, Trash2 } from 'lucide-react'
|
||
import { useConfirm } from '@/store/confirm'
|
||
|
||
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()
|
||
const confirm = useConfirm()
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
const handleDelete = async (content: StaticContent) => {
|
||
const confirmed = await confirm({
|
||
title: 'Удалить контент?',
|
||
message: `Вы уверены, что хотите удалить "${content.title}"? Это действие нельзя отменить.`,
|
||
confirmText: 'Удалить',
|
||
cancelText: 'Отмена',
|
||
variant: 'danger',
|
||
})
|
||
|
||
if (!confirmed) return
|
||
|
||
try {
|
||
await adminApi.deleteContent(content.key)
|
||
setContents(contents.filter(c => c.id !== content.id))
|
||
if (editing?.id === content.id) {
|
||
handleCancel()
|
||
}
|
||
toast.success('Контент удалён')
|
||
} catch (err) {
|
||
console.error('Failed to delete content:', err)
|
||
toast.error('Ошибка удаления')
|
||
}
|
||
}
|
||
|
||
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>
|
||
<div className="flex items-center gap-1 ml-3">
|
||
<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"
|
||
title="Редактировать"
|
||
>
|
||
<Pencil className="w-4 h-4" />
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
handleDelete(content)
|
||
}}
|
||
className="p-2 text-gray-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-colors"
|
||
title="Удалить"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</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>
|
||
)
|
||
}
|