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

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

View File

@ -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 запросы
Админ панель теперь имеет современный, профессиональный вид с улучшенной функциональностью и удобством использования.

View File

@ -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 и является собственностью компании.

View File

@ -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`
Проблема полностью решена. Новости теперь загружаются корректно, а пользователи получают информативные сообщения в случае ошибок.

View File

@ -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

View File

@ -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-тип файла корректен

View File

@ -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. Опишите точные симптомы и шаги воспроизведения

View File

@ -18,28 +18,43 @@ export default function AdminLayout({ children }: AdminLayoutProps) {
const router = useRouter();
useEffect(() => {
// Проверяем авторизацию
const adminAuth = localStorage.getItem('adminAuth');
if (adminAuth) {
setIsAuthenticated(true);
}
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>

View File

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

View File

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

View File

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

View File

@ -1,14 +1,14 @@
'use client';
import React, { useState } from 'react';
import { Save, Plus, Edit, Trash2, Settings as SettingsIcon, Palette, Globe } from 'lucide-react';
import { NEWS_CATEGORIES, NewsCategory, NewsCategoryInfo } from '@/lib/types';
import React, { useEffect, useState } from 'react';
import { Save, Plus, Edit, Trash2, Settings as SettingsIcon, Palette, Globe, Lock, Mail } from 'lucide-react';
import { NewsCategory, NewsCategoryInfo } from '@/lib/types';
export default function SettingsPage() {
const [activeTab, setActiveTab] = useState('categories');
const [isSubmitting, setIsSubmitting] = useState(false);
const [categories, setCategories] = useState(NEWS_CATEGORIES);
const [categories, setCategories] = useState<NewsCategoryInfo[]>([]);
const [newCategory, setNewCategory] = useState({
name: '',
description: '',
@ -38,27 +38,91 @@ export default function SettingsPage() {
const tabs = [
{ id: 'categories', name: 'Категории', icon: Palette },
{ id: 'general', name: 'Общие', icon: SettingsIcon },
{ id: 'security', name: 'Безопасность', icon: Lock },
{ id: 'seo', name: 'SEO', icon: Globe }
];
const handleAddCategory = () => {
const [security, setSecurity] = useState({
email: 'admin@ckeproekt.ru',
username: 'admin',
currentPassword: '',
newPassword: '',
isSaving: false,
message: '' as string | null,
error: '' as string | null,
});
useEffect(() => {
(async () => {
try {
const res = await fetch('/api/admin/me', { cache: 'no-store' });
if (!res.ok) return;
const data = await res.json();
if (data?.user) {
setSecurity(prev => ({ ...prev, email: data.user.email, username: data.user.username }));
}
} catch {}
})();
}, []);
// загрузка категорий из БД
useEffect(() => {
(async () => {
try {
const res = await fetch('/api/categories', { cache: 'no-store' });
if (!res.ok) return;
const data = await res.json();
const mapped: NewsCategoryInfo[] = (data.data || []).map((c: any) => ({
id: c.slug as NewsCategory,
name: c.name,
description: c.description || '',
color: c.color || 'bg-blue-500'
}));
setCategories(mapped);
} catch {}
})();
}, []);
const handleAddCategory = async () => {
if (!newCategory.name.trim()) return;
const categoryId = newCategory.name.toLowerCase().replace(/\s+/g, '-') as NewsCategory;
const category: NewsCategoryInfo = {
id: categoryId,
name: newCategory.name,
description: newCategory.description,
color: newCategory.color
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>

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

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

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

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

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

View File

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

View File

@ -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);

View File

@ -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

View File

@ -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 || '',
};