Удалены устаревшие файлы документации и отчетов, включая 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();
|
||||
|
||||
useEffect(() => {
|
||||
// Проверяем авторизацию
|
||||
const adminAuth = localStorage.getItem('adminAuth');
|
||||
if (adminAuth) {
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
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' }));
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('adminAuth');
|
||||
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
|
||||
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([...categories, category]);
|
||||
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">
|
||||
@ -184,9 +246,7 @@ 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)}
|
||||
className="p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded"
|
||||
@ -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);
|
||||
|
@ -25,18 +25,7 @@ services:
|
||||
- HOSTNAME=0.0.0.0
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"wget",
|
||||
"--no-verbose",
|
||||
"--tries=1",
|
||||
"--spider",
|
||||
"http://localhost:3000/api/health",
|
||||
"||",
|
||||
"exit",
|
||||
"1",
|
||||
]
|
||||
test: ["CMD-SHELL", "wget -q --tries=1 --spider http://localhost:3000/api/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
@ -1,4 +1,4 @@
|
||||
export const TELEGRAM_CONFIG = {
|
||||
BOT_TOKEN: '7802786776:AAGQGYf2BxuBFmZOhRcLTX52KnUEYifTKZY',
|
||||
CHAT_ID: '-1002321880357',
|
||||
BOT_TOKEN: process.env.TELEGRAM_BOT_TOKEN || '',
|
||||
CHAT_ID: process.env.TELEGRAM_CHAT_ID || '',
|
||||
};
|
||||
|
Reference in New Issue
Block a user