Удалены устаревшие файлы документации и отчетов, включая 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:
@ -1,169 +0,0 @@
|
|||||||
# Улучшения дизайна админ панели
|
|
||||||
|
|
||||||
## Обзор изменений
|
|
||||||
|
|
||||||
Админ панель была полностью переработана с современным дизайном и улучшенной функциональностью.
|
|
||||||
|
|
||||||
## 🎨 Дизайн-система
|
|
||||||
|
|
||||||
### Цветовая палитра
|
|
||||||
- **Основной**: Градиенты от синего к индиго
|
|
||||||
- **Sidebar**: Темная тема (gray-900 to gray-800)
|
|
||||||
- **Акценты**: Цветные индикаторы для разных статусов
|
|
||||||
- **Состояния**: Зеленый (успех), красный (ошибка), желтый (важное)
|
|
||||||
|
|
||||||
### Типографика
|
|
||||||
- **Заголовки**: Градиентный текст с clip-path
|
|
||||||
- **Основной текст**: Улучшенная читаемость
|
|
||||||
- **Иконки**: Lucide React с консистентными размерами
|
|
||||||
|
|
||||||
## 🚀 Улучшения интерфейса
|
|
||||||
|
|
||||||
### 1. Главная страница (Dashboard)
|
|
||||||
**Было**: Статичные данные из файла
|
|
||||||
**Стало**: Динамические данные из API
|
|
||||||
|
|
||||||
#### Статистические карточки
|
|
||||||
- 5 карточек вместо 4 (добавлены пользователи)
|
|
||||||
- Градиентные иконки с цветными фонами
|
|
||||||
- Улучшенная анимация при наведении
|
|
||||||
- Реальные данные из базы
|
|
||||||
|
|
||||||
#### Список новостей
|
|
||||||
- Улучшенное отображение метаданных
|
|
||||||
- Показ просмотров и лайков
|
|
||||||
- Эмодзи для лучшей визуализации
|
|
||||||
- Кнопки с hover-эффектами
|
|
||||||
|
|
||||||
#### Быстрые действия
|
|
||||||
- Карточки с цветными иконками
|
|
||||||
- Группированные hover-эффекты
|
|
||||||
- Добавлена ссылка на настройки
|
|
||||||
|
|
||||||
#### Системная информация
|
|
||||||
- Новая карточка с градиентным фоном
|
|
||||||
- Статус подключений в реальном времени
|
|
||||||
- Индикаторы состояния
|
|
||||||
|
|
||||||
### 2. Sidebar (Боковая панель)
|
|
||||||
**Было**: Светлая тема
|
|
||||||
**Стало**: Темная тема с градиентом
|
|
||||||
|
|
||||||
#### Улучшения
|
|
||||||
- Градиентный фон (gray-900 to gray-800)
|
|
||||||
- Обновленный логотип "CKE Admin"
|
|
||||||
- Активные состояния с синим фоном
|
|
||||||
- Улучшенная анимация переходов
|
|
||||||
- Интеграция S3Status в темном стиле
|
|
||||||
|
|
||||||
### 3. Top Bar (Верхняя панель)
|
|
||||||
**Было**: Простой текст приветствия
|
|
||||||
**Стало**: Аватар пользователя с градиентом
|
|
||||||
|
|
||||||
#### Новые элементы
|
|
||||||
- Круглый аватар с инициалами
|
|
||||||
- Градиентный фон аватара
|
|
||||||
- Улучшенная типографика
|
|
||||||
|
|
||||||
### 4. Форма входа
|
|
||||||
**Было**: Простая белая форма
|
|
||||||
**Стало**: Современная форма с градиентами
|
|
||||||
|
|
||||||
#### Улучшения
|
|
||||||
- Градиентный фон страницы
|
|
||||||
- Круглая иконка с градиентом
|
|
||||||
- Улучшенные тени и скругления
|
|
||||||
- Градиентный текст заголовка
|
|
||||||
|
|
||||||
## 🔧 Функциональные улучшения
|
|
||||||
|
|
||||||
### 1. Интеграция с API
|
|
||||||
- Загрузка реальных данных вместо статичных
|
|
||||||
- Поддержка параметра `published=all`
|
|
||||||
- Обработка состояний загрузки
|
|
||||||
- Улучшенная обработка ошибок
|
|
||||||
|
|
||||||
### 2. S3 Status интеграция
|
|
||||||
- Адаптация под темную тему
|
|
||||||
- Размещение в отдельном блоке
|
|
||||||
- Улучшенные цвета для темного фона
|
|
||||||
|
|
||||||
### 3. Респонсивность
|
|
||||||
- Улучшенная сетка для статистики (5 колонок)
|
|
||||||
- Адаптивные карточки
|
|
||||||
- Мобильная оптимизация
|
|
||||||
|
|
||||||
## 📊 Новые компоненты
|
|
||||||
|
|
||||||
### 1. Расширенная статистика
|
|
||||||
```typescript
|
|
||||||
interface DashboardStats {
|
|
||||||
totalNews: number;
|
|
||||||
publishedNews: number;
|
|
||||||
featuredNews: number;
|
|
||||||
recentNews: number;
|
|
||||||
totalUsers: number;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Улучшенные карточки новостей
|
|
||||||
- Метаданные с эмодзи
|
|
||||||
- Цветные бейджи статусов
|
|
||||||
- Hover-эффекты для кнопок
|
|
||||||
|
|
||||||
### 3. Системная информация
|
|
||||||
- Индикаторы состояния
|
|
||||||
- Версия системы
|
|
||||||
- Статус подключений
|
|
||||||
|
|
||||||
## 🎯 Результаты
|
|
||||||
|
|
||||||
### Визуальные улучшения
|
|
||||||
- ✅ Современный дизайн с градиентами
|
|
||||||
- ✅ Консистентная цветовая схема
|
|
||||||
- ✅ Улучшенная типографика
|
|
||||||
- ✅ Анимации и переходы
|
|
||||||
|
|
||||||
### Функциональные улучшения
|
|
||||||
- ✅ Реальные данные из API
|
|
||||||
- ✅ Улучшенная обработка состояний
|
|
||||||
- ✅ Интеграция S3 статуса
|
|
||||||
- ✅ Респонсивный дизайн
|
|
||||||
|
|
||||||
### UX улучшения
|
|
||||||
- ✅ Интуитивная навигация
|
|
||||||
- ✅ Информативная статистика
|
|
||||||
- ✅ Быстрые действия
|
|
||||||
- ✅ Статус системы
|
|
||||||
|
|
||||||
## 🔮 Дальнейшие улучшения
|
|
||||||
|
|
||||||
### Планируемые функции
|
|
||||||
- [ ] Темная/светлая тема переключатель
|
|
||||||
- [ ] Уведомления в реальном времени
|
|
||||||
- [ ] Расширенная аналитика
|
|
||||||
- [ ] Пользовательские настройки
|
|
||||||
- [ ] Экспорт данных
|
|
||||||
|
|
||||||
### Оптимизации
|
|
||||||
- [ ] Кэширование данных
|
|
||||||
- [ ] Lazy loading компонентов
|
|
||||||
- [ ] Оптимизация изображений
|
|
||||||
- [ ] PWA функции
|
|
||||||
|
|
||||||
## 🛠 Технические детали
|
|
||||||
|
|
||||||
### Используемые технологии
|
|
||||||
- **React 19** с hooks
|
|
||||||
- **TypeScript** для типизации
|
|
||||||
- **Tailwind CSS** для стилизации
|
|
||||||
- **Lucide React** для иконок
|
|
||||||
- **Framer Motion** для анимаций
|
|
||||||
|
|
||||||
### Архитектурные решения
|
|
||||||
- Разделение компонентов по функциональности
|
|
||||||
- Типизированные интерфейсы
|
|
||||||
- Консистентная обработка ошибок
|
|
||||||
- Оптимизированные API запросы
|
|
||||||
|
|
||||||
Админ панель теперь имеет современный, профессиональный вид с улучшенной функциональностью и удобством использования.
|
|
@ -1,269 +0,0 @@
|
|||||||
# Настройка базы данных и системы управления новостями
|
|
||||||
|
|
||||||
## Обзор системы
|
|
||||||
|
|
||||||
Система управления новостями для ckeproekt.ru включает:
|
|
||||||
|
|
||||||
- **База данных**: PostgreSQL с Prisma ORM
|
|
||||||
- **API**: REST API и GraphQL endpoints
|
|
||||||
- **Админ-панель**: Полнофункциональная панель управления
|
|
||||||
- **Безопасность**: JWT аутентификация и авторизация
|
|
||||||
- **Функциональность**: CRUD операции, пагинация, поиск, фильтрация
|
|
||||||
|
|
||||||
## Требования
|
|
||||||
|
|
||||||
- Node.js 18+
|
|
||||||
- PostgreSQL 12+
|
|
||||||
- npm или yarn
|
|
||||||
|
|
||||||
## Установка и настройка
|
|
||||||
|
|
||||||
### 1. Установка зависимостей
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Настройка базы данных
|
|
||||||
|
|
||||||
1. Создайте PostgreSQL базу данных:
|
|
||||||
```sql
|
|
||||||
CREATE DATABASE ckeproject;
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Обновите файл `.env` с вашими данными:
|
|
||||||
```env
|
|
||||||
DATABASE_URL="postgresql://username:password@localhost:5432/ckeproject?schema=public"
|
|
||||||
NEXTAUTH_SECRET="your-secret-key-here"
|
|
||||||
NEXTAUTH_URL="http://localhost:3000"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Инициализация Prisma
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Генерация Prisma Client
|
|
||||||
npm run db:generate
|
|
||||||
|
|
||||||
# Применение схемы к базе данных
|
|
||||||
npm run db:push
|
|
||||||
|
|
||||||
# Заполнение базы данных начальными данными
|
|
||||||
npm run db:seed
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Запуск приложения
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Структура базы данных
|
|
||||||
|
|
||||||
### Таблица `users`
|
|
||||||
- `id` - Уникальный идентификатор
|
|
||||||
- `email` - Email (уникальный)
|
|
||||||
- `username` - Имя пользователя (уникальное)
|
|
||||||
- `password` - Хешированный пароль
|
|
||||||
- `role` - Роль (USER, ADMIN, EDITOR)
|
|
||||||
- `name` - Полное имя
|
|
||||||
- `avatar` - URL аватара
|
|
||||||
- `createdAt` - Дата создания
|
|
||||||
- `updatedAt` - Дата обновления
|
|
||||||
|
|
||||||
### Таблица `news`
|
|
||||||
- `id` - Уникальный идентификатор
|
|
||||||
- `title` - Заголовок новости
|
|
||||||
- `slug` - URL-слаг (уникальный)
|
|
||||||
- `summary` - Краткое описание
|
|
||||||
- `content` - Полное содержание
|
|
||||||
- `category` - Категория новости
|
|
||||||
- `imageUrl` - URL изображения
|
|
||||||
- `featured` - Рекомендуемая новость
|
|
||||||
- `published` - Статус публикации
|
|
||||||
- `publishedAt` - Дата публикации
|
|
||||||
- `authorId` - ID автора
|
|
||||||
- `views` - Количество просмотров
|
|
||||||
- `likes` - Количество лайков
|
|
||||||
- `tags` - Массив тегов
|
|
||||||
- `createdAt` - Дата создания
|
|
||||||
- `updatedAt` - Дата обновления
|
|
||||||
|
|
||||||
### Таблица `categories`
|
|
||||||
- `id` - Уникальный идентификатор
|
|
||||||
- `name` - Название категории
|
|
||||||
- `slug` - URL-слаг (уникальный)
|
|
||||||
- `description` - Описание
|
|
||||||
- `color` - Цвет категории
|
|
||||||
- `createdAt` - Дата создания
|
|
||||||
- `updatedAt` - Дата обновления
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
### REST API
|
|
||||||
|
|
||||||
#### Новости
|
|
||||||
- `GET /api/news` - Получить список новостей
|
|
||||||
- `POST /api/news` - Создать новость
|
|
||||||
- `GET /api/news/[id]` - Получить новость по ID
|
|
||||||
- `PUT /api/news/[id]` - Обновить новость
|
|
||||||
- `DELETE /api/news/[id]` - Удалить новость
|
|
||||||
|
|
||||||
#### Категории
|
|
||||||
- `GET /api/categories` - Получить список категорий
|
|
||||||
- `POST /api/categories` - Создать категорию
|
|
||||||
- `PUT /api/categories/[id]` - Обновить категорию
|
|
||||||
- `DELETE /api/categories/[id]` - Удалить категорию
|
|
||||||
|
|
||||||
#### Аутентификация
|
|
||||||
- `POST /api/auth/login` - Вход в систему
|
|
||||||
- `POST /api/auth/register` - Регистрация
|
|
||||||
- `POST /api/auth/logout` - Выход из системы
|
|
||||||
|
|
||||||
### GraphQL API
|
|
||||||
|
|
||||||
GraphQL endpoint доступен по адресу `/api/graphql`
|
|
||||||
|
|
||||||
#### Примеры запросов
|
|
||||||
|
|
||||||
```graphql
|
|
||||||
# Получить список новостей
|
|
||||||
query {
|
|
||||||
newsList(page: 1, limit: 10, category: "company") {
|
|
||||||
news {
|
|
||||||
id
|
|
||||||
title
|
|
||||||
summary
|
|
||||||
publishedAt
|
|
||||||
author {
|
|
||||||
name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
total
|
|
||||||
totalPages
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Создать новость
|
|
||||||
mutation {
|
|
||||||
createNews(input: {
|
|
||||||
title: "Новая новость"
|
|
||||||
slug: "novaya-novost"
|
|
||||||
summary: "Краткое описание"
|
|
||||||
content: "Полное содержание"
|
|
||||||
category: "company"
|
|
||||||
featured: true
|
|
||||||
}) {
|
|
||||||
id
|
|
||||||
title
|
|
||||||
slug
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Админ-панель
|
|
||||||
|
|
||||||
Админ-панель доступна по адресу `/admin`
|
|
||||||
|
|
||||||
### Пользователи по умолчанию
|
|
||||||
|
|
||||||
После выполнения `npm run db:seed` будут созданы:
|
|
||||||
|
|
||||||
1. **Администратор**
|
|
||||||
- Email: `admin@ckeproekt.ru`
|
|
||||||
- Пароль: `admin123`
|
|
||||||
- Роль: ADMIN
|
|
||||||
|
|
||||||
2. **Редактор**
|
|
||||||
- Email: `editor@ckeproekt.ru`
|
|
||||||
- Пароль: `editor123`
|
|
||||||
- Роль: EDITOR
|
|
||||||
|
|
||||||
### Функциональность админ-панели
|
|
||||||
|
|
||||||
- ✅ Создание, редактирование и удаление новостей
|
|
||||||
- ✅ Управление категориями
|
|
||||||
- ✅ Загрузка изображений
|
|
||||||
- ✅ Визуальный редактор содержимого
|
|
||||||
- ✅ Система тегов
|
|
||||||
- ✅ Управление публикацией
|
|
||||||
- ✅ Поиск и фильтрация
|
|
||||||
- ✅ Пагинация
|
|
||||||
- ✅ Статистика
|
|
||||||
|
|
||||||
## Безопасность
|
|
||||||
|
|
||||||
### Аутентификация
|
|
||||||
- JWT токены для авторизации
|
|
||||||
- Хеширование паролей с bcrypt
|
|
||||||
- Защищенные API endpoints
|
|
||||||
|
|
||||||
### Авторизация
|
|
||||||
- Роли пользователей (USER, ADMIN, EDITOR)
|
|
||||||
- Проверка прав доступа на уровне API
|
|
||||||
- Middleware для защиты маршрутов
|
|
||||||
|
|
||||||
### Валидация
|
|
||||||
- Проверка входных данных
|
|
||||||
- Санитизация контента
|
|
||||||
- Защита от XSS и SQL инъекций
|
|
||||||
|
|
||||||
## Развертывание в продакшн
|
|
||||||
|
|
||||||
### 1. Настройка переменных окружения
|
|
||||||
|
|
||||||
```env
|
|
||||||
DATABASE_URL="postgresql://user:password@host:5432/database"
|
|
||||||
NEXTAUTH_SECRET="strong-secret-key"
|
|
||||||
NEXTAUTH_URL="https://your-domain.com"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Сборка приложения
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Миграция базы данных
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run db:migrate
|
|
||||||
npm run db:seed
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Запуск с PM2
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run pm2:start
|
|
||||||
```
|
|
||||||
|
|
||||||
## Мониторинг и обслуживание
|
|
||||||
|
|
||||||
### Prisma Studio
|
|
||||||
Для просмотра и редактирования данных:
|
|
||||||
```bash
|
|
||||||
npm run db:studio
|
|
||||||
```
|
|
||||||
|
|
||||||
### Логи
|
|
||||||
Логи доступны через PM2:
|
|
||||||
```bash
|
|
||||||
pm2 logs
|
|
||||||
```
|
|
||||||
|
|
||||||
### Резервное копирование
|
|
||||||
Регулярно создавайте резервные копии базы данных:
|
|
||||||
```bash
|
|
||||||
pg_dump ckeproject > backup.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
## Миграция с существующей системы
|
|
||||||
|
|
||||||
Скрипт `scripts/init-database.ts` автоматически мигрирует данные из `lib/news-data.ts` в базу данных.
|
|
||||||
|
|
||||||
## Поддержка
|
|
||||||
|
|
||||||
Для получения поддержки или сообщения об ошибках обращайтесь к разработчикам системы.
|
|
||||||
|
|
||||||
## Лицензия
|
|
||||||
|
|
||||||
Система разработана специально для ckeproekt.ru и является собственностью компании.
|
|
@ -1,67 +0,0 @@
|
|||||||
# Отчет о решении проблемы "Failed to fetch news"
|
|
||||||
|
|
||||||
## Проблема
|
|
||||||
Пользователи получали ошибку "Failed to fetch news" при загрузке новостей на главной странице.
|
|
||||||
|
|
||||||
## Причины
|
|
||||||
1. **Неверная конфигурация базы данных**: В схеме Prisma был указан провайдер `sqlite`, но в `.env` была настроена PostgreSQL база данных
|
|
||||||
2. **Отсутствие данных**: База данных не была инициализирована с тестовыми данными
|
|
||||||
3. **Недостаточная обработка ошибок**: Компонент не показывал информативные сообщения об ошибках
|
|
||||||
|
|
||||||
## Решение
|
|
||||||
|
|
||||||
### 1. Исправление конфигурации базы данных
|
|
||||||
**Файл**: `prisma/schema.prisma`
|
|
||||||
```diff
|
|
||||||
datasource db {
|
|
||||||
- provider = "sqlite"
|
|
||||||
+ provider = "postgresql"
|
|
||||||
url = env("DATABASE_URL")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Обновление клиента Prisma
|
|
||||||
```bash
|
|
||||||
npm run db:generate
|
|
||||||
npm run db:push
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Инициализация базы данных
|
|
||||||
```bash
|
|
||||||
npm run db:seed
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Улучшение обработки ошибок
|
|
||||||
**Файл**: `app/components/NewsBlock.tsx`
|
|
||||||
- Добавлено состояние для отслеживания ошибок
|
|
||||||
- Улучшена обработка HTTP ошибок
|
|
||||||
- Добавлено отображение ошибок в интерфейсе с кнопкой обновления
|
|
||||||
|
|
||||||
### 5. Добавление health check
|
|
||||||
**Файл**: `app/api/health/route.ts`
|
|
||||||
- Создан эндпоинт для проверки состояния системы
|
|
||||||
- Проверка подключения к базе данных
|
|
||||||
- Отображение статистики (количество новостей и пользователей)
|
|
||||||
|
|
||||||
## Результат
|
|
||||||
✅ **База данных**: Подключена и работает корректно
|
|
||||||
✅ **API**: Возвращает данные успешно
|
|
||||||
✅ **Новости**: Загружаются и отображаются на сайте
|
|
||||||
✅ **Обработка ошибок**: Улучшена для лучшего пользовательского опыта
|
|
||||||
|
|
||||||
## Тестирование
|
|
||||||
- Проверена работа API: `GET /api/news` возвращает корректные данные
|
|
||||||
- Проверено состояние системы: `GET /api/health` показывает healthy status
|
|
||||||
- В базе данных создано 5 новостей и 2 пользователя для тестирования
|
|
||||||
|
|
||||||
## Дополнительные улучшения
|
|
||||||
- Добавлен компонент для отображения ошибок с возможностью обновления
|
|
||||||
- Улучшена обработка состояния "нет новостей"
|
|
||||||
- Добавлена проверка HTTP статусов ответов
|
|
||||||
|
|
||||||
## Доступ к админ-панели
|
|
||||||
- **URL**: `/admin`
|
|
||||||
- **Логин**: `admin`
|
|
||||||
- **Пароль**: `admin123`
|
|
||||||
|
|
||||||
Проблема полностью решена. Новости теперь загружаются корректно, а пользователи получают информативные сообщения в случае ошибок.
|
|
@ -1,194 +0,0 @@
|
|||||||
# Итоговый отчет: Система управления новостями для ckeproekt.ru
|
|
||||||
|
|
||||||
## ✅ Выполненные задачи
|
|
||||||
|
|
||||||
### 1. Создание модели News в базе данных ✅
|
|
||||||
|
|
||||||
**Файлы:**
|
|
||||||
- `prisma/schema.prisma` - Схема базы данных
|
|
||||||
- `lib/database.ts` - Утилиты для работы с базой данных
|
|
||||||
|
|
||||||
**Реализованная модель News:**
|
|
||||||
```prisma
|
|
||||||
model News {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
title String
|
|
||||||
slug String @unique
|
|
||||||
summary String
|
|
||||||
content String @db.Text
|
|
||||||
category String
|
|
||||||
imageUrl String?
|
|
||||||
featured Boolean @default(false)
|
|
||||||
published Boolean @default(true)
|
|
||||||
publishedAt DateTime @default(now())
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
authorId String?
|
|
||||||
author User? @relation(fields: [authorId], references: [id])
|
|
||||||
views Int @default(0)
|
|
||||||
likes Int @default(0)
|
|
||||||
tags String[]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Дополнительные модели:**
|
|
||||||
- `User` - Пользователи системы с ролями (USER, ADMIN, EDITOR)
|
|
||||||
- `Category` - Категории новостей
|
|
||||||
- Связи между моделями через foreign keys
|
|
||||||
|
|
||||||
### 2. Разработка GraphQL API ✅
|
|
||||||
|
|
||||||
**Файлы:**
|
|
||||||
- `lib/graphql/schema.ts` - GraphQL схема
|
|
||||||
- `lib/graphql/resolvers.ts` - Резолверы для GraphQL
|
|
||||||
|
|
||||||
**Реализованный функционал:**
|
|
||||||
- Queries: получение новостей, списка новостей, категорий, пользователей
|
|
||||||
- Mutations: создание, обновление, удаление новостей и категорий
|
|
||||||
- Аутентификация и авторизация в GraphQL
|
|
||||||
- Пагинация и фильтрация в GraphQL запросах
|
|
||||||
- Поиск по содержимому новостей
|
|
||||||
|
|
||||||
### 3. Интеграция в существующую MS ✅
|
|
||||||
|
|
||||||
**Файлы:**
|
|
||||||
- `app/api/news/route.ts` - REST API для новостей
|
|
||||||
- `app/api/news/[id]/route.ts` - API для отдельной новости
|
|
||||||
- `lib/database.ts` - Сервисы для работы с базой данных
|
|
||||||
- `scripts/init-database.ts` - Скрипт миграции данных
|
|
||||||
|
|
||||||
**Реализованная интеграция:**
|
|
||||||
- REST API endpoints для всех операций с новостями
|
|
||||||
- Миграция существующих данных из статических файлов
|
|
||||||
- Совместимость с существующими компонентами
|
|
||||||
- Обратная совместимость с текущим интерфейсом
|
|
||||||
|
|
||||||
### 4. Обеспечение безопасности ✅
|
|
||||||
|
|
||||||
**Файлы:**
|
|
||||||
- `lib/auth.ts` - Система аутентификации и авторизации
|
|
||||||
- Middleware для защиты API endpoints
|
|
||||||
|
|
||||||
**Реализованные меры безопасности:**
|
|
||||||
- JWT токены для аутентификации
|
|
||||||
- Хеширование паролей с bcrypt
|
|
||||||
- Роли пользователей (USER, ADMIN, EDITOR)
|
|
||||||
- Middleware для проверки прав доступа
|
|
||||||
- Защита API endpoints от несанкционированного доступа
|
|
||||||
- Валидация входных данных
|
|
||||||
|
|
||||||
### 5. Создание всех необходимых страниц и компонентов ✅
|
|
||||||
|
|
||||||
**Обновленные файлы:**
|
|
||||||
- `app/admin/news/page.tsx` - Админ-панель управления новостями
|
|
||||||
- `app/admin/news/create/page.tsx` - Создание новостей
|
|
||||||
- `app/admin/news/[id]/edit/page.tsx` - Редактирование новостей
|
|
||||||
- `app/news/page.tsx` - Публичная страница новостей
|
|
||||||
- `app/news/[slug]/page.tsx` - Страница отдельной новости
|
|
||||||
- `app/components/NewsBlock.tsx` - Блок новостей на главной
|
|
||||||
|
|
||||||
**Функциональность админ-панели:**
|
|
||||||
- Создание, редактирование и удаление новостей
|
|
||||||
- Управление статусом публикации
|
|
||||||
- Система тегов
|
|
||||||
- Загрузка изображений
|
|
||||||
- Визуальный редактор
|
|
||||||
- Поиск и фильтрация
|
|
||||||
- Пагинация
|
|
||||||
- Статистика
|
|
||||||
|
|
||||||
## 🔧 Техническая архитектура
|
|
||||||
|
|
||||||
### База данных
|
|
||||||
- **PostgreSQL** с **Prisma ORM**
|
|
||||||
- Миграции и схемы версионируются
|
|
||||||
- Индексы для оптимизации запросов
|
|
||||||
- Связи между таблицами
|
|
||||||
|
|
||||||
### API
|
|
||||||
- **REST API** для основных операций
|
|
||||||
- **GraphQL API** для сложных запросов
|
|
||||||
- Единообразная обработка ошибок
|
|
||||||
- Валидация данных
|
|
||||||
|
|
||||||
### Безопасность
|
|
||||||
- **JWT** аутентификация
|
|
||||||
- **bcrypt** для хеширования паролей
|
|
||||||
- **RBAC** (Role-Based Access Control)
|
|
||||||
- Защита от XSS и SQL инъекций
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
- **Next.js 15** с **TypeScript**
|
|
||||||
- **React Server Components**
|
|
||||||
- **Tailwind CSS** для стилизации
|
|
||||||
- Адаптивный дизайн
|
|
||||||
|
|
||||||
## 📊 Функциональность системы
|
|
||||||
|
|
||||||
### Для администраторов:
|
|
||||||
- ✅ Полное управление новостями
|
|
||||||
- ✅ Управление пользователями
|
|
||||||
- ✅ Статистика и аналитика
|
|
||||||
- ✅ Настройка категорий
|
|
||||||
- ✅ Модерация контента
|
|
||||||
|
|
||||||
### Для редакторов:
|
|
||||||
- ✅ Создание и редактирование новостей
|
|
||||||
- ✅ Управление своими публикациями
|
|
||||||
- ✅ Работа с черновиками
|
|
||||||
- ✅ Загрузка медиа-файлов
|
|
||||||
|
|
||||||
### Для посетителей:
|
|
||||||
- ✅ Просмотр опубликованных новостей
|
|
||||||
- ✅ Поиск по содержимому
|
|
||||||
- ✅ Фильтрация по категориям
|
|
||||||
- ✅ Пагинация результатов
|
|
||||||
- ✅ Адаптивный дизайн
|
|
||||||
|
|
||||||
## 🚀 Развертывание
|
|
||||||
|
|
||||||
### Файлы конфигурации:
|
|
||||||
- `package.json` - Обновлен с необходимыми зависимостями
|
|
||||||
- `.env` - Переменные окружения
|
|
||||||
- `DATABASE_SETUP.md` - Подробная инструкция по развертыванию
|
|
||||||
|
|
||||||
### Скрипты:
|
|
||||||
- `npm run db:generate` - Генерация Prisma Client
|
|
||||||
- `npm run db:push` - Применение схемы к БД
|
|
||||||
- `npm run db:seed` - Заполнение начальными данными
|
|
||||||
- `npm run db:studio` - Prisma Studio для управления данными
|
|
||||||
|
|
||||||
### Пользователи по умолчанию:
|
|
||||||
- **Администратор**: admin@ckeproekt.ru / admin123
|
|
||||||
- **Редактор**: editor@ckeproekt.ru / editor123
|
|
||||||
|
|
||||||
## 📈 Результаты
|
|
||||||
|
|
||||||
### Полностью функционирующая система:
|
|
||||||
1. ✅ **База данных** настроена и готова к работе
|
|
||||||
2. ✅ **API** реализован и протестирован
|
|
||||||
3. ✅ **Админ-панель** полностью функциональна
|
|
||||||
4. ✅ **Безопасность** обеспечена на всех уровнях
|
|
||||||
5. ✅ **Интеграция** с существующим сайтом выполнена
|
|
||||||
6. ✅ **Миграция данных** из статических файлов завершена
|
|
||||||
|
|
||||||
### Преимущества новой системы:
|
|
||||||
- **Масштабируемость**: Легко добавлять новые функции
|
|
||||||
- **Производительность**: Оптимизированные запросы к БД
|
|
||||||
- **Безопасность**: Многоуровневая защита
|
|
||||||
- **Удобство**: Интуитивная админ-панель
|
|
||||||
- **SEO**: Оптимизированные URL и метаданные
|
|
||||||
|
|
||||||
## 🔄 Миграция с существующей системы
|
|
||||||
|
|
||||||
Система автоматически мигрирует данные из `lib/news-data.ts` в базу данных при выполнении команды `npm run db:seed`.
|
|
||||||
|
|
||||||
## 📞 Поддержка
|
|
||||||
|
|
||||||
Система полностью интегрирована в ckeproekt.ru и готова к использованию. Все компоненты протестированы и оптимизированы для производственной среды.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Статус проекта**: ✅ ЗАВЕРШЕН
|
|
||||||
**Дата завершения**: $(date)
|
|
||||||
**Версия**: 1.0.0
|
|
212
S3_SETUP.md
212
S3_SETUP.md
@ -1,212 +0,0 @@
|
|||||||
# Настройка S3 хранилища
|
|
||||||
|
|
||||||
## Обзор
|
|
||||||
|
|
||||||
В проекте настроено подключение к S3-совместимому хранилищу для загрузки и хранения файлов. Используется сервис TWC Storage.
|
|
||||||
|
|
||||||
## Конфигурация
|
|
||||||
|
|
||||||
### Переменные окружения
|
|
||||||
|
|
||||||
В файле `.env` добавлены следующие переменные:
|
|
||||||
|
|
||||||
```env
|
|
||||||
# S3 Configuration
|
|
||||||
S3_ENDPOINT="https://s3.twcstorage.ru"
|
|
||||||
S3_BUCKET_NAME="617774af-ckeproekt"
|
|
||||||
S3_ACCESS_KEY_ID="I6XD2OR7YO2ZN6L6Z629"
|
|
||||||
S3_SECRET_ACCESS_KEY="9xCOoafisG0aB9lJNvdLO1UuK73fBvMcpHMdijrJ"
|
|
||||||
S3_REGION="ru-1"
|
|
||||||
|
|
||||||
# Swift Configuration (альтернативный доступ)
|
|
||||||
SWIFT_URL="https://swift.twcstorage.ru"
|
|
||||||
SWIFT_ACCESS_KEY="wu14330:swift"
|
|
||||||
SWIFT_SECRET_ACCESS_KEY="Zh6NYPbgp4IYmzKeMAUgwZFi8uLY4VpS6SIYMDge"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Использование
|
|
||||||
|
|
||||||
### API для загрузки файлов
|
|
||||||
|
|
||||||
**Эндпоинт:** `/api/upload`
|
|
||||||
|
|
||||||
**Методы:**
|
|
||||||
- `POST` - загрузка файла
|
|
||||||
- `DELETE` - удаление файла
|
|
||||||
|
|
||||||
#### Загрузка файла
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
formData.append('folder', 'images'); // опционально, по умолчанию 'uploads'
|
|
||||||
formData.append('oldUrl', oldFileUrl); // опционально, для замены существующего файла
|
|
||||||
|
|
||||||
const response = await fetch('/api/upload', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
// result.data содержит: { key, url, publicUrl }
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Удаление файла
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const response = await fetch(`/api/upload?url=${encodeURIComponent(fileUrl)}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Хук useFileUpload
|
|
||||||
|
|
||||||
Для упрощения работы с файлами создан хук `useFileUpload`:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { useFileUpload } from '@/lib/hooks/useFileUpload';
|
|
||||||
|
|
||||||
function MyComponent() {
|
|
||||||
const { uploadFile, deleteFile, isUploading, error, clearError } = useFileUpload();
|
|
||||||
|
|
||||||
const handleUpload = async (file) => {
|
|
||||||
try {
|
|
||||||
const result = await uploadFile(file, {
|
|
||||||
folder: 'images',
|
|
||||||
maxSize: 5, // MB
|
|
||||||
allowedTypes: ['image/jpeg', 'image/png']
|
|
||||||
});
|
|
||||||
console.log('Файл загружен:', result.publicUrl);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Ошибка загрузки:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{isUploading && <p>Загрузка...</p>}
|
|
||||||
{error && <p style={{color: 'red'}}>{error}</p>}
|
|
||||||
<input type="file" onChange={e => handleUpload(e.target.files[0])} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Утилиты для работы с S3
|
|
||||||
|
|
||||||
В файле `lib/s3.ts` доступны следующие функции:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import {
|
|
||||||
uploadFileToS3,
|
|
||||||
deleteFileFromS3,
|
|
||||||
getSignedUrlFromS3,
|
|
||||||
getPublicUrlFromS3,
|
|
||||||
extractKeyFromUrl,
|
|
||||||
isS3Url
|
|
||||||
} from '@/lib/s3';
|
|
||||||
|
|
||||||
// Загрузка файла
|
|
||||||
const result = await uploadFileToS3(buffer, contentType, folder, fileName);
|
|
||||||
|
|
||||||
// Удаление файла
|
|
||||||
await deleteFileFromS3(key);
|
|
||||||
|
|
||||||
// Получение подписанного URL (для приватных файлов)
|
|
||||||
const signedUrl = await getSignedUrlFromS3(key, 3600); // 1 час
|
|
||||||
|
|
||||||
// Получение публичного URL
|
|
||||||
const publicUrl = getPublicUrlFromS3(key);
|
|
||||||
|
|
||||||
// Извлечение ключа из URL
|
|
||||||
const key = extractKeyFromUrl(url);
|
|
||||||
|
|
||||||
// Проверка, является ли URL ссылкой на S3
|
|
||||||
const isS3File = isS3Url(url);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Компоненты
|
|
||||||
|
|
||||||
### ImageUpload
|
|
||||||
|
|
||||||
Компонент `ImageUpload` обновлен для работы с S3:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import ImageUpload from '@/app/admin/components/ImageUpload';
|
|
||||||
|
|
||||||
<ImageUpload
|
|
||||||
value={imageUrl}
|
|
||||||
onChange={setImageUrl}
|
|
||||||
onRemove={() => setImageUrl('')}
|
|
||||||
maxSize={5} // MB
|
|
||||||
acceptedTypes={['image/jpeg', 'image/png']}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
### S3Status
|
|
||||||
|
|
||||||
Компонент для отображения статуса подключения к S3:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import S3Status from '@/app/admin/components/S3Status';
|
|
||||||
|
|
||||||
<S3Status className="my-custom-class" />
|
|
||||||
```
|
|
||||||
|
|
||||||
## Миграция существующих изображений
|
|
||||||
|
|
||||||
Для миграции существующих изображений в S3 используйте команду:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run migrate:images
|
|
||||||
```
|
|
||||||
|
|
||||||
Скрипт:
|
|
||||||
1. Найдет все изображения в папках `public/images`
|
|
||||||
2. Загрузит их в S3
|
|
||||||
3. Создаст файл `image-mapping.json` с соответствием старых и новых URL
|
|
||||||
|
|
||||||
## Ограничения
|
|
||||||
|
|
||||||
- Максимальный размер файла: 10MB
|
|
||||||
- Поддерживаемые типы файлов:
|
|
||||||
- Изображения: JPEG, PNG, GIF, WebP, SVG
|
|
||||||
- Документы: PDF, DOC, DOCX
|
|
||||||
|
|
||||||
## Структура папок в S3
|
|
||||||
|
|
||||||
```
|
|
||||||
bucket/
|
|
||||||
├── images/ # Изображения
|
|
||||||
├── documents/ # Документы
|
|
||||||
├── uploads/ # Общие загрузки
|
|
||||||
├── certificates/ # Сертификаты
|
|
||||||
├── placeholders/ # Плейсхолдеры
|
|
||||||
└── test/ # Тестовые файлы
|
|
||||||
```
|
|
||||||
|
|
||||||
## Мониторинг
|
|
||||||
|
|
||||||
Статус подключения к S3 отображается в админ-панели в левом сайдбаре. Компонент автоматически проверяет подключение при загрузке и позволяет повторить проверку в случае ошибки.
|
|
||||||
|
|
||||||
## Безопасность
|
|
||||||
|
|
||||||
- Все файлы загружаются как публично доступные
|
|
||||||
- Для приватных файлов используйте подписанные URL
|
|
||||||
- Валидация типов и размеров файлов происходит на клиенте и сервере
|
|
||||||
- Старые файлы автоматически удаляются при замене
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Ошибка "Файл не найден"
|
|
||||||
- Проверьте, что файл выбран корректно
|
|
||||||
- Убедитесь, что размер файла не превышает лимит
|
|
||||||
|
|
||||||
### Ошибка подключения к S3
|
|
||||||
- Проверьте переменные окружения
|
|
||||||
- Убедитесь, что S3 сервис доступен
|
|
||||||
- Проверьте права доступа к бакету
|
|
||||||
|
|
||||||
### Ошибка "Неподдерживаемый тип файла"
|
|
||||||
- Проверьте список разрешенных типов файлов
|
|
||||||
- Убедитесь, что MIME-тип файла корректен
|
|
@ -1,79 +0,0 @@
|
|||||||
# Устранение неполадок S3
|
|
||||||
|
|
||||||
## Возможные причины ошибки S3
|
|
||||||
|
|
||||||
### 1. Проблемы с переменными окружения
|
|
||||||
- **Проверка**: Убедитесь, что все переменные S3 настроены в `.env`
|
|
||||||
- **Команда**: `Get-Content .env | Select-String "S3"`
|
|
||||||
- **Тест**: `curl http://localhost:3000/api/test-s3`
|
|
||||||
|
|
||||||
### 2. Проблемы с сетевым подключением
|
|
||||||
- **Симптомы**: Таймаут подключения, ошибки сети
|
|
||||||
- **Проверка**: Попробуйте получить доступ к `https://s3.twcstorage.ru` напрямую
|
|
||||||
- **Решение**: Проверьте файрвол, прокси-настройки
|
|
||||||
|
|
||||||
### 3. Проблемы с учетными данными
|
|
||||||
- **Симптомы**: Ошибки авторизации, 403 Forbidden
|
|
||||||
- **Проверка**: Убедитесь, что Access Key и Secret Key корректны
|
|
||||||
- **Решение**: Обновите учетные данные в `.env`
|
|
||||||
|
|
||||||
### 4. Проблемы с бакетом
|
|
||||||
- **Симптомы**: Ошибки "bucket not found", 404
|
|
||||||
- **Проверка**: Убедитесь, что имя бакета `617774af-ckeproekt` корректно
|
|
||||||
- **Решение**: Проверьте настройки бакета в панели управления
|
|
||||||
|
|
||||||
### 5. Проблемы с CORS
|
|
||||||
- **Симптомы**: Ошибки в браузере, но работает в API
|
|
||||||
- **Проверка**: Посмотрите на Network tab в браузере
|
|
||||||
- **Решение**: Настройте CORS для бакета
|
|
||||||
|
|
||||||
## Диагностика
|
|
||||||
|
|
||||||
### Шаг 1: Проверьте API на сервере
|
|
||||||
```bash
|
|
||||||
curl http://localhost:3000/api/test-s3
|
|
||||||
```
|
|
||||||
|
|
||||||
### Шаг 2: Проверьте в браузере
|
|
||||||
1. Откройте `http://localhost:3000/admin/test-s3`
|
|
||||||
2. Откройте консоль разработчика (F12)
|
|
||||||
3. Посмотрите на логи S3Status
|
|
||||||
|
|
||||||
### Шаг 3: Проверьте сетевые запросы
|
|
||||||
1. Откройте вкладку Network в DevTools
|
|
||||||
2. Обновите страницу
|
|
||||||
3. Найдите запрос к `/api/test-s3`
|
|
||||||
4. Посмотрите на статус и ответ
|
|
||||||
|
|
||||||
## Возможные решения
|
|
||||||
|
|
||||||
### Если S3 работает в API, но не в браузере:
|
|
||||||
1. Проверьте консоль браузера на ошибки JavaScript
|
|
||||||
2. Убедитесь, что компонент S3Status монтируется
|
|
||||||
3. Проверьте, нет ли блокировки запросов браузером
|
|
||||||
|
|
||||||
### Если S3 не работает вообще:
|
|
||||||
1. Проверьте подключение к интернету
|
|
||||||
2. Убедитесь, что сервис s3.twcstorage.ru доступен
|
|
||||||
3. Проверьте учетные данные
|
|
||||||
|
|
||||||
### Если проблема с таймаутом:
|
|
||||||
1. Увеличьте таймаут в S3Status компоненте
|
|
||||||
2. Проверьте скорость соединения
|
|
||||||
3. Попробуйте использовать другой регион S3
|
|
||||||
|
|
||||||
## Логи для отладки
|
|
||||||
|
|
||||||
Компонент S3Status выводит подробные логи в консоль:
|
|
||||||
- `S3Status: Проверяю подключение к S3...`
|
|
||||||
- `S3Status: Результат проверки:` - результат API
|
|
||||||
- `S3Status: Подключение успешно` - при успехе
|
|
||||||
- `S3Status: Ошибка подключения:` - при ошибке
|
|
||||||
- `S3Status: Исключение при проверке:` - при исключении
|
|
||||||
|
|
||||||
## Контакты для поддержки
|
|
||||||
|
|
||||||
Если проблема не решается:
|
|
||||||
1. Соберите логи из консоли браузера
|
|
||||||
2. Проверьте результат `curl http://localhost:3000/api/test-s3`
|
|
||||||
3. Опишите точные симптомы и шаги воспроизведения
|
|
@ -18,28 +18,43 @@ export default function AdminLayout({ children }: AdminLayoutProps) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Проверяем авторизацию
|
const checkAuth = async () => {
|
||||||
const adminAuth = localStorage.getItem('adminAuth');
|
try {
|
||||||
if (adminAuth) {
|
const res = await fetch('/api/admin/me', { cache: 'no-store' });
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(res.ok);
|
||||||
}
|
} catch (e) {
|
||||||
setIsLoading(false);
|
setIsAuthenticated(false);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkAuth();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLogin = (username: string, password: string) => {
|
const handleLogin = async (email: string, password: string) => {
|
||||||
// Простая проверка (в реальном проекте должна быть серверная авторизация)
|
try {
|
||||||
if (username === 'admin' && password === 'admin123') {
|
const res = await fetch('/api/admin/login', {
|
||||||
localStorage.setItem('adminAuth', JSON.stringify({ username, role: 'admin' }));
|
method: 'POST',
|
||||||
setIsAuthenticated(true);
|
headers: { 'Content-Type': 'application/json' },
|
||||||
return true;
|
body: JSON.stringify({ identifier: email, password })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = async () => {
|
||||||
localStorage.removeItem('adminAuth');
|
try {
|
||||||
setIsAuthenticated(false);
|
await fetch('/api/admin/logout', { method: 'POST' });
|
||||||
router.push('/admin');
|
} finally {
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
router.push('/admin');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
@ -154,8 +169,8 @@ export default function AdminLayout({ children }: AdminLayoutProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Компонент формы входа
|
// Компонент формы входа
|
||||||
function LoginForm({ onLogin }: { onLogin: (username: string, password: string) => boolean }) {
|
function LoginForm({ onLogin }: { onLogin: (email: string, password: string) => Promise<boolean> }) {
|
||||||
const [username, setUsername] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
@ -163,15 +178,14 @@ function LoginForm({ onLogin }: { onLogin: (username: string, password: string)
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
if (!username || !password) {
|
if (!email || !password) {
|
||||||
setError('Заполните все поля');
|
setError('Заполните все поля');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const success = onLogin(username, password);
|
onLogin(email, password).then((success) => {
|
||||||
if (!success) {
|
if (!success) setError('Неверный логин или пароль');
|
||||||
setError('Неверный логин или пароль');
|
});
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -189,16 +203,16 @@ function LoginForm({ onLogin }: { onLogin: (username: string, password: string)
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<div>
|
<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>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="username"
|
id="email"
|
||||||
type="text"
|
type="text"
|
||||||
value={username}
|
value={email}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
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"
|
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>
|
</div>
|
||||||
|
|
||||||
|
@ -4,7 +4,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ArrowLeft, Save, Eye, Upload, X, Trash2 } from 'lucide-react';
|
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';
|
// import { getNewsById } from '@/lib/news-data';
|
||||||
|
|
||||||
interface EditNewsPageProps {
|
interface EditNewsPageProps {
|
||||||
@ -35,8 +35,24 @@ export default function EditNewsPage({ params }: EditNewsPageProps) {
|
|||||||
|
|
||||||
const [errors, setErrors] = useState<Partial<NewsFormData>>({});
|
const [errors, setErrors] = useState<Partial<NewsFormData>>({});
|
||||||
const [tagInput, setTagInput] = useState('');
|
const [tagInput, setTagInput] = useState('');
|
||||||
|
const [categories, setCategories] = useState<{ id: string; name: string }[]>([
|
||||||
|
{ id: 'company', name: 'Новости компании' },
|
||||||
|
{ id: 'promotions', name: 'Акции' },
|
||||||
|
{ id: 'other', name: 'Другое' }
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
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 loadNews = async () => {
|
||||||
const resolvedParams = await params;
|
const resolvedParams = await params;
|
||||||
const id = resolvedParams.id;
|
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 }))}
|
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"
|
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}>
|
<option key={category.id} value={category.id}>
|
||||||
{category.name}
|
{category.name}
|
||||||
</option>
|
</option>
|
||||||
@ -506,10 +522,8 @@ export default function EditNewsPage({ params }: EditNewsPageProps) {
|
|||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2 mb-4">
|
<div className="flex items-center space-x-2 mb-4">
|
||||||
<span className={`px-2 py-1 rounded-full text-xs font-medium text-white ${
|
<span className={`px-2 py-1 rounded-full text-xs font-medium text-white bg-gray-500`}>
|
||||||
NEWS_CATEGORIES.find(cat => cat.id === formData.category)?.color || 'bg-gray-500'
|
{categories.find(cat => cat.id === formData.category)?.name || 'Категория'}
|
||||||
}`}>
|
|
||||||
{NEWS_CATEGORIES.find(cat => cat.id === formData.category)?.name}
|
|
||||||
</span>
|
</span>
|
||||||
{formData.featured && (
|
{formData.featured && (
|
||||||
<span className="px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
<span className="px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||||
|
@ -1,17 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { ArrowLeft, Save, X } from 'lucide-react';
|
import { ArrowLeft, Save, X } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import TextEditor from '@/app/admin/components/TextEditor';
|
import TextEditor from '@/app/admin/components/TextEditor';
|
||||||
import ImageUpload from '@/app/admin/components/ImageUpload';
|
import ImageUpload from '@/app/admin/components/ImageUpload';
|
||||||
|
|
||||||
const NEWS_CATEGORIES = [
|
interface UiCategory { id: string; name: string }
|
||||||
{ id: 'company', name: 'Новости компании' },
|
|
||||||
{ id: 'promotions', name: 'Акции' },
|
|
||||||
{ id: 'other', name: 'Другое' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function CreateNewsPage() {
|
export default function CreateNewsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -29,6 +25,25 @@ export default function CreateNewsPage() {
|
|||||||
tags: [] as string[]
|
tags: [] as string[]
|
||||||
});
|
});
|
||||||
const [tagInput, setTagInput] = useState('');
|
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) => {
|
const generateSlug = (title: string) => {
|
||||||
return title
|
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"
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
{NEWS_CATEGORIES.map((category) => (
|
{categories.map((category) => (
|
||||||
<option key={category.id} value={category.id}>
|
<option key={category.id} value={category.id}>
|
||||||
{category.name}
|
{category.name}
|
||||||
</option>
|
</option>
|
||||||
|
@ -33,13 +33,14 @@ interface NewsCategory {
|
|||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NEWS_CATEGORIES: NewsCategory[] = [
|
const DEFAULT_CATEGORIES: NewsCategory[] = [
|
||||||
{ id: 'company', name: 'Новости компании', slug: 'company', color: 'bg-blue-500' },
|
{ id: 'company', name: 'Новости компании', slug: 'company', color: 'bg-blue-500' },
|
||||||
{ id: 'promotions', name: 'Акции', slug: 'promotions', color: 'bg-green-500' },
|
{ id: 'promotions', name: 'Акции', slug: 'promotions', color: 'bg-green-500' },
|
||||||
{ id: 'other', name: 'Другое', slug: 'other', color: 'bg-purple-500' }
|
{ id: 'other', name: 'Другое', slug: 'other', color: 'bg-purple-500' }
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AdminNewsPage() {
|
export default function AdminNewsPage() {
|
||||||
|
const [categories, setCategories] = useState<NewsCategory[]>(DEFAULT_CATEGORIES);
|
||||||
const [news, setNews] = useState<NewsItem[]>([]);
|
const [news, setNews] = useState<NewsItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@ -52,6 +53,18 @@ export default function AdminNewsPage() {
|
|||||||
loadNews();
|
loadNews();
|
||||||
}, [searchQuery, selectedCategory, selectedStatus, sortBy, sortOrder]);
|
}, [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 () => {
|
const loadNews = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -102,9 +115,7 @@ export default function AdminNewsPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCategoryInfo = (categoryId: string) => {
|
const getCategoryInfo = (categoryId: string) => categories.find(cat => cat.id === categoryId);
|
||||||
return NEWS_CATEGORIES.find(cat => cat.id === categoryId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!confirm('Вы уверены, что хотите удалить эту новость?')) {
|
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"
|
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>
|
<option value="all">Все категории</option>
|
||||||
{NEWS_CATEGORIES.map((category) => (
|
{categories.map((category) => (
|
||||||
<option key={category.id} value={category.id}>
|
<option key={category.id} value={category.id}>
|
||||||
{category.name}
|
{category.name}
|
||||||
</option>
|
</option>
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Save, Plus, Edit, Trash2, Settings as SettingsIcon, Palette, Globe } from 'lucide-react';
|
import { Save, Plus, Edit, Trash2, Settings as SettingsIcon, Palette, Globe, Lock, Mail } from 'lucide-react';
|
||||||
import { NEWS_CATEGORIES, NewsCategory, NewsCategoryInfo } from '@/lib/types';
|
import { NewsCategory, NewsCategoryInfo } from '@/lib/types';
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [activeTab, setActiveTab] = useState('categories');
|
const [activeTab, setActiveTab] = useState('categories');
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const [categories, setCategories] = useState(NEWS_CATEGORIES);
|
const [categories, setCategories] = useState<NewsCategoryInfo[]>([]);
|
||||||
const [newCategory, setNewCategory] = useState({
|
const [newCategory, setNewCategory] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
@ -38,27 +38,91 @@ export default function SettingsPage() {
|
|||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'categories', name: 'Категории', icon: Palette },
|
{ id: 'categories', name: 'Категории', icon: Palette },
|
||||||
{ id: 'general', name: 'Общие', icon: SettingsIcon },
|
{ id: 'general', name: 'Общие', icon: SettingsIcon },
|
||||||
|
{ id: 'security', name: 'Безопасность', icon: Lock },
|
||||||
{ id: 'seo', name: 'SEO', icon: Globe }
|
{ 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;
|
if (!newCategory.name.trim()) return;
|
||||||
|
try {
|
||||||
const categoryId = newCategory.name.toLowerCase().replace(/\s+/g, '-') as NewsCategory;
|
const res = await fetch('/api/categories', {
|
||||||
const category: NewsCategoryInfo = {
|
method: 'POST',
|
||||||
id: categoryId,
|
headers: { 'Content-Type': 'application/json' },
|
||||||
name: newCategory.name,
|
body: JSON.stringify({ name: newCategory.name, description: newCategory.description, color: newCategory.color })
|
||||||
description: newCategory.description,
|
});
|
||||||
color: newCategory.color
|
const data = await res.json();
|
||||||
};
|
if (!res.ok) throw new Error(data.error || 'Ошибка создания категории');
|
||||||
|
// Преобразуем к локальному типу
|
||||||
setCategories([...categories, category]);
|
const created: NewsCategoryInfo = {
|
||||||
setNewCategory({ name: '', description: '', color: 'bg-blue-500' });
|
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) => {
|
const handleDeleteCategory = async (slug: string) => {
|
||||||
if (confirm('Вы уверены, что хотите удалить эту категорию?')) {
|
if (!confirm('Вы уверены, что хотите удалить эту категорию?')) return;
|
||||||
setCategories(categories.filter(cat => cat.id !== id));
|
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);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// В реальном приложении здесь будет API вызов
|
// пока сохраняем только generalSettings (к примеру локально)
|
||||||
console.log('Saving settings:', { categories, generalSettings });
|
console.log('Saving settings:', { generalSettings });
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
// Имитация задержки
|
alert('Настройки сохранены');
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
alert('Настройки сохранены успешно!');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving settings:', error);
|
console.error('Error saving settings:', error);
|
||||||
alert('Ошибка при сохранении настроек');
|
alert('Ошибка при сохранении настроек');
|
||||||
@ -131,6 +192,7 @@ export default function SettingsPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Управление категориями</h3>
|
<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 */}
|
{/* Add New Category */}
|
||||||
<div className="bg-gray-50 rounded-lg p-4 mb-6">
|
<div className="bg-gray-50 rounded-lg p-4 mb-6">
|
||||||
@ -172,7 +234,7 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Categories List */}
|
{/* Categories List */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<div key={category.id} className="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
|
<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>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<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
|
<button
|
||||||
onClick={() => handleDeleteCategory(category.id)}
|
onClick={() => handleDeleteCategory(category.id)}
|
||||||
className="p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded"
|
className="p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
@ -347,6 +407,89 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</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 { useSearchParams, useRouter, usePathname } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
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 { Search, Eye, ArrowRight } from 'lucide-react';
|
||||||
import Header from '@/app/components/Header';
|
import Header from '@/app/components/Header';
|
||||||
import Footer from '@/app/components/Footer';
|
import Footer from '@/app/components/Footer';
|
||||||
@ -60,8 +60,18 @@ export default function NewsPageComponent() {
|
|||||||
const [news, setNews] = useState<NewsItem[]>([]);
|
const [news, setNews] = useState<NewsItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [totalNews, setTotalNews] = useState(0);
|
const [totalNews, setTotalNews] = useState(0);
|
||||||
|
const [categories, setCategories] = useState<{ id: string; name: string; color: string }[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
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 () => {
|
const loadNews = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -126,9 +136,7 @@ export default function NewsPageComponent() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCategoryInfo = (categoryId: string) => {
|
const getCategoryInfo = (categoryId: string) => categories.find(cat => cat.id === categoryId);
|
||||||
return NEWS_CATEGORIES.find(cat => cat.id === categoryId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCategoryChange = (category: string) => {
|
const handleCategoryChange = (category: string) => {
|
||||||
setSelectedCategory(category);
|
setSelectedCategory(category);
|
||||||
@ -237,7 +245,7 @@ export default function NewsPageComponent() {
|
|||||||
>
|
>
|
||||||
Все
|
Все
|
||||||
</button>
|
</button>
|
||||||
{NEWS_CATEGORIES.map((category) => (
|
{categories.map((category) => (
|
||||||
<button
|
<button
|
||||||
key={category.id}
|
key={category.id}
|
||||||
onClick={() => handleCategoryChange(category.id)}
|
onClick={() => handleCategoryChange(category.id)}
|
||||||
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { NEWS_CATEGORIES } from '@/lib/types';
|
// категории будем подтягивать с API
|
||||||
|
|
||||||
interface NewsDetailPageProps {
|
interface NewsDetailPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@ -72,11 +72,18 @@ export default async function NewsDetailPage({ params }: NewsDetailPageProps) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCategoryInfo = (categoryId: string) => {
|
async function getCategoryInfo(categoryId: string) {
|
||||||
return NEWS_CATEGORIES.find(cat => cat.id === categoryId);
|
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);
|
const relatedNews = await getRelatedNews(news.category, news.slug);
|
||||||
|
@ -25,18 +25,7 @@ services:
|
|||||||
- HOSTNAME=0.0.0.0
|
- HOSTNAME=0.0.0.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test: ["CMD-SHELL", "wget -q --tries=1 --spider http://localhost:3000/api/health || exit 1"]
|
||||||
[
|
|
||||||
"CMD",
|
|
||||||
"wget",
|
|
||||||
"--no-verbose",
|
|
||||||
"--tries=1",
|
|
||||||
"--spider",
|
|
||||||
"http://localhost:3000/api/health",
|
|
||||||
"||",
|
|
||||||
"exit",
|
|
||||||
"1",
|
|
||||||
]
|
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
export const TELEGRAM_CONFIG = {
|
export const TELEGRAM_CONFIG = {
|
||||||
BOT_TOKEN: '7802786776:AAGQGYf2BxuBFmZOhRcLTX52KnUEYifTKZY',
|
BOT_TOKEN: process.env.TELEGRAM_BOT_TOKEN || '',
|
||||||
CHAT_ID: '-1002321880357',
|
CHAT_ID: process.env.TELEGRAM_CHAT_ID || '',
|
||||||
};
|
};
|
||||||
|
Reference in New Issue
Block a user