Удалены устаревшие файлы документации и отчетов, включая 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:
@ -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>
|
||||
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
54
app/api/admin/credentials/route.ts
Normal file
54
app/api/admin/credentials/route.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAuthContext, hashPassword, verifyPassword } from '@/lib/auth';
|
||||
import prisma from '@/lib/database';
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const context = await getAuthContext(request);
|
||||
if (!context.user || context.user.role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { email, username, currentPassword, newPassword } = await request.json();
|
||||
|
||||
if (!currentPassword) {
|
||||
return NextResponse.json({ error: 'Текущий пароль обязателен' }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: context.user.id } });
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Пользователь не найден' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Проверка текущего пароля
|
||||
const isValid = await verifyPassword(currentPassword, user.password);
|
||||
if (!isValid) {
|
||||
return NextResponse.json({ error: 'Неверный текущий пароль' }, { status: 400 });
|
||||
}
|
||||
|
||||
const data: any = {};
|
||||
if (email && email !== user.email) data.email = email;
|
||||
if (username && username !== user.username) data.username = username;
|
||||
if (newPassword && newPassword.length >= 6) {
|
||||
data.password = await hashPassword(newPassword);
|
||||
}
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data,
|
||||
select: { id: true, email: true, username: true, role: true, name: true, avatar: true }
|
||||
});
|
||||
|
||||
return NextResponse.json({ user: updated });
|
||||
} catch (error: any) {
|
||||
if (error.code === 'P2002') {
|
||||
return NextResponse.json({ error: 'Email или логин уже заняты' }, { status: 409 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Внутренняя ошибка сервера' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
51
app/api/admin/login/route.ts
Normal file
51
app/api/admin/login/route.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/database';
|
||||
import { verifyPassword, generateToken } from '@/lib/auth';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { identifier, password } = await request.json();
|
||||
|
||||
if (!identifier || !password) {
|
||||
return NextResponse.json({ error: 'Логин/Email и пароль обязательны' }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ email: identifier },
|
||||
{ username: identifier }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Неверные учетные данные' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (user.role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Доступ запрещен' }, { status: 403 });
|
||||
}
|
||||
|
||||
const isValid = await verifyPassword(password, user.password);
|
||||
if (!isValid) {
|
||||
return NextResponse.json({ error: 'Неверные учетные данные' }, { status: 401 });
|
||||
}
|
||||
|
||||
const token = generateToken(user.id);
|
||||
const { password: _pwd, ...safeUser } = user as any;
|
||||
|
||||
const response = NextResponse.json({ user: safeUser });
|
||||
response.cookies.set('auth-token', token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 дней
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Внутренняя ошибка сервера' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
14
app/api/admin/logout/route.ts
Normal file
14
app/api/admin/logout/route.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function POST() {
|
||||
const response = NextResponse.json({ success: true });
|
||||
response.cookies.set('auth-token', '', {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
11
app/api/admin/me/route.ts
Normal file
11
app/api/admin/me/route.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAuthContext } from '@/lib/auth';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const context = await getAuthContext(request);
|
||||
if (!context.user || context.user.role !== 'ADMIN') {
|
||||
return NextResponse.json({ user: null }, { status: 401 });
|
||||
}
|
||||
return NextResponse.json({ user: context.user });
|
||||
}
|
||||
|
51
app/api/categories/[id]/route.ts
Normal file
51
app/api/categories/[id]/route.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/database';
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { name, description, color, slug } = body;
|
||||
const id = params.id;
|
||||
|
||||
const updated = await prisma.category.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(name ? { name } : {}),
|
||||
...(description !== undefined ? { description } : {}),
|
||||
...(color ? { color } : {}),
|
||||
...(slug ? { slug } : {}),
|
||||
},
|
||||
select: { id: true, name: true, slug: true, description: true, color: true }
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, data: updated });
|
||||
} catch (error: any) {
|
||||
if (error.code === 'P2025') {
|
||||
return NextResponse.json({ success: false, error: 'Категория не найдена' }, { status: 404 });
|
||||
}
|
||||
if (error.code === 'P2002') {
|
||||
return NextResponse.json({ success: false, error: 'Имя или слаг уже используются' }, { status: 409 });
|
||||
}
|
||||
return NextResponse.json({ success: false, error: 'Не удалось обновить категорию' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const id = params.id;
|
||||
await prisma.category.delete({ where: { id } });
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error: any) {
|
||||
if (error.code === 'P2025') {
|
||||
return NextResponse.json({ success: false, error: 'Категория не найдена' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ success: false, error: 'Не удалось удалить категорию' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
56
app/api/categories/route.ts
Normal file
56
app/api/categories/route.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/database';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const categories = await prisma.category.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
select: { id: true, name: true, slug: true, description: true, color: true }
|
||||
});
|
||||
return NextResponse.json({ success: true, data: categories });
|
||||
} catch {
|
||||
return NextResponse.json({ success: false, error: 'Не удалось получить категории' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const name: string = body.name;
|
||||
const description: string | undefined = body.description;
|
||||
const color: string | undefined = body.color;
|
||||
let slug: string | undefined = body.slug;
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
return NextResponse.json({ success: false, error: 'Название обязательно' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!slug) {
|
||||
slug = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9а-яё\s-]/g, '')
|
||||
.replace(/[\s_]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
const created = await prisma.category.create({
|
||||
data: {
|
||||
name: name.trim(),
|
||||
slug,
|
||||
description,
|
||||
color: color || 'bg-blue-500'
|
||||
},
|
||||
select: { id: true, name: true, slug: true, description: true, color: true }
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, data: created }, { status: 201 });
|
||||
} catch (e: unknown) {
|
||||
const code = typeof e === 'object' && e !== null && 'code' in e ? (e as { code?: string }).code : undefined;
|
||||
if (code === 'P2002') {
|
||||
return NextResponse.json({ success: false, error: 'Категория с таким именем или слагом уже существует' }, { status: 409 });
|
||||
}
|
||||
return NextResponse.json({ success: false, error: 'Не удалось создать категорию' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { NEWS_CATEGORIES, NewsItem } from '@/lib/types';
|
||||
import { NewsItem } from '@/lib/types';
|
||||
import { Search, Eye, ArrowRight } from 'lucide-react';
|
||||
import Header from '@/app/components/Header';
|
||||
import Footer from '@/app/components/Footer';
|
||||
@ -60,8 +60,18 @@ export default function NewsPageComponent() {
|
||||
const [news, setNews] = useState<NewsItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [totalNews, setTotalNews] = useState(0);
|
||||
const [categories, setCategories] = useState<{ id: string; name: string; color: string }[]>([]);
|
||||
|
||||
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, color: c.color || 'bg-gray-500' })));
|
||||
}
|
||||
} catch {}
|
||||
})();
|
||||
const loadNews = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@ -126,9 +136,7 @@ export default function NewsPageComponent() {
|
||||
});
|
||||
};
|
||||
|
||||
const getCategoryInfo = (categoryId: string) => {
|
||||
return NEWS_CATEGORIES.find(cat => cat.id === categoryId);
|
||||
};
|
||||
const getCategoryInfo = (categoryId: string) => categories.find(cat => cat.id === categoryId);
|
||||
|
||||
const handleCategoryChange = (category: string) => {
|
||||
setSelectedCategory(category);
|
||||
@ -237,7 +245,7 @@ export default function NewsPageComponent() {
|
||||
>
|
||||
Все
|
||||
</button>
|
||||
{NEWS_CATEGORIES.map((category) => (
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => handleCategoryChange(category.id)}
|
||||
|
@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { NEWS_CATEGORIES } from '@/lib/types';
|
||||
// категории будем подтягивать с API
|
||||
|
||||
interface NewsDetailPageProps {
|
||||
params: Promise<{
|
||||
@ -72,11 +72,18 @@ export default async function NewsDetailPage({ params }: NewsDetailPageProps) {
|
||||
});
|
||||
};
|
||||
|
||||
const getCategoryInfo = (categoryId: string) => {
|
||||
return NEWS_CATEGORIES.find(cat => cat.id === categoryId);
|
||||
};
|
||||
async function getCategoryInfo(categoryId: string) {
|
||||
try {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/categories`, { cache: 'no-store' });
|
||||
const data = await response.json();
|
||||
if (response.ok && data?.data?.length) {
|
||||
return data.data.find((c: any) => c.slug === categoryId);
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
const categoryInfo = getCategoryInfo(news.category);
|
||||
const categoryInfo = await getCategoryInfo(news.category);
|
||||
|
||||
// Получаем связанные новости (из той же категории, исключая текущую)
|
||||
const relatedNews = await getRelatedNews(news.category, news.slug);
|
||||
|
Reference in New Issue
Block a user