2025-12-19 02:07:25 +07:00
|
|
|
|
import { useState, useEffect } from 'react'
|
|
|
|
|
|
import { adminApi } from '@/api'
|
|
|
|
|
|
import type { StaticContent } from '@/types'
|
|
|
|
|
|
import { useToast } from '@/store/toast'
|
|
|
|
|
|
import { NeonButton } from '@/components/ui'
|
2025-12-20 02:01:51 +07:00
|
|
|
|
import { FileText, Plus, Pencil, X, Save, Code, Trash2 } from 'lucide-react'
|
|
|
|
|
|
import { useConfirm } from '@/store/confirm'
|
2025-12-19 02:07:25 +07:00
|
|
|
|
|
|
|
|
|
|
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()
|
2025-12-20 02:01:51 +07:00
|
|
|
|
const confirm = useConfirm()
|
2025-12-19 02:07:25 +07:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-20 02:01:51 +07:00
|
|
|
|
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('Ошибка удаления')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-19 02:07:25 +07:00
|
|
|
|
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>
|
2025-12-20 02:01:51 +07:00
|
|
|
|
<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>
|
2025-12-19 02:07:25 +07:00
|
|
|
|
</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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|