Удалены устаревшие файлы документации и отчетов, включая ADMIN_DESIGN_IMPROVEMENTS.md, DATABASE_SETUP.md, FIX_REPORT.md, IMPLEMENTATION_SUMMARY.md, S3_SETUP.md, S3_TROUBLESHOOTING.md. Обновлен docker-compose.yml для упрощения проверки состояния контейнера. Исправлены ошибки в компонентах админ-панели, включая улучшение логики авторизации и загрузки категорий новостей.

This commit is contained in:
Bivekich
2025-08-08 01:56:54 +03:00
parent e49559c0b4
commit 8191775647
21 changed files with 541 additions and 1093 deletions

View File

@ -18,28 +18,43 @@ export default function AdminLayout({ children }: AdminLayoutProps) {
const router = useRouter();
useEffect(() => {
// Проверяем авторизацию
const adminAuth = localStorage.getItem('adminAuth');
if (adminAuth) {
setIsAuthenticated(true);
}
setIsLoading(false);
const checkAuth = async () => {
try {
const res = await fetch('/api/admin/me', { cache: 'no-store' });
setIsAuthenticated(res.ok);
} catch (e) {
setIsAuthenticated(false);
} finally {
setIsLoading(false);
}
};
checkAuth();
}, []);
const handleLogin = (username: string, password: string) => {
// Простая проверка (в реальном проекте должна быть серверная авторизация)
if (username === 'admin' && password === 'admin123') {
localStorage.setItem('adminAuth', JSON.stringify({ username, role: 'admin' }));
setIsAuthenticated(true);
return true;
const handleLogin = async (email: string, password: string) => {
try {
const res = await fetch('/api/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: email, password })
});
if (res.ok) {
setIsAuthenticated(true);
return true;
}
return false;
} catch (e) {
return false;
}
return false;
};
const handleLogout = () => {
localStorage.removeItem('adminAuth');
setIsAuthenticated(false);
router.push('/admin');
const handleLogout = async () => {
try {
await fetch('/api/admin/logout', { method: 'POST' });
} finally {
setIsAuthenticated(false);
router.push('/admin');
}
};
const navigation = [
@ -154,8 +169,8 @@ export default function AdminLayout({ children }: AdminLayoutProps) {
}
// Компонент формы входа
function LoginForm({ onLogin }: { onLogin: (username: string, password: string) => boolean }) {
const [username, setUsername] = useState('');
function LoginForm({ onLogin }: { onLogin: (email: string, password: string) => Promise<boolean> }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
@ -163,15 +178,14 @@ function LoginForm({ onLogin }: { onLogin: (username: string, password: string)
e.preventDefault();
setError('');
if (!username || !password) {
if (!email || !password) {
setError('Заполните все поля');
return;
}
const success = onLogin(username, password);
if (!success) {
setError('Неверный логин или пароль');
}
onLogin(email, password).then((success) => {
if (!success) setError('Неверный логин или пароль');
});
};
return (
@ -189,16 +203,16 @@ function LoginForm({ onLogin }: { onLogin: (username: string, password: string)
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-2">
Логин
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
Email или логин
</label>
<input
id="username"
id="email"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Введите логин"
placeholder="Введите email или логин"
/>
</div>

View File

@ -4,7 +4,7 @@ import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft, Save, Eye, Upload, X, Trash2 } from 'lucide-react';
import { NEWS_CATEGORIES, NewsFormData, NewsCategory } from '@/lib/types';
import { NewsFormData, NewsCategory } from '@/lib/types';
// import { getNewsById } from '@/lib/news-data';
interface EditNewsPageProps {
@ -35,8 +35,24 @@ export default function EditNewsPage({ params }: EditNewsPageProps) {
const [errors, setErrors] = useState<Partial<NewsFormData>>({});
const [tagInput, setTagInput] = useState('');
const [categories, setCategories] = useState<{ id: string; name: string }[]>([
{ id: 'company', name: 'Новости компании' },
{ id: 'promotions', name: 'Акции' },
{ id: 'other', name: 'Другое' }
]);
useEffect(() => {
// подгружаем категории
(async () => {
try {
const res = await fetch('/api/categories', { cache: 'no-store' });
const data = await res.json();
if (res.ok && data?.data?.length) {
setCategories(data.data.map((c: any) => ({ id: c.slug, name: c.name })));
}
} catch {}
})();
const loadNews = async () => {
const resolvedParams = await params;
const id = resolvedParams.id;
@ -300,7 +316,7 @@ export default function EditNewsPage({ params }: EditNewsPageProps) {
onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value as NewsCategory }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
{NEWS_CATEGORIES.map((category) => (
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</option>
@ -506,10 +522,8 @@ export default function EditNewsPage({ params }: EditNewsPageProps) {
</h4>
<div className="flex items-center space-x-2 mb-4">
<span className={`px-2 py-1 rounded-full text-xs font-medium text-white ${
NEWS_CATEGORIES.find(cat => cat.id === formData.category)?.color || 'bg-gray-500'
}`}>
{NEWS_CATEGORIES.find(cat => cat.id === formData.category)?.name}
<span className={`px-2 py-1 rounded-full text-xs font-medium text-white bg-gray-500`}>
{categories.find(cat => cat.id === formData.category)?.name || 'Категория'}
</span>
{formData.featured && (
<span className="px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">

View File

@ -1,17 +1,13 @@
'use client';
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { ArrowLeft, Save, X } from 'lucide-react';
import Link from 'next/link';
import TextEditor from '@/app/admin/components/TextEditor';
import ImageUpload from '@/app/admin/components/ImageUpload';
const NEWS_CATEGORIES = [
{ id: 'company', name: 'Новости компании' },
{ id: 'promotions', name: 'Акции' },
{ id: 'other', name: 'Другое' }
];
interface UiCategory { id: string; name: string }
export default function CreateNewsPage() {
const router = useRouter();
@ -29,6 +25,25 @@ export default function CreateNewsPage() {
tags: [] as string[]
});
const [tagInput, setTagInput] = useState('');
const [categories, setCategories] = useState<UiCategory[]>([
{ id: 'company', name: 'Новости компании' },
{ id: 'promotions', name: 'Акции' },
{ id: 'other', name: 'Другое' }
]);
useEffect(() => {
(async () => {
try {
const res = await fetch('/api/categories', { cache: 'no-store' });
const data = await res.json();
if (res.ok && data?.data?.length) {
setCategories(data.data.map((c: any) => ({ id: c.slug, name: c.name })));
// если выбранная категория отсутствует — выставим первую
setFormData(prev => ({ ...prev, category: data.data[0]?.slug || prev.category }));
}
} catch {}
})();
}, []);
const generateSlug = (title: string) => {
return title
@ -238,7 +253,7 @@ export default function CreateNewsPage() {
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
>
{NEWS_CATEGORIES.map((category) => (
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</option>

View File

@ -33,13 +33,14 @@ interface NewsCategory {
color: string;
}
const NEWS_CATEGORIES: NewsCategory[] = [
const DEFAULT_CATEGORIES: NewsCategory[] = [
{ id: 'company', name: 'Новости компании', slug: 'company', color: 'bg-blue-500' },
{ id: 'promotions', name: 'Акции', slug: 'promotions', color: 'bg-green-500' },
{ id: 'other', name: 'Другое', slug: 'other', color: 'bg-purple-500' }
];
export default function AdminNewsPage() {
const [categories, setCategories] = useState<NewsCategory[]>(DEFAULT_CATEGORIES);
const [news, setNews] = useState<NewsItem[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
@ -52,6 +53,18 @@ export default function AdminNewsPage() {
loadNews();
}, [searchQuery, selectedCategory, selectedStatus, sortBy, sortOrder]);
useEffect(() => {
(async () => {
try {
const res = await fetch('/api/categories', { cache: 'no-store' });
const data = await res.json();
if (res.ok && data?.data?.length) {
setCategories(data.data.map((c: any) => ({ id: c.slug, name: c.name, slug: c.slug, color: c.color || 'bg-blue-500' })));
}
} catch {}
})();
}, []);
const loadNews = async () => {
try {
setLoading(true);
@ -102,9 +115,7 @@ export default function AdminNewsPage() {
});
};
const getCategoryInfo = (categoryId: string) => {
return NEWS_CATEGORIES.find(cat => cat.id === categoryId);
};
const getCategoryInfo = (categoryId: string) => categories.find(cat => cat.id === categoryId);
const handleDelete = async (id: string) => {
if (!confirm('Вы уверены, что хотите удалить эту новость?')) {
@ -230,7 +241,7 @@ export default function AdminNewsPage() {
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="all">Все категории</option>
{NEWS_CATEGORIES.map((category) => (
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</option>

View File

@ -1,14 +1,14 @@
'use client';
import React, { useState } from 'react';
import { Save, Plus, Edit, Trash2, Settings as SettingsIcon, Palette, Globe } from 'lucide-react';
import { NEWS_CATEGORIES, NewsCategory, NewsCategoryInfo } from '@/lib/types';
import React, { useEffect, useState } from 'react';
import { Save, Plus, Edit, Trash2, Settings as SettingsIcon, Palette, Globe, Lock, Mail } from 'lucide-react';
import { NewsCategory, NewsCategoryInfo } from '@/lib/types';
export default function SettingsPage() {
const [activeTab, setActiveTab] = useState('categories');
const [isSubmitting, setIsSubmitting] = useState(false);
const [categories, setCategories] = useState(NEWS_CATEGORIES);
const [categories, setCategories] = useState<NewsCategoryInfo[]>([]);
const [newCategory, setNewCategory] = useState({
name: '',
description: '',
@ -38,27 +38,91 @@ export default function SettingsPage() {
const tabs = [
{ id: 'categories', name: 'Категории', icon: Palette },
{ id: 'general', name: 'Общие', icon: SettingsIcon },
{ id: 'security', name: 'Безопасность', icon: Lock },
{ id: 'seo', name: 'SEO', icon: Globe }
];
const handleAddCategory = () => {
const [security, setSecurity] = useState({
email: 'admin@ckeproekt.ru',
username: 'admin',
currentPassword: '',
newPassword: '',
isSaving: false,
message: '' as string | null,
error: '' as string | null,
});
useEffect(() => {
(async () => {
try {
const res = await fetch('/api/admin/me', { cache: 'no-store' });
if (!res.ok) return;
const data = await res.json();
if (data?.user) {
setSecurity(prev => ({ ...prev, email: data.user.email, username: data.user.username }));
}
} catch {}
})();
}, []);
// загрузка категорий из БД
useEffect(() => {
(async () => {
try {
const res = await fetch('/api/categories', { cache: 'no-store' });
if (!res.ok) return;
const data = await res.json();
const mapped: NewsCategoryInfo[] = (data.data || []).map((c: any) => ({
id: c.slug as NewsCategory,
name: c.name,
description: c.description || '',
color: c.color || 'bg-blue-500'
}));
setCategories(mapped);
} catch {}
})();
}, []);
const handleAddCategory = async () => {
if (!newCategory.name.trim()) return;
const categoryId = newCategory.name.toLowerCase().replace(/\s+/g, '-') as NewsCategory;
const category: NewsCategoryInfo = {
id: categoryId,
name: newCategory.name,
description: newCategory.description,
color: newCategory.color
};
setCategories([...categories, category]);
setNewCategory({ name: '', description: '', color: 'bg-blue-500' });
try {
const res = await fetch('/api/categories', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newCategory.name, description: newCategory.description, color: newCategory.color })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка создания категории');
// Преобразуем к локальному типу
const created: NewsCategoryInfo = {
id: data.data.slug as NewsCategory,
name: data.data.name,
description: data.data.description || '',
color: data.data.color || 'bg-blue-500'
};
setCategories(prev => [...prev, created]);
setNewCategory({ name: '', description: '', color: 'bg-blue-500' });
} catch (e) {
alert(e instanceof Error ? e.message : 'Ошибка');
}
};
const handleDeleteCategory = (id: string) => {
if (confirm('Вы уверены, что хотите удалить эту категорию?')) {
setCategories(categories.filter(cat => cat.id !== id));
const handleDeleteCategory = async (slug: string) => {
if (!confirm('Вы уверены, что хотите удалить эту категорию?')) return;
try {
// нужно найти id категории по slug через /api/categories (у нас в списке нет db id),
// поэтому запрашиваем полный список и ищем совпадение
const resList = await fetch('/api/categories');
const list = await resList.json();
const match = (list.data || []).find((c: any) => c.slug === slug);
if (!match) throw new Error('Категория не найдена');
const res = await fetch(`/api/categories/${match.id}`, { method: 'DELETE' });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка удаления');
setCategories(prev => prev.filter(cat => cat.id !== slug));
} catch (e) {
alert(e instanceof Error ? e.message : 'Ошибка');
}
};
@ -66,13 +130,10 @@ export default function SettingsPage() {
setIsSubmitting(true);
try {
// В реальном приложении здесь будет API вызов
console.log('Saving settings:', { categories, generalSettings });
// Имитация задержки
await new Promise(resolve => setTimeout(resolve, 1000));
alert('Настройки сохранены успешно!');
// пока сохраняем только generalSettings (к примеру локально)
console.log('Saving settings:', { generalSettings });
await new Promise(r => setTimeout(r, 500));
alert('Настройки сохранены');
} catch (error) {
console.error('Error saving settings:', error);
alert('Ошибка при сохранении настроек');
@ -131,6 +192,7 @@ export default function SettingsPage() {
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">Управление категориями</h3>
<p className="text-sm text-gray-500 mb-4">Категории синхронизируются с базой данных</p>
{/* Add New Category */}
<div className="bg-gray-50 rounded-lg p-4 mb-6">
@ -172,7 +234,7 @@ export default function SettingsPage() {
</div>
</div>
{/* Categories List */}
{/* Categories List */}
<div className="space-y-3">
{categories.map((category) => (
<div key={category.id} className="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
@ -184,11 +246,9 @@ export default function SettingsPage() {
</div>
</div>
<div className="flex items-center space-x-2">
<button className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded">
<Edit className="h-4 w-4" />
</button>
{/* Редактирование можно добавить при необходимости */}
<button
onClick={() => handleDeleteCategory(category.id)}
onClick={() => handleDeleteCategory(category.id)}
className="p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded"
>
<Trash2 className="h-4 w-4" />
@ -347,6 +407,89 @@ export default function SettingsPage() {
</div>
</div>
)}
{/* Security Tab */}
{activeTab === 'security' && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">Учетные данные администратора</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Email</label>
<input
type="email"
value={security.email}
onChange={(e) => setSecurity(prev => ({ ...prev, email: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="admin@ckeproekt.ru"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Логин</label>
<input
type="text"
value={security.username}
onChange={(e) => setSecurity(prev => ({ ...prev, username: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="admin"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Текущий пароль</label>
<input
type="password"
value={security.currentPassword}
onChange={(e) => setSecurity(prev => ({ ...prev, currentPassword: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Введите текущий пароль"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Новый пароль</label>
<input
type="password"
value={security.newPassword}
onChange={(e) => setSecurity(prev => ({ ...prev, newPassword: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Оставьте пустым, чтобы не менять"
/>
</div>
</div>
<div className="mt-4">
<button
disabled={security.isSaving}
onClick={async () => {
setSecurity(prev => ({ ...prev, isSaving: true, message: null, error: null }));
try {
const res = await fetch('/api/admin/credentials', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: security.email,
username: security.username,
currentPassword: security.currentPassword,
newPassword: security.newPassword || undefined,
})
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка сохранения');
setSecurity(prev => ({ ...prev, message: 'Изменения сохранены', currentPassword: '', newPassword: '' }));
} catch (e: any) {
setSecurity(prev => ({ ...prev, error: e.message || 'Ошибка' }));
} finally {
setSecurity(prev => ({ ...prev, isSaving: false }));
}
}}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{security.isSaving ? 'Сохранение...' : 'Сохранить учетные данные'}
</button>
</div>
{security.message && <p className="text-green-600 text-sm mt-2">{security.message}</p>}
{security.error && <p className="text-red-600 text-sm mt-2">{security.error}</p>}
</div>
</div>
)}
</div>
</div>
</div>