Files
game-marathon/frontend/src/pages/admin/AdminContentPage.tsx

301 lines
11 KiB
TypeScript
Raw Normal View History

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