+cd nakama
+
+# 2. Запустить автоматический скрипт
+./start.sh
+```
+
+Скрипт автоматически:
+- ✅ Проверит и запустит MongoDB
+- ✅ Установит все зависимости
+- ✅ Создаст .env файлы из примеров
+- ✅ Запустит приложение
+
+**Единственное что нужно** - получить Telegram Bot Token у [@BotFather](https://t.me/BotFather) и добавить в `.env`
+
+📖 Подробная инструкция: [QUICKSTART.md](QUICKSTART.md)
+
+### Ручная установка
+
+```bash
+# 1. Установить зависимости
+npm install
+cd frontend && npm install && cd ..
+
+# 2. Настроить .env файлы
+cp .env.example .env
+cp frontend/.env.example frontend/.env
+# Отредактируйте .env файлы
+
+# 3. Запустить MongoDB
+brew services start mongodb-community # macOS
+sudo systemctl start mongod # Linux
+
+# 4. Запустить приложение
+npm run dev
+```
+
+📖 Полная инструкция: [SETUP.md](SETUP.md)
+
+---
+
+## 🎨 Дизайн-система
+
+### Минималистичный iOS-стиль 2025
+
+- **Философия**: Чистый, минималистичный интерфейс в стиле нового Telegram
+- **Типографика**: SF Pro Display (iOS) / Roboto (Android)
+- **Радиус**: 16px для карточек, 12px для кнопок
+- **Тени**: Мягкие, rgba(0,0,0,0.08)
+- **Анимации**: Плавные, 0.2-0.3s ease-out
+
+### Цветовая палитра
+
+```
+🎨 Основные цвета
+├── Фон: #F2F3F5
+├── Карточки: #FFFFFF
+├── Текст: #1C1C1E
+├── Акцент: #007AFF (iOS синий)
+└── Границы: #C7C7CC
+
+🏷️ Теги
+├── Furry: #FF8A33 (оранжевый)
+├── Anime: #4A90E2 (синий)
+└── Other: #A0A0A0 (серый)
+
+⚠️ Дополнительные
+├── NSFW: #FF3B30 (красный)
+├── Успех: #34C759 (зелёный)
+└── Донаты: #FFD700 (золотой)
+```
+
+---
+
+## 📱 Технологии
+
+### Frontend
+- **React 18** - UI библиотека
+- **Vite** - Быстрый сборщик
+- **React Router** - Маршрутизация
+- **Telegram Mini App SDK** - Интеграция с Telegram
+- **Axios** - HTTP клиент
+- **Lucide React** - Иконки
+
+### Backend
+- **Node.js + Express** - API сервер
+- **MongoDB + Mongoose** - База данных
+- **Multer** - Загрузка файлов
+- **Crypto** - Telegram Init Data валидация
+
+### Интеграции
+- **Telegram Bot API** - Авторизация через Init Data
+- **e621 API** - Поиск Furry контента
+- **gelbooru API** - Поиск Anime контента
+
+---
+
+## 📂 Структура проекта
+
+```
+nakama/
+├── 📁 backend/ Backend сервер (Node.js + Express)
+│ ├── 📁 models/ MongoDB схемы (User, Post, Notification, Report)
+│ ├── 📁 routes/ API endpoints (auth, posts, users, etc)
+│ ├── 📁 middleware/ Middleware функции (auth)
+│ └── 📄 server.js Точка входа сервера
+│
+├── 📁 frontend/ Frontend приложение (React + Vite)
+│ ├── 📁 src/
+│ │ ├── 📁 components/ React компоненты (PostCard, Modals, etc)
+│ │ ├── 📁 pages/ Страницы-вкладки (Feed, Search, Notifications, Profile)
+│ │ ├── 📁 utils/ Утилиты (API клиент, Telegram SDK)
+│ │ └── 📁 styles/ CSS стили с переменными
+│ └── 📄 index.html
+│
+├── 📄 README.md Основная документация (этот файл)
+├── 📄 SETUP.md Подробная инструкция по установке
+├── 📄 QUICKSTART.md Быстрый старт за 5 минут
+├── 📄 PROJECT_STRUCTURE.md Детальная карта проекта
+├── 📄 CONTRIBUTING.md Гайд для разработчиков
+└── 📄 start.sh Скрипт быстрого запуска
+```
+
+📖 Полная карта проекта: [PROJECT_STRUCTURE.md](PROJECT_STRUCTURE.md)
+
+---
+
+## 🔐 Безопасность
+
+- ✅ **Telegram Init Data** валидация с HMAC-SHA256
+- ✅ **Безопасная загрузка** файлов с проверкой типов
+- ✅ **Роли и права** доступа (User, Moderator, Admin)
+- ✅ **XSS защита** через React
+- ✅ **CORS** настройки
+- ✅ **HTTPS only** для production
+
+---
+
+## 📚 Документация
+
+- [📖 README.md](README.md) - Основная документация (вы здесь)
+- [⚡ QUICKSTART.md](QUICKSTART.md) - Быстрый старт за 5 минут
+- [🔧 SETUP.md](SETUP.md) - Подробная инструкция по установке и деплою
+- [📂 PROJECT_STRUCTURE.md](PROJECT_STRUCTURE.md) - Детальная структура проекта
+- [🤝 CONTRIBUTING.md](CONTRIBUTING.md) - Гайд для разработчиков
+
+---
+
+## 🛠️ Разработка
+
+### Скрипты
+
+```bash
+# Запуск в dev режиме (backend + frontend)
+npm run dev
+
+# Только backend
+npm run server
+
+# Только frontend
+npm run client
+
+# Сборка для production
+npm run build
+
+# Production запуск
+npm start
+```
+
+### Локальное тестирование в Telegram
+
+Telegram требует HTTPS для Mini Apps. Используйте ngrok:
+
+```bash
+# Установить ngrok
+brew install ngrok # macOS
+
+# Запустить туннель
+ngrok http 5173
+
+# Скопируйте HTTPS URL и добавьте в BotFather
+```
+
+### Назначение модераторов
+
+```javascript
+// Подключиться к MongoDB
+mongo nakama
+
+// Назначить роль
+db.users.updateOne(
+ { telegramId: "YOUR_TELEGRAM_ID" },
+ { $set: { role: "moderator" } } // или "admin"
+)
+```
+
+---
+
+## 🚢 Деплой
+
+### Рекомендуемые платформы
+
+- **Backend**: Railway, Render, Heroku
+- **Frontend**: Vercel, Netlify
+- **MongoDB**: MongoDB Atlas (бесплатный tier)
+
+### Быстрый деплой на Railway
+
+```bash
+npm i -g @railway/cli
+railway login
+railway init
+railway up
+```
+
+📖 Подробнее: [SETUP.md - Production деплой](SETUP.md#production-деплой)
+
+---
+
+## 🎯 Roadmap
+
+### ✅ Реализовано (v2.0)
+- [x] Backend API (Express + MongoDB)
+- [x] Frontend React приложение
+- [x] Telegram авторизация
+- [x] Система постов (CRUD, лайки, комментарии, репосты)
+- [x] Теги (Furry, Anime, Other)
+- [x] Поиск (e621 + gelbooru)
+- [x] Уведомления
+- [x] Профили и подписки
+- [x] Модерация и жалобы
+- [x] Настройки фильтров
+- [x] iOS-стиль дизайн
+- [x] **Dark mode** - переключатель тем
+- [x] **Rate limiting** - защита от спама
+- [x] **Redis кэширование** - ускорение API
+- [x] **Поиск по постам** - полнотекстовый
+- [x] **Хэштеги** - система #тегов
+- [x] **Статистика** - просмотры, engagement
+- [x] **WebSocket** - real-time уведомления
+- [x] **Telegram Stars** - UI готов
+
+### 🔜 В планах (v3.0)
+- [ ] Unit и E2E тесты
+- [ ] Приватные сообщения (чаты)
+- [ ] Группы/сообщества
+- [ ] Рекомендательный алгоритм (ML)
+- [ ] Мультиязычность (EN/RU/JP)
+- [ ] Telegram Mini App Ads
+- [ ] Stories функция
+- [ ] Voice messages
+- [ ] Live streaming
+
+---
+
+## 🤝 Вклад в проект
+
+Мы рады любому вкладу! Смотрите [CONTRIBUTING.md](CONTRIBUTING.md) для деталей.
+
+### Как помочь
+1. 🐛 Сообщить о баге через Issues
+2. 💡 Предложить новую функцию
+3. 🔧 Исправить баг или добавить фичу
+4. 📖 Улучшить документацию
+5. ⭐ Поставить звезду проекту!
+
+---
+
+## 📄 Лицензия
+
+MIT License - см. [LICENSE](LICENSE)
+
+---
+
+## 👥 Авторы
+
+Создано с ❤️ для сообщества
+
+---
+
+## 📞 Поддержка
+
+- 💬 **Issues**: Создайте Issue на GitHub
+- 📖 **Документация**: Смотрите [SETUP.md](SETUP.md)
+- 🐛 **Баги**: Смотрите [Troubleshooting](SETUP.md#troubleshooting)
+
+---
+
+## 🌟 Благодарности
+
+- [Telegram](https://telegram.org/) за отличную платформу Mini Apps
+- [e621](https://e621.net/) за API
+- [gelbooru](https://gelbooru.com/) за API
+- Сообществу за поддержку и идеи
+
+---
+
+
+
+**[⬆ Наверх](#-nakamaspace---telegram-mini-app)**
+
+Сделано с 🦊 и 🎌
+
+
+
diff --git a/SETUP.md b/SETUP.md
new file mode 100644
index 0000000..183c835
--- /dev/null
+++ b/SETUP.md
@@ -0,0 +1,260 @@
+# NakamaSpace - Инструкция по установке и запуску
+
+## 📋 Требования
+
+- Node.js 16+ и npm
+- MongoDB 5+
+- Telegram Bot Token (получить у [@BotFather](https://t.me/BotFather))
+
+## 🚀 Установка
+
+### 1. Установить зависимости
+
+```bash
+# Установка зависимостей backend
+npm install
+
+# Установка зависимостей frontend
+cd frontend
+npm install
+cd ..
+```
+
+### 2. Настроить переменные окружения
+
+Создайте файл `.env` в корне проекта:
+
+```bash
+cp .env.example .env
+```
+
+Отредактируйте `.env`:
+
+```
+MONGODB_URI=mongodb://localhost:27017/nakama
+PORT=3000
+JWT_SECRET=your_secret_key_here
+TELEGRAM_BOT_TOKEN=your_bot_token_here
+NODE_ENV=development
+```
+
+Создайте файл `frontend/.env`:
+
+```bash
+cp frontend/.env.example frontend/.env
+```
+
+### 3. Запустить MongoDB
+
+```bash
+# macOS с Homebrew
+brew services start mongodb-community
+
+# Linux
+sudo systemctl start mongod
+
+# Или запустите вручную
+mongod --dbpath /path/to/data/directory
+```
+
+### 4. Создать Telegram бота
+
+1. Откройте [@BotFather](https://t.me/BotFather) в Telegram
+2. Отправьте `/newbot` и следуйте инструкциям
+3. Получите токен бота и добавьте в `.env`
+4. Настройте Web App:
+ - `/mybots` → выберите бота → Bot Settings → Menu Button
+ - Укажите URL вашего приложения
+
+## 🏃 Запуск приложения
+
+### Режим разработки
+
+```bash
+# Запустить backend и frontend одновременно
+npm run dev
+
+# Или запустить по отдельности:
+
+# Backend (http://localhost:3000)
+npm run server
+
+# Frontend (http://localhost:5173)
+npm run client
+```
+
+### Production
+
+```bash
+# Собрать frontend
+npm run build
+
+# Запустить production сервер
+npm start
+```
+
+## 🔧 Настройка Telegram Mini App
+
+### Локальная разработка
+
+Для тестирования локально:
+
+1. Используйте ngrok или подобный туннель:
+```bash
+ngrok http 5173
+```
+
+2. Скопируйте HTTPS URL от ngrok
+
+3. Настройте Menu Button в BotFather с этим URL
+
+### Production деплой
+
+Рекомендуемые платформы:
+- **Backend**: Railway, Render, Heroku
+- **Frontend**: Vercel, Netlify
+- **MongoDB**: MongoDB Atlas
+
+Пример деплоя на Railway:
+
+```bash
+# Установить Railway CLI
+npm i -g @railway/cli
+
+# Войти
+railway login
+
+# Создать проект
+railway init
+
+# Деплой
+railway up
+```
+
+## 📱 Тестирование
+
+1. Откройте бота в Telegram
+2. Нажмите на кнопку меню или отправьте команду
+3. Приложение откроется как Mini App
+
+## 🛠️ Структура проекта
+
+```
+nakama/
+├── backend/ # Backend сервер
+│ ├── models/ # MongoDB модели
+│ ├── routes/ # API endpoints
+│ ├── middleware/ # Middleware (auth, etc)
+│ └── server.js # Точка входа
+├── frontend/ # Frontend React приложение
+│ ├── src/
+│ │ ├── components/ # React компоненты
+│ │ ├── pages/ # Страницы-вкладки
+│ │ ├── utils/ # Утилиты (API, Telegram)
+│ │ └── styles/ # CSS стили
+│ └── index.html
+└── package.json
+```
+
+## 🎨 Дизайн-система
+
+Проект использует минималистичный iOS-стиль с палитрой:
+
+- **Фон**: #F2F3F5
+- **Карточки**: #FFFFFF
+- **Текст**: #1C1C1E
+- **Furry теги**: #FF8A33
+- **Anime теги**: #4A90E2
+- **Other теги**: #A0A0A0
+
+## 🔐 Модерация
+
+Для назначения модераторов/админов:
+
+```javascript
+// Подключиться к MongoDB
+mongo nakama
+
+// Обновить роль пользователя
+db.users.updateOne(
+ { telegramId: "YOUR_TELEGRAM_ID" },
+ { $set: { role: "admin" } }
+)
+```
+
+Роли:
+- `user` - обычный пользователь
+- `moderator` - может модерировать контент
+- `admin` - полные права
+
+## 📚 API Документация
+
+### Основные endpoints:
+
+#### Авторизация
+- `POST /api/auth/verify` - Верификация Telegram Init Data
+
+#### Посты
+- `GET /api/posts` - Получить ленту постов
+- `POST /api/posts` - Создать пост
+- `POST /api/posts/:id/like` - Лайкнуть пост
+- `POST /api/posts/:id/comment` - Добавить комментарий
+- `POST /api/posts/:id/repost` - Репостнуть
+- `DELETE /api/posts/:id` - Удалить пост
+
+#### Пользователи
+- `GET /api/users/:id` - Получить профиль
+- `GET /api/users/:id/posts` - Получить посты пользователя
+- `POST /api/users/:id/follow` - Подписаться/отписаться
+- `PUT /api/users/profile` - Обновить профиль
+- `GET /api/users/search/:query` - Поиск пользователей
+
+#### Уведомления
+- `GET /api/notifications` - Получить уведомления
+- `PUT /api/notifications/:id/read` - Отметить как прочитанное
+- `PUT /api/notifications/read-all` - Прочитать все
+
+#### Поиск
+- `GET /api/search/furry?query=tags` - Поиск в e621
+- `GET /api/search/anime?query=tags` - Поиск в gelbooru
+- `GET /api/search/furry/tags?query=tag` - Автокомплит тегов e621
+- `GET /api/search/anime/tags?query=tag` - Автокомплит тегов gelbooru
+- `GET /api/search/proxy/:encodedUrl` - Проксирование изображений с e621/gelbooru (для доступа из РФ)
+
+#### Модерация
+- `POST /api/moderation/report` - Создать жалобу
+- `GET /api/moderation/reports` - Получить жалобы (модераторы)
+- `PUT /api/moderation/reports/:id` - Обработать жалобу
+- `PUT /api/moderation/posts/:id/nsfw` - Установить NSFW флаг
+- `PUT /api/moderation/users/:id/ban` - Заблокировать пользователя
+
+## ⚠️ Troubleshooting
+
+### MongoDB не подключается
+```bash
+# Проверить статус
+brew services list # macOS
+sudo systemctl status mongod # Linux
+
+# Проверить порт
+lsof -i :27017
+```
+
+### CORS ошибки
+Убедитесь что `VITE_API_URL` в `frontend/.env` указывает на правильный адрес backend
+
+### Telegram Init Data invalid
+В dev режиме проверка отключена, но для production нужен валидный `TELEGRAM_BOT_TOKEN`
+
+## 📞 Поддержка
+
+Если возникли проблемы:
+1. Проверьте логи: `npm run server`
+2. Откройте DevTools в браузере
+3. Проверьте MongoDB подключение
+4. Убедитесь что все переменные окружения установлены
+
+## 🎉 Готово!
+
+Приложение должно работать! Откройте бота в Telegram и начните использовать NakamaSpace.
+
diff --git a/backend/config/index.js b/backend/config/index.js
new file mode 100644
index 0000000..fa3f1ce
--- /dev/null
+++ b/backend/config/index.js
@@ -0,0 +1,58 @@
+// Централизованная конфигурация приложения
+
+module.exports = {
+ // Сервер
+ port: process.env.PORT || 3000,
+ nodeEnv: process.env.NODE_ENV || 'development',
+
+ // MongoDB
+ mongoUri: process.env.MONGODB_URI || 'mongodb://localhost:27017/nakama',
+
+ // Redis (опционально)
+ redisUrl: process.env.REDIS_URL || null,
+
+ // JWT
+ jwtSecret: process.env.JWT_SECRET || 'nakama_secret_key_change_in_production',
+
+ // Telegram
+ telegramBotToken: process.env.TELEGRAM_BOT_TOKEN,
+
+ // Frontend URL
+ frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173',
+
+ // CORS
+ corsOrigin: process.env.CORS_ORIGIN || '*',
+
+ // Загрузка файлов
+ maxFileSize: parseInt(process.env.MAX_FILE_SIZE || '10485760'), // 10MB
+ uploadsDir: process.env.UPLOADS_DIR || 'uploads',
+
+ // Rate limiting
+ rateLimits: {
+ general: {
+ windowMs: 15 * 60 * 1000,
+ max: parseInt(process.env.RATE_LIMIT_GENERAL || '100')
+ },
+ posts: {
+ windowMs: 60 * 60 * 1000,
+ max: parseInt(process.env.RATE_LIMIT_POSTS || '10')
+ },
+ interactions: {
+ windowMs: 60 * 1000,
+ max: parseInt(process.env.RATE_LIMIT_INTERACTIONS || '20')
+ }
+ },
+
+ // Cache TTL (seconds)
+ cacheTTL: {
+ posts: parseInt(process.env.CACHE_TTL_POSTS || '300'), // 5 мин
+ users: parseInt(process.env.CACHE_TTL_USERS || '600'), // 10 мин
+ search: parseInt(process.env.CACHE_TTL_SEARCH || '180') // 3 мин
+ },
+
+ // Проверки
+ isDevelopment: () => process.env.NODE_ENV === 'development',
+ isProduction: () => process.env.NODE_ENV === 'production',
+ isTest: () => process.env.NODE_ENV === 'test'
+};
+
diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js
new file mode 100644
index 0000000..4758862
--- /dev/null
+++ b/backend/middleware/auth.js
@@ -0,0 +1,118 @@
+const crypto = require('crypto');
+const User = require('../models/User');
+
+// Проверка Telegram Init Data
+function validateTelegramWebAppData(initData, botToken) {
+ const urlParams = new URLSearchParams(initData);
+ const hash = urlParams.get('hash');
+ urlParams.delete('hash');
+
+ const dataCheckString = Array.from(urlParams.entries())
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([key, value]) => `${key}=${value}`)
+ .join('\n');
+
+ const secretKey = crypto
+ .createHmac('sha256', 'WebAppData')
+ .update(botToken)
+ .digest();
+
+ const calculatedHash = crypto
+ .createHmac('sha256', secretKey)
+ .update(dataCheckString)
+ .digest('hex');
+
+ return calculatedHash === hash;
+}
+
+// Middleware для проверки авторизации
+const authenticate = async (req, res, next) => {
+ try {
+ const initData = req.headers['x-telegram-init-data'];
+
+ if (!initData) {
+ return res.status(401).json({ error: 'Не авторизован' });
+ }
+
+ // В dev режиме можно пропустить проверку
+ if (process.env.NODE_ENV === 'development') {
+ // Получаем user из initData
+ const urlParams = new URLSearchParams(initData);
+ const userParam = urlParams.get('user');
+ if (userParam) {
+ const telegramUser = JSON.parse(userParam);
+ req.telegramUser = telegramUser;
+
+ // Найти или создать пользователя
+ let user = await User.findOne({ telegramId: telegramUser.id.toString() });
+ if (!user) {
+ user = new User({
+ telegramId: telegramUser.id.toString(),
+ username: telegramUser.username || telegramUser.first_name,
+ firstName: telegramUser.first_name,
+ lastName: telegramUser.last_name,
+ photoUrl: telegramUser.photo_url
+ });
+ await user.save();
+ }
+ req.user = user;
+ return next();
+ }
+ }
+
+ // Проверка подписи Telegram
+ const isValid = validateTelegramWebAppData(initData, process.env.TELEGRAM_BOT_TOKEN);
+
+ if (!isValid) {
+ return res.status(401).json({ error: 'Неверные данные авторизации' });
+ }
+
+ const urlParams = new URLSearchParams(initData);
+ const userParam = urlParams.get('user');
+ const telegramUser = JSON.parse(userParam);
+
+ req.telegramUser = telegramUser;
+
+ // Найти или создать пользователя
+ let user = await User.findOne({ telegramId: telegramUser.id.toString() });
+ if (!user) {
+ user = new User({
+ telegramId: telegramUser.id.toString(),
+ username: telegramUser.username || telegramUser.first_name,
+ firstName: telegramUser.first_name,
+ lastName: telegramUser.last_name,
+ photoUrl: telegramUser.photo_url
+ });
+ await user.save();
+ }
+
+ req.user = user;
+ next();
+ } catch (error) {
+ console.error('Ошибка авторизации:', error);
+ res.status(401).json({ error: 'Ошибка авторизации' });
+ }
+};
+
+// Middleware для проверки роли модератора
+const requireModerator = (req, res, next) => {
+ if (req.user.role !== 'moderator' && req.user.role !== 'admin') {
+ return res.status(403).json({ error: 'Требуются права модератора' });
+ }
+ next();
+};
+
+// Middleware для проверки роли админа
+const requireAdmin = (req, res, next) => {
+ if (req.user.role !== 'admin') {
+ return res.status(403).json({ error: 'Требуются права администратора' });
+ }
+ next();
+};
+
+module.exports = {
+ authenticate,
+ requireModerator,
+ requireAdmin
+};
+
diff --git a/backend/middleware/cache.js b/backend/middleware/cache.js
new file mode 100644
index 0000000..9ea8359
--- /dev/null
+++ b/backend/middleware/cache.js
@@ -0,0 +1,40 @@
+const cache = require('../utils/redis');
+
+// Middleware для кэширования GET запросов
+const cacheMiddleware = (ttl = 300) => {
+ return async (req, res, next) => {
+ // Кэшировать только GET запросы
+ if (req.method !== 'GET') {
+ return next();
+ }
+
+ if (!cache.isConnected()) {
+ return next();
+ }
+
+ const key = `cache:${req.originalUrl}`;
+
+ try {
+ const cachedData = await cache.get(key);
+
+ if (cachedData) {
+ return res.json(cachedData);
+ }
+
+ // Перехватить res.json для сохранения в кэш
+ const originalJson = res.json.bind(res);
+ res.json = (data) => {
+ cache.set(key, data, ttl);
+ return originalJson(data);
+ };
+
+ next();
+ } catch (error) {
+ console.error('Cache middleware error:', error);
+ next();
+ }
+ };
+};
+
+module.exports = cacheMiddleware;
+
diff --git a/backend/middleware/rateLimiter.js b/backend/middleware/rateLimiter.js
new file mode 100644
index 0000000..ff39190
--- /dev/null
+++ b/backend/middleware/rateLimiter.js
@@ -0,0 +1,48 @@
+const rateLimit = require('express-rate-limit');
+
+// Общий лимит для API
+const generalLimiter = rateLimit({
+ windowMs: 15 * 60 * 1000, // 15 минут
+ max: 100, // 100 запросов
+ message: 'Слишком много запросов, попробуйте позже',
+ standardHeaders: true,
+ legacyHeaders: false,
+});
+
+// Строгий лимит для создания постов
+const postCreationLimiter = rateLimit({
+ windowMs: 60 * 60 * 1000, // 1 час
+ max: 10, // 10 постов в час
+ message: 'Вы создаёте слишком много постов, подождите немного',
+ skipSuccessfulRequests: true,
+});
+
+// Лимит для авторизации
+const authLimiter = rateLimit({
+ windowMs: 15 * 60 * 1000, // 15 минут
+ max: 5, // 5 попыток
+ message: 'Слишком много попыток авторизации',
+});
+
+// Лимит для поиска
+const searchLimiter = rateLimit({
+ windowMs: 60 * 1000, // 1 минута
+ max: 30, // 30 запросов
+ message: 'Слишком много поисковых запросов',
+});
+
+// Лимит для лайков/комментариев (защита от спама)
+const interactionLimiter = rateLimit({
+ windowMs: 60 * 1000, // 1 минута
+ max: 20, // 20 действий
+ message: 'Вы слишком активны, немного подождите',
+});
+
+module.exports = {
+ generalLimiter,
+ postCreationLimiter,
+ authLimiter,
+ searchLimiter,
+ interactionLimiter
+};
+
diff --git a/backend/models/Notification.js b/backend/models/Notification.js
new file mode 100644
index 0000000..981112a
--- /dev/null
+++ b/backend/models/Notification.js
@@ -0,0 +1,34 @@
+const mongoose = require('mongoose');
+
+const NotificationSchema = new mongoose.Schema({
+ recipient: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'User',
+ required: true
+ },
+ sender: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'User',
+ required: true
+ },
+ type: {
+ type: String,
+ enum: ['follow', 'like', 'comment', 'repost', 'mention'],
+ required: true
+ },
+ post: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'Post'
+ },
+ read: {
+ type: Boolean,
+ default: false
+ },
+ createdAt: {
+ type: Date,
+ default: Date.now
+ }
+});
+
+module.exports = mongoose.model('Notification', NotificationSchema);
+
diff --git a/backend/models/Post.js b/backend/models/Post.js
new file mode 100644
index 0000000..12a9eb3
--- /dev/null
+++ b/backend/models/Post.js
@@ -0,0 +1,72 @@
+const mongoose = require('mongoose');
+
+const CommentSchema = new mongoose.Schema({
+ author: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'User',
+ required: true
+ },
+ content: {
+ type: String,
+ required: true,
+ maxlength: 500
+ },
+ createdAt: {
+ type: Date,
+ default: Date.now
+ }
+});
+
+const PostSchema = new mongoose.Schema({
+ author: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'User',
+ required: true
+ },
+ content: {
+ type: String,
+ maxlength: 2000
+ },
+ hashtags: [{
+ type: String,
+ lowercase: true,
+ trim: true
+ }],
+ imageUrl: String,
+ tags: [{
+ type: String,
+ enum: ['furry', 'anime', 'other'],
+ required: true
+ }],
+ mentionedUsers: [{
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'User'
+ }],
+ isNSFW: {
+ type: Boolean,
+ default: false
+ },
+ likes: [{
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'User'
+ }],
+ comments: [CommentSchema],
+ reposts: [{
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'User'
+ }],
+ views: {
+ type: Number,
+ default: 0
+ },
+ createdAt: {
+ type: Date,
+ default: Date.now
+ }
+});
+
+// Текстовый индекс для поиска
+PostSchema.index({ content: 'text', hashtags: 'text' });
+
+module.exports = mongoose.model('Post', PostSchema);
+
diff --git a/backend/models/Report.js b/backend/models/Report.js
new file mode 100644
index 0000000..8b0d03a
--- /dev/null
+++ b/backend/models/Report.js
@@ -0,0 +1,35 @@
+const mongoose = require('mongoose');
+
+const ReportSchema = new mongoose.Schema({
+ reporter: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'User',
+ required: true
+ },
+ post: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'Post',
+ required: true
+ },
+ reason: {
+ type: String,
+ required: true,
+ maxlength: 500
+ },
+ status: {
+ type: String,
+ enum: ['pending', 'reviewed', 'resolved', 'dismissed'],
+ default: 'pending'
+ },
+ reviewedBy: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'User'
+ },
+ createdAt: {
+ type: Date,
+ default: Date.now
+ }
+});
+
+module.exports = mongoose.model('Report', ReportSchema);
+
diff --git a/backend/models/User.js b/backend/models/User.js
new file mode 100644
index 0000000..04ef57e
--- /dev/null
+++ b/backend/models/User.js
@@ -0,0 +1,58 @@
+const mongoose = require('mongoose');
+
+const UserSchema = new mongoose.Schema({
+ telegramId: {
+ type: String,
+ required: true,
+ unique: true
+ },
+ username: {
+ type: String,
+ required: true
+ },
+ firstName: String,
+ lastName: String,
+ photoUrl: String,
+ bio: {
+ type: String,
+ default: '',
+ maxlength: 300
+ },
+ role: {
+ type: String,
+ enum: ['user', 'moderator', 'admin'],
+ default: 'user'
+ },
+ followers: [{
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'User'
+ }],
+ following: [{
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'User'
+ }],
+ settings: {
+ whitelist: {
+ noFurry: { type: Boolean, default: false },
+ onlyAnime: { type: Boolean, default: false },
+ noNSFW: { type: Boolean, default: false }
+ },
+ searchPreference: {
+ type: String,
+ enum: ['furry', 'anime', 'mixed'],
+ default: 'mixed'
+ }
+ },
+ banned: {
+ type: Boolean,
+ default: false
+ },
+ bannedUntil: Date,
+ createdAt: {
+ type: Date,
+ default: Date.now
+ }
+});
+
+module.exports = mongoose.model('User', UserSchema);
+
diff --git a/backend/routes/auth.js b/backend/routes/auth.js
new file mode 100644
index 0000000..a00fed5
--- /dev/null
+++ b/backend/routes/auth.js
@@ -0,0 +1,37 @@
+const express = require('express');
+const router = express.Router();
+const { authenticate } = require('../middleware/auth');
+
+// Проверка авторизации и получение данных пользователя
+router.post('/verify', authenticate, async (req, res) => {
+ try {
+ const user = await req.user.populate([
+ { path: 'followers', select: 'username firstName lastName photoUrl' },
+ { path: 'following', select: 'username firstName lastName photoUrl' }
+ ]);
+
+ res.json({
+ success: true,
+ user: {
+ id: user._id,
+ telegramId: user.telegramId,
+ username: user.username,
+ firstName: user.firstName,
+ lastName: user.lastName,
+ photoUrl: user.photoUrl,
+ bio: user.bio,
+ role: user.role,
+ followersCount: user.followers.length,
+ followingCount: user.following.length,
+ settings: user.settings,
+ banned: user.banned
+ }
+ });
+ } catch (error) {
+ console.error('Ошибка verify:', error);
+ res.status(500).json({ error: 'Ошибка сервера' });
+ }
+});
+
+module.exports = router;
+
diff --git a/backend/routes/moderation.js b/backend/routes/moderation.js
new file mode 100644
index 0000000..1423257
--- /dev/null
+++ b/backend/routes/moderation.js
@@ -0,0 +1,166 @@
+const express = require('express');
+const router = express.Router();
+const { authenticate, requireModerator } = require('../middleware/auth');
+const Report = require('../models/Report');
+const Post = require('../models/Post');
+const User = require('../models/User');
+
+// Создать репорт
+router.post('/report', authenticate, async (req, res) => {
+ try {
+ const { postId, reason } = req.body;
+
+ if (!postId || !reason) {
+ return res.status(400).json({ error: 'postId и reason обязательны' });
+ }
+
+ const post = await Post.findById(postId);
+ if (!post) {
+ return res.status(404).json({ error: 'Пост не найден' });
+ }
+
+ const report = new Report({
+ reporter: req.user._id,
+ post: postId,
+ reason
+ });
+
+ await report.save();
+
+ res.status(201).json({
+ message: 'Жалоба отправлена',
+ report
+ });
+ } catch (error) {
+ console.error('Ошибка создания репорта:', error);
+ res.status(500).json({ error: 'Ошибка сервера' });
+ }
+});
+
+// Получить все репорты (только модераторы)
+router.get('/reports', authenticate, requireModerator, async (req, res) => {
+ try {
+ const { status = 'pending', page = 1, limit = 20 } = req.query;
+
+ const query = status === 'all' ? {} : { status };
+
+ const reports = await Report.find(query)
+ .populate('reporter', 'username firstName lastName')
+ .populate('post')
+ .populate('reviewedBy', 'username firstName lastName')
+ .sort({ createdAt: -1 })
+ .limit(limit * 1)
+ .skip((page - 1) * limit)
+ .exec();
+
+ const count = await Report.countDocuments(query);
+
+ res.json({
+ reports,
+ totalPages: Math.ceil(count / limit),
+ currentPage: page
+ });
+ } catch (error) {
+ console.error('Ошибка получения репортов:', error);
+ res.status(500).json({ error: 'Ошибка сервера' });
+ }
+});
+
+// Обработать репорт (только модераторы)
+router.put('/reports/:id', authenticate, requireModerator, async (req, res) => {
+ try {
+ const { status, action } = req.body; // action: 'delete_post', 'ban_user', 'dismiss'
+
+ const report = await Report.findById(req.params.id).populate('post');
+
+ if (!report) {
+ return res.status(404).json({ error: 'Репорт не найден' });
+ }
+
+ report.status = status || 'reviewed';
+ report.reviewedBy = req.user._id;
+
+ // Выполнить действие
+ if (action === 'delete_post' && report.post) {
+ await Post.findByIdAndDelete(report.post._id);
+ report.status = 'resolved';
+ } else if (action === 'ban_user' && report.post) {
+ const author = await User.findById(report.post.author);
+ if (author) {
+ author.banned = true;
+ author.bannedUntil = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 дней
+ await author.save();
+ }
+ report.status = 'resolved';
+ } else if (action === 'dismiss') {
+ report.status = 'dismissed';
+ }
+
+ await report.save();
+
+ res.json({
+ message: 'Репорт обработан',
+ report
+ });
+ } catch (error) {
+ console.error('Ошибка обработки репорта:', error);
+ res.status(500).json({ error: 'Ошибка сервера' });
+ }
+});
+
+// Установить NSFW флаг (модераторы)
+router.put('/posts/:id/nsfw', authenticate, requireModerator, async (req, res) => {
+ try {
+ const { isNSFW } = req.body;
+
+ const post = await Post.findById(req.params.id);
+
+ if (!post) {
+ return res.status(404).json({ error: 'Пост не найден' });
+ }
+
+ post.isNSFW = isNSFW;
+ await post.save();
+
+ res.json({
+ message: 'NSFW статус обновлен',
+ post
+ });
+ } catch (error) {
+ console.error('Ошибка обновления NSFW:', error);
+ res.status(500).json({ error: 'Ошибка сервера' });
+ }
+});
+
+// Заблокировать/разблокировать пользователя (модераторы)
+router.put('/users/:id/ban', authenticate, requireModerator, async (req, res) => {
+ try {
+ const { banned, days } = req.body;
+
+ const user = await User.findById(req.params.id);
+
+ if (!user) {
+ return res.status(404).json({ error: 'Пользователь не найден' });
+ }
+
+ user.banned = banned;
+ if (banned && days) {
+ user.bannedUntil = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
+ } else {
+ user.bannedUntil = null;
+ }
+
+ await user.save();
+
+ res.json({
+ message: banned ? 'Пользователь заблокирован' : 'Пользователь разблокирован',
+ user
+ });
+ } catch (error) {
+ console.error('Ошибка блокировки пользователя:', error);
+ res.status(500).json({ error: 'Ошибка сервера' });
+ }
+});
+
+module.exports = router;
+
diff --git a/backend/routes/notifications.js b/backend/routes/notifications.js
new file mode 100644
index 0000000..850c100
--- /dev/null
+++ b/backend/routes/notifications.js
@@ -0,0 +1,75 @@
+const express = require('express');
+const router = express.Router();
+const { authenticate } = require('../middleware/auth');
+const Notification = require('../models/Notification');
+
+// Получить уведомления пользователя
+router.get('/', authenticate, async (req, res) => {
+ try {
+ const { page = 1, limit = 50 } = req.query;
+
+ const notifications = await Notification.find({ recipient: req.user._id })
+ .populate('sender', 'username firstName lastName photoUrl')
+ .populate('post', 'content imageUrl')
+ .sort({ createdAt: -1 })
+ .limit(limit * 1)
+ .skip((page - 1) * limit)
+ .exec();
+
+ const count = await Notification.countDocuments({ recipient: req.user._id });
+ const unreadCount = await Notification.countDocuments({
+ recipient: req.user._id,
+ read: false
+ });
+
+ res.json({
+ notifications,
+ totalPages: Math.ceil(count / limit),
+ currentPage: page,
+ unreadCount
+ });
+ } catch (error) {
+ console.error('Ошибка получения уведомлений:', error);
+ res.status(500).json({ error: 'Ошибка сервера' });
+ }
+});
+
+// Отметить уведомление как прочитанное
+router.put('/:id/read', authenticate, async (req, res) => {
+ try {
+ const notification = await Notification.findOne({
+ _id: req.params.id,
+ recipient: req.user._id
+ });
+
+ if (!notification) {
+ return res.status(404).json({ error: 'Уведомление не найдено' });
+ }
+
+ notification.read = true;
+ await notification.save();
+
+ res.json({ message: 'Уведомление прочитано' });
+ } catch (error) {
+ console.error('Ошибка обновления уведомления:', error);
+ res.status(500).json({ error: 'Ошибка сервера' });
+ }
+});
+
+// Отметить все уведомления как прочитанные
+router.put('/read-all', authenticate, async (req, res) => {
+ try {
+ await Notification.updateMany(
+ { recipient: req.user._id, read: false },
+ { read: true }
+ );
+
+ res.json({ message: 'Все уведомления прочитаны' });
+ } catch (error) {
+ console.error('Ошибка обновления уведомлений:', error);
+ res.status(500).json({ error: 'Ошибка сервера' });
+ }
+});
+
+module.exports = router;
+
diff --git a/backend/routes/postSearch.js b/backend/routes/postSearch.js
new file mode 100644
index 0000000..10d046a
--- /dev/null
+++ b/backend/routes/postSearch.js
@@ -0,0 +1,125 @@
+const express = require('express');
+const router = express.Router();
+const { authenticate } = require('../middleware/auth');
+const { searchLimiter } = require('../middleware/rateLimiter');
+const Post = require('../models/Post');
+
+// Поиск постов по тексту и хэштегам
+router.get('/', authenticate, searchLimiter, async (req, res) => {
+ try {
+ const { query, hashtag, page = 1, limit = 20 } = req.query;
+
+ let searchQuery = {};
+
+ // Применить whitelist настройки пользователя
+ if (req.user.settings.whitelist.noFurry) {
+ searchQuery.tags = { $ne: 'furry' };
+ }
+ if (req.user.settings.whitelist.onlyAnime) {
+ searchQuery.tags = 'anime';
+ }
+ if (req.user.settings.whitelist.noNSFW) {
+ searchQuery.isNSFW = false;
+ }
+
+ // Поиск по хэштегу
+ if (hashtag) {
+ searchQuery.hashtags = hashtag.toLowerCase();
+ }
+ // Полнотекстовый поиск
+ else if (query) {
+ searchQuery.$text = { $search: query };
+ } else {
+ return res.status(400).json({ error: 'Параметр query или hashtag обязателен' });
+ }
+
+ const posts = await Post.find(searchQuery)
+ .populate('author', 'username firstName lastName photoUrl')
+ .populate('mentionedUsers', 'username firstName lastName')
+ .populate('comments.author', 'username firstName lastName photoUrl')
+ .sort(query ? { score: { $meta: 'textScore' } } : { createdAt: -1 })
+ .limit(limit * 1)
+ .skip((page - 1) * limit)
+ .exec();
+
+ const count = await Post.countDocuments(searchQuery);
+
+ res.json({
+ posts,
+ totalPages: Math.ceil(count / limit),
+ currentPage: page
+ });
+ } catch (error) {
+ console.error('Ошибка поиска постов:', error);
+ res.status(500).json({ error: 'Ошибка сервера' });
+ }
+});
+
+// Получить популярные хэштеги
+router.get('/trending-hashtags', authenticate, async (req, res) => {
+ try {
+ const { limit = 20 } = req.query;
+
+ const hashtags = await Post.aggregate([
+ { $unwind: '$hashtags' },
+ { $group: { _id: '$hashtags', count: { $sum: 1 } } },
+ { $sort: { count: -1 } },
+ { $limit: parseInt(limit) }
+ ]);
+
+ res.json({
+ hashtags: hashtags.map(h => ({
+ tag: h._id,
+ count: h.count
+ }))
+ });
+ } catch (error) {
+ console.error('Ошибка получения трендовых хэштегов:', error);
+ res.status(500).json({ error: 'Ошибка сервера' });
+ }
+});
+
+// Получить посты по хэштегу
+router.get('/hashtag/:tag', authenticate, async (req, res) => {
+ try {
+ const { page = 1, limit = 20 } = req.query;
+ const hashtag = req.params.tag.toLowerCase();
+
+ const query = { hashtags: hashtag };
+
+ // Применить whitelist настройки пользователя
+ if (req.user.settings.whitelist.noFurry) {
+ query.tags = { $ne: 'furry' };
+ }
+ if (req.user.settings.whitelist.onlyAnime) {
+ query.tags = 'anime';
+ }
+ if (req.user.settings.whitelist.noNSFW) {
+ query.isNSFW = false;
+ }
+
+ const posts = await Post.find(query)
+ .populate('author', 'username firstName lastName photoUrl')
+ .populate('mentionedUsers', 'username firstName lastName')
+ .populate('comments.author', 'username firstName lastName photoUrl')
+ .sort({ createdAt: -1 })
+ .limit(limit * 1)
+ .skip((page - 1) * limit)
+ .exec();
+
+ const count = await Post.countDocuments(query);
+
+ res.json({
+ hashtag,
+ posts,
+ totalPages: Math.ceil(count / limit),
+ currentPage: page
+ });
+ } catch (error) {
+ console.error('Ошибка получения постов по хэштегу:', error);
+ res.status(500).json({ error: 'Ошибка сервера' });
+ }
+});
+
+module.exports = router;
+
diff --git a/backend/routes/posts.js b/backend/routes/posts.js
new file mode 100644
index 0000000..c6bc5ec
--- /dev/null
+++ b/backend/routes/posts.js
@@ -0,0 +1,284 @@
+const express = require('express');
+const router = express.Router();
+const multer = require('multer');
+const path = require('path');
+const fs = require('fs');
+const { authenticate } = require('../middleware/auth');
+const { postCreationLimiter, interactionLimiter } = require('../middleware/rateLimiter');
+const { searchLimiter } = require('../middleware/rateLimiter');
+const Post = require('../models/Post');
+const Notification = require('../models/Notification');
+const { extractHashtags } = require('../utils/hashtags');
+
+// Настройка multer для загрузки изображений
+const storage = multer.diskStorage({
+ destination: (req, file, cb) => {
+ const dir = path.join(__dirname, '../uploads/posts');
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+ cb(null, dir);
+ },
+ filename: (req, file, cb) => {
+ const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
+ cb(null, uniqueSuffix + path.extname(file.originalname));
+ }
+});
+
+const upload = multer({
+ storage: storage,
+ limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
+ fileFilter: (req, file, cb) => {
+ const allowedTypes = /jpeg|jpg|png|gif|webp/;
+ const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
+ const mimetype = allowedTypes.test(file.mimetype);
+
+ if (mimetype && extname) {
+ return cb(null, true);
+ } else {
+ cb(new Error('Только изображения разрешены'));
+ }
+ }
+});
+
+// Получить ленту постов
+router.get('/', authenticate, async (req, res) => {
+ try {
+ const { page = 1, limit = 20, tag, userId } = req.query;
+ const query = {};
+
+ // Фильтр по тегу
+ if (tag) {
+ query.tags = tag;
+ }
+
+ // Фильтр по пользователю
+ if (userId) {
+ query.author = userId;
+ }
+
+ // Применить whitelist настройки пользователя
+ if (req.user.settings.whitelist.noFurry) {
+ query.tags = { $ne: 'furry' };
+ }
+ if (req.user.settings.whitelist.onlyAnime) {
+ query.tags = 'anime';
+ }
+ if (req.user.settings.whitelist.noNSFW) {
+ query.isNSFW = false;
+ }
+
+ const posts = await Post.find(query)
+ .populate('author', 'username firstName lastName photoUrl')
+ .populate('mentionedUsers', 'username firstName lastName')
+ .populate('comments.author', 'username firstName lastName photoUrl')
+ .sort({ createdAt: -1 })
+ .limit(limit * 1)
+ .skip((page - 1) * limit)
+ .exec();
+
+ const count = await Post.countDocuments(query);
+
+ res.json({
+ posts,
+ totalPages: Math.ceil(count / limit),
+ currentPage: page
+ });
+ } catch (error) {
+ console.error('Ошибка получения постов:', error);
+ res.status(500).json({ error: 'Ошибка сервера' });
+ }
+});
+
+// Создать пост
+router.post('/', authenticate, postCreationLimiter, upload.single('image'), async (req, res) => {
+ try {
+ const { content, tags, mentionedUsers, isNSFW } = req.body;
+
+ // Проверка тегов
+ const parsedTags = JSON.parse(tags || '[]');
+ if (!parsedTags.length) {
+ return res.status(400).json({ error: 'Теги обязательны' });
+ }
+
+ // Извлечь хэштеги из контента
+ const hashtags = extractHashtags(content);
+
+ const post = new Post({
+ author: req.user._id,
+ content,
+ imageUrl: req.file ? `/uploads/posts/${req.file.filename}` : null,
+ tags: parsedTags,
+ hashtags,
+ mentionedUsers: mentionedUsers ? JSON.parse(mentionedUsers) : [],
+ isNSFW: isNSFW === 'true'
+ });
+
+ await post.save();
+ await post.populate('author', 'username firstName lastName photoUrl');
+
+ // Создать уведомления для упомянутых пользователей
+ if (post.mentionedUsers.length > 0) {
+ const notifications = post.mentionedUsers.map(userId => ({
+ recipient: userId,
+ sender: req.user._id,
+ type: 'mention',
+ post: post._id
+ }));
+ await Notification.insertMany(notifications);
+ }
+
+ res.status(201).json({ post });
+ } catch (error) {
+ console.error('Ошибка создания поста:', error);
+ res.status(500).json({ error: 'Ошибка создания поста' });
+ }
+});
+
+// Лайкнуть пост
+router.post('/:id/like', authenticate, interactionLimiter, async (req, res) => {
+ try {
+ const post = await Post.findById(req.params.id);
+
+ if (!post) {
+ return res.status(404).json({ error: 'Пост не найден' });
+ }
+
+ const alreadyLiked = post.likes.includes(req.user._id);
+
+ if (alreadyLiked) {
+ // Убрать лайк
+ post.likes = post.likes.filter(id => !id.equals(req.user._id));
+ } else {
+ // Добавить лайк
+ post.likes.push(req.user._id);
+
+ // Создать уведомление
+ if (!post.author.equals(req.user._id)) {
+ const notification = new Notification({
+ recipient: post.author,
+ sender: req.user._id,
+ type: 'like',
+ post: post._id
+ });
+ await notification.save();
+ }
+ }
+
+ await post.save();
+ res.json({ likes: post.likes.length, liked: !alreadyLiked });
+ } catch (error) {
+ console.error('Ошибка лайка:', error);
+ res.status(500).json({ error: 'Ошибка сервера' });
+ }
+});
+
+// Добавить комментарий
+router.post('/:id/comment', authenticate, interactionLimiter, async (req, res) => {
+ try {
+ const { content } = req.body;
+
+ if (!content || content.trim().length === 0) {
+ return res.status(400).json({ error: 'Комментарий не может быть пустым' });
+ }
+
+ const post = await Post.findById(req.params.id);
+
+ if (!post) {
+ return res.status(404).json({ error: 'Пост не найден' });
+ }
+
+ post.comments.push({
+ author: req.user._id,
+ content
+ });
+
+ await post.save();
+ await post.populate('comments.author', 'username firstName lastName photoUrl');
+
+ // Создать уведомление
+ if (!post.author.equals(req.user._id)) {
+ const notification = new Notification({
+ recipient: post.author,
+ sender: req.user._id,
+ type: 'comment',
+ post: post._id
+ });
+ await notification.save();
+ }
+
+ res.json({ comments: post.comments });
+ } catch (error) {
+ console.error('Ошибка комментария:', error);
+ res.status(500).json({ error: 'Ошибка сервера' });
+ }
+});
+
+// Репостнуть
+router.post('/:id/repost', authenticate, async (req, res) => {
+ try {
+ const post = await Post.findById(req.params.id);
+
+ if (!post) {
+ return res.status(404).json({ error: 'Пост не найден' });
+ }
+
+ const alreadyReposted = post.reposts.includes(req.user._id);
+
+ if (alreadyReposted) {
+ post.reposts = post.reposts.filter(id => !id.equals(req.user._id));
+ } else {
+ post.reposts.push(req.user._id);
+
+ // Создать уведомление
+ if (!post.author.equals(req.user._id)) {
+ const notification = new Notification({
+ recipient: post.author,
+ sender: req.user._id,
+ type: 'repost',
+ post: post._id
+ });
+ await notification.save();
+ }
+ }
+
+ await post.save();
+ res.json({ reposts: post.reposts.length, reposted: !alreadyReposted });
+ } catch (error) {
+ console.error('Ошибка репоста:', error);
+ res.status(500).json({ error: 'Ошибка сервера' });
+ }
+});
+
+// Удалить пост (автор или модератор)
+router.delete('/:id', authenticate, async (req, res) => {
+ try {
+ const post = await Post.findById(req.params.id);
+
+ if (!post) {
+ return res.status(404).json({ error: 'Пост не найден' });
+ }
+
+ // Проверить права
+ if (!post.author.equals(req.user._id) && req.user.role !== 'moderator' && req.user.role !== 'admin') {
+ return res.status(403).json({ error: 'Нет прав на удаление' });
+ }
+
+ // Удалить изображение если есть
+ if (post.imageUrl) {
+ const imagePath = path.join(__dirname, '..', post.imageUrl);
+ if (fs.existsSync(imagePath)) {
+ fs.unlinkSync(imagePath);
+ }
+ }
+
+ await Post.findByIdAndDelete(req.params.id);
+ res.json({ message: 'Пост удален' });
+ } catch (error) {
+ console.error('Ошибка удаления поста:', error);
+ res.status(500).json({ error: 'Ошибка сервера' });
+ }
+});
+
+module.exports = router;
+
diff --git a/backend/routes/search.js b/backend/routes/search.js
new file mode 100644
index 0000000..1903633
--- /dev/null
+++ b/backend/routes/search.js
@@ -0,0 +1,207 @@
+const express = require('express');
+const router = express.Router();
+const axios = require('axios');
+const { authenticate } = require('../middleware/auth');
+
+// Функция для создания прокси URL
+function createProxyUrl(originalUrl) {
+ if (!originalUrl) return null;
+
+ // Кодируем URL в base64
+ const encodedUrl = Buffer.from(originalUrl).toString('base64');
+ return `/api/search/proxy/${encodedUrl}`;
+}
+
+// Эндпоинт для проксирования изображений
+router.get('/proxy/:encodedUrl', async (req, res) => {
+ try {
+ const { encodedUrl } = req.params;
+
+ // Декодируем URL
+ const originalUrl = Buffer.from(encodedUrl, 'base64').toString('utf-8');
+
+ // Проверяем, что URL от разрешенных доменов
+ const allowedDomains = [
+ 'e621.net',
+ 'static1.e621.net',
+ 'gelbooru.com',
+ 'img3.gelbooru.com',
+ 'img2.gelbooru.com',
+ 'img1.gelbooru.com',
+ 'simg3.gelbooru.com',
+ 'simg4.gelbooru.com'
+ ];
+ const urlObj = new URL(originalUrl);
+
+ if (!allowedDomains.some(domain => urlObj.hostname.includes(domain))) {
+ return res.status(403).json({ error: 'Запрещенный домен' });
+ }
+
+ // Запрашиваем изображение
+ const response = await axios.get(originalUrl, {
+ responseType: 'stream',
+ headers: {
+ 'User-Agent': 'NakamaSpace/1.0',
+ 'Referer': urlObj.origin
+ },
+ timeout: 30000 // 30 секунд таймаут
+ });
+
+ // Копируем заголовки
+ res.setHeader('Content-Type', response.headers['content-type']);
+ res.setHeader('Cache-Control', 'public, max-age=86400'); // Кешируем на 24 часа
+
+ if (response.headers['content-length']) {
+ res.setHeader('Content-Length', response.headers['content-length']);
+ }
+
+ // Стримим изображение
+ response.data.pipe(res);
+ } catch (error) {
+ console.error('Ошибка проксирования изображения:', error.message);
+ res.status(500).json({ error: 'Ошибка загрузки изображения' });
+ }
+});
+
+// e621 API поиск
+router.get('/furry', authenticate, async (req, res) => {
+ try {
+ const { query, limit = 50, page = 1 } = req.query;
+
+ if (!query) {
+ return res.status(400).json({ error: 'Параметр query обязателен' });
+ }
+
+ const response = await axios.get('https://e621.net/posts.json', {
+ params: {
+ tags: query,
+ limit,
+ page
+ },
+ headers: {
+ 'User-Agent': 'NakamaSpace/1.0'
+ }
+ });
+
+ const posts = response.data.posts.map(post => ({
+ id: post.id,
+ url: createProxyUrl(post.file.url),
+ preview: createProxyUrl(post.preview.url),
+ tags: post.tags.general,
+ rating: post.rating,
+ score: post.score.total,
+ source: 'e621'
+ }));
+
+ res.json({ posts });
+ } catch (error) {
+ console.error('Ошибка e621 API:', error);
+ res.status(500).json({ error: 'Ошибка поиска' });
+ }
+});
+
+// Gelbooru API поиск
+router.get('/anime', authenticate, async (req, res) => {
+ try {
+ const { query, limit = 50, page = 1 } = req.query;
+
+ if (!query) {
+ return res.status(400).json({ error: 'Параметр query обязателен' });
+ }
+
+ const response = await axios.get('https://gelbooru.com/index.php', {
+ params: {
+ page: 'dapi',
+ s: 'post',
+ q: 'index',
+ json: 1,
+ tags: query,
+ limit,
+ pid: page
+ }
+ });
+
+ const posts = (response.data.post || []).map(post => ({
+ id: post.id,
+ url: createProxyUrl(post.file_url),
+ preview: createProxyUrl(post.preview_url),
+ tags: post.tags ? post.tags.split(' ') : [],
+ rating: post.rating,
+ score: post.score,
+ source: 'gelbooru'
+ }));
+
+ res.json({ posts });
+ } catch (error) {
+ console.error('Ошибка Gelbooru API:', error);
+ res.status(500).json({ error: 'Ошибка поиска' });
+ }
+});
+
+// Автокомплит тегов для e621
+router.get('/furry/tags', authenticate, async (req, res) => {
+ try {
+ const { query } = req.query;
+
+ if (!query || query.length < 2) {
+ return res.json({ tags: [] });
+ }
+
+ const response = await axios.get('https://e621.net/tags.json', {
+ params: {
+ 'search[name_matches]': `${query}*`,
+ 'search[order]': 'count',
+ limit: 10
+ },
+ headers: {
+ 'User-Agent': 'NakamaSpace/1.0'
+ }
+ });
+
+ const tags = response.data.map(tag => ({
+ name: tag.name,
+ count: tag.post_count
+ }));
+
+ res.json({ tags });
+ } catch (error) {
+ console.error('Ошибка получения тегов:', error);
+ res.status(500).json({ error: 'Ошибка получения тегов' });
+ }
+});
+
+// Автокомплит тегов для Gelbooru
+router.get('/anime/tags', authenticate, async (req, res) => {
+ try {
+ const { query } = req.query;
+
+ if (!query || query.length < 2) {
+ return res.json({ tags: [] });
+ }
+
+ const response = await axios.get('https://gelbooru.com/index.php', {
+ params: {
+ page: 'dapi',
+ s: 'tag',
+ q: 'index',
+ json: 1,
+ name_pattern: `${query}%`,
+ orderby: 'count',
+ limit: 10
+ }
+ });
+
+ const tags = (response.data.tag || []).map(tag => ({
+ name: tag.name,
+ count: tag.count
+ }));
+
+ res.json({ tags });
+ } catch (error) {
+ console.error('Ошибка получения тегов:', error);
+ res.status(500).json({ error: 'Ошибка получения тегов' });
+ }
+});
+
+module.exports = router;
+
diff --git a/backend/routes/statistics.js b/backend/routes/statistics.js
new file mode 100644
index 0000000..357b7f4
--- /dev/null
+++ b/backend/routes/statistics.js
@@ -0,0 +1,52 @@
+const express = require('express');
+const router = express.Router();
+const { authenticate } = require('../middleware/auth');
+const { getUserStatistics, getUserTopPosts } = require('../utils/statistics');
+
+// Получить статистику своего профиля
+router.get('/me', authenticate, async (req, res) => {
+ try {
+ const stats = await getUserStatistics(req.user._id);
+
+ if (!stats) {
+ return res.status(404).json({ error: 'Статистика не найдена' });
+ }
+
+ res.json(stats);
+ } catch (error) {
+ console.error('Ошибка получения статистики:', error);
+ res.status(500).json({ error: 'Ошибка сервера' });
+ }
+});
+
+// Получить статистику другого пользователя
+router.get('/user/:id', authenticate, async (req, res) => {
+ try {
+ const stats = await getUserStatistics(req.params.id);
+
+ if (!stats) {
+ return res.status(404).json({ error: 'Статистика не найдена' });
+ }
+
+ res.json(stats);
+ } catch (error) {
+ console.error('Ошибка получения статистики:', error);
+ res.status(500).json({ error: 'Ошибка сервера' });
+ }
+});
+
+// Получить топ посты
+router.get('/top-posts/:userId', authenticate, async (req, res) => {
+ try {
+ const { limit = 5 } = req.query;
+ const topPosts = await getUserTopPosts(req.params.userId, parseInt(limit));
+
+ res.json({ topPosts });
+ } catch (error) {
+ console.error('Ошибка получения топ постов:', error);
+ res.status(500).json({ error: 'Ошибка сервера' });
+ }
+});
+
+module.exports = router;
+
diff --git a/backend/routes/users.js b/backend/routes/users.js
new file mode 100644
index 0000000..c34e19e
--- /dev/null
+++ b/backend/routes/users.js
@@ -0,0 +1,160 @@
+const express = require('express');
+const router = express.Router();
+const { authenticate } = require('../middleware/auth');
+const User = require('../models/User');
+const Post = require('../models/Post');
+const Notification = require('../models/Notification');
+
+// Получить профиль пользователя
+router.get('/:id', authenticate, async (req, res) => {
+ try {
+ const user = await User.findById(req.params.id)
+ .select('-__v')
+ .populate('followers', 'username firstName lastName photoUrl')
+ .populate('following', 'username firstName lastName photoUrl');
+
+ if (!user) {
+ return res.status(404).json({ error: 'Пользователь не найден' });
+ }
+
+ // Проверить подписку текущего пользователя
+ const isFollowing = user.followers.some(f => f._id.equals(req.user._id));
+
+ res.json({
+ user: {
+ id: user._id,
+ username: user.username,
+ firstName: user.firstName,
+ lastName: user.lastName,
+ photoUrl: user.photoUrl,
+ bio: user.bio,
+ followersCount: user.followers.length,
+ followingCount: user.following.length,
+ isFollowing,
+ createdAt: user.createdAt
+ }
+ });
+ } catch (error) {
+ console.error('Ошибка получения профиля:', error);
+ res.status(500).json({ error: 'Ошибка сервера' });
+ }
+});
+
+// Получить посты пользователя
+router.get('/:id/posts', authenticate, async (req, res) => {
+ try {
+ const { page = 1, limit = 20 } = req.query;
+
+ const posts = await Post.find({ author: req.params.id })
+ .populate('author', 'username firstName lastName photoUrl')
+ .sort({ createdAt: -1 })
+ .limit(limit * 1)
+ .skip((page - 1) * limit)
+ .exec();
+
+ const count = await Post.countDocuments({ author: req.params.id });
+
+ res.json({
+ posts,
+ totalPages: Math.ceil(count / limit),
+ currentPage: page
+ });
+ } catch (error) {
+ console.error('Ошибка получения постов:', error);
+ res.status(500).json({ error: 'Ошибка сервера' });
+ }
+});
+
+// Подписаться / отписаться
+router.post('/:id/follow', authenticate, async (req, res) => {
+ try {
+ if (req.params.id === req.user._id.toString()) {
+ return res.status(400).json({ error: 'Нельзя подписаться на себя' });
+ }
+
+ const targetUser = await User.findById(req.params.id);
+
+ if (!targetUser) {
+ return res.status(404).json({ error: 'Пользователь не найден' });
+ }
+
+ const isFollowing = targetUser.followers.includes(req.user._id);
+
+ if (isFollowing) {
+ // Отписаться
+ targetUser.followers = targetUser.followers.filter(id => !id.equals(req.user._id));
+ req.user.following = req.user.following.filter(id => !id.equals(targetUser._id));
+ } else {
+ // Подписаться
+ targetUser.followers.push(req.user._id);
+ req.user.following.push(targetUser._id);
+
+ // Создать уведомление
+ const notification = new Notification({
+ recipient: targetUser._id,
+ sender: req.user._id,
+ type: 'follow'
+ });
+ await notification.save();
+ }
+
+ await targetUser.save();
+ await req.user.save();
+
+ res.json({
+ following: !isFollowing,
+ followersCount: targetUser.followers.length
+ });
+ } catch (error) {
+ console.error('Ошибка подписки:', error);
+ res.status(500).json({ error: 'Ошибка сервера' });
+ }
+});
+
+// Обновить профиль
+router.put('/profile', authenticate, async (req, res) => {
+ try {
+ const { bio, settings } = req.body;
+
+ if (bio !== undefined) {
+ req.user.bio = bio;
+ }
+
+ if (settings) {
+ req.user.settings = { ...req.user.settings, ...settings };
+ }
+
+ await req.user.save();
+
+ res.json({
+ message: 'Профиль обновлен',
+ user: req.user
+ });
+ } catch (error) {
+ console.error('Ошибка обновления профиля:', error);
+ res.status(500).json({ error: 'Ошибка сервера' });
+ }
+});
+
+// Поиск пользователей
+router.get('/search/:query', authenticate, async (req, res) => {
+ try {
+ const users = await User.find({
+ $or: [
+ { username: { $regex: req.params.query, $options: 'i' } },
+ { firstName: { $regex: req.params.query, $options: 'i' } },
+ { lastName: { $regex: req.params.query, $options: 'i' } }
+ ]
+ })
+ .select('username firstName lastName photoUrl')
+ .limit(10);
+
+ res.json({ users });
+ } catch (error) {
+ console.error('Ошибка поиска пользователей:', error);
+ res.status(500).json({ error: 'Ошибка сервера' });
+ }
+});
+
+module.exports = router;
+
diff --git a/backend/server.js b/backend/server.js
new file mode 100644
index 0000000..0093d82
--- /dev/null
+++ b/backend/server.js
@@ -0,0 +1,102 @@
+const express = require('express');
+const mongoose = require('mongoose');
+const cors = require('cors');
+const dotenv = require('dotenv');
+const path = require('path');
+const http = require('http');
+const { generalLimiter } = require('./middleware/rateLimiter');
+const { initRedis } = require('./utils/redis');
+const { initWebSocket } = require('./websocket');
+const config = require('./config');
+
+dotenv.config();
+
+const app = express();
+const server = http.createServer(app);
+
+// CORS настройки
+const corsOptions = {
+ origin: config.corsOrigin === '*' ? '*' : config.corsOrigin.split(','),
+ credentials: true,
+ optionsSuccessStatus: 200
+};
+
+// Middleware
+app.use(cors(corsOptions));
+app.use(express.json());
+app.use(express.urlencoded({ extended: true }));
+app.use('/uploads', express.static(path.join(__dirname, config.uploadsDir)));
+
+// Доверять proxy для правильного IP (для rate limiting за nginx/cloudflare)
+if (config.isProduction()) {
+ app.set('trust proxy', 1);
+}
+
+// Rate limiting
+app.use('/api', generalLimiter);
+
+// Health check endpoint
+app.get('/health', (req, res) => {
+ res.json({
+ status: 'ok',
+ environment: config.nodeEnv,
+ timestamp: new Date().toISOString()
+ });
+});
+
+// MongoDB подключение
+mongoose.connect(config.mongoUri, {
+ useNewUrlParser: true,
+ useUnifiedTopology: true
+})
+.then(() => {
+ console.log(`✅ MongoDB подключена: ${config.mongoUri.replace(/\/\/.*@/, '//***@')}`);
+ // Инициализировать Redis (опционально)
+ if (config.redisUrl) {
+ initRedis().catch(err => console.log('⚠️ Redis недоступен, работаем без кэша'));
+ } else {
+ console.log('ℹ️ Redis не настроен, кэширование отключено');
+ }
+})
+.catch(err => console.error('❌ Ошибка MongoDB:', err));
+
+// Routes
+app.use('/api/auth', require('./routes/auth'));
+app.use('/api/posts', require('./routes/posts'));
+app.use('/api/users', require('./routes/users'));
+app.use('/api/notifications', require('./routes/notifications'));
+app.use('/api/search', require('./routes/search'));
+app.use('/api/search/posts', require('./routes/postSearch'));
+app.use('/api/moderation', require('./routes/moderation'));
+app.use('/api/statistics', require('./routes/statistics'));
+
+// Базовый роут
+app.get('/', (req, res) => {
+ res.json({ message: 'NakamaSpace API работает' });
+});
+
+// Инициализировать WebSocket
+initWebSocket(server);
+
+// Graceful shutdown
+process.on('SIGTERM', () => {
+ console.log('SIGTERM получен, закрываем сервер...');
+ server.close(() => {
+ console.log('Сервер закрыт');
+ mongoose.connection.close(false, () => {
+ console.log('MongoDB соединение закрыто');
+ process.exit(0);
+ });
+ });
+});
+
+server.listen(config.port, '0.0.0.0', () => {
+ console.log(`🚀 Сервер запущен`);
+ console.log(` Порт: ${config.port}`);
+ console.log(` Окружение: ${config.nodeEnv}`);
+ console.log(` API: http://0.0.0.0:${config.port}/api`);
+ if (config.isDevelopment()) {
+ console.log(` Frontend: ${config.frontendUrl}`);
+ }
+});
+
diff --git a/backend/utils/hashtags.js b/backend/utils/hashtags.js
new file mode 100644
index 0000000..b5fd07e
--- /dev/null
+++ b/backend/utils/hashtags.js
@@ -0,0 +1,34 @@
+// Утилиты для работы с хэштегами
+
+// Извлечь хэштеги из текста
+function extractHashtags(text) {
+ if (!text) return [];
+
+ const hashtagRegex = /#[\wа-яА-ЯёЁ]+/g;
+ const matches = text.match(hashtagRegex);
+
+ if (!matches) return [];
+
+ // Убрать # и привести к lowercase
+ return [...new Set(matches.map(tag => tag.substring(1).toLowerCase()))];
+}
+
+// Добавить ссылки к хэштегам в тексте
+function linkifyHashtags(text) {
+ if (!text) return text;
+
+ return text.replace(/#([\wа-яА-ЯёЁ]+)/g, '#$1');
+}
+
+// Валидация хэштега
+function isValidHashtag(tag) {
+ if (!tag || tag.length < 2 || tag.length > 50) return false;
+ return /^[\wа-яА-ЯёЁ]+$/.test(tag);
+}
+
+module.exports = {
+ extractHashtags,
+ linkifyHashtags,
+ isValidHashtag
+};
+
diff --git a/backend/utils/redis.js b/backend/utils/redis.js
new file mode 100644
index 0000000..bdd0a0f
--- /dev/null
+++ b/backend/utils/redis.js
@@ -0,0 +1,97 @@
+const redis = require('redis');
+
+let client = null;
+let isConnected = false;
+
+// Инициализация Redis (опционально)
+async function initRedis() {
+ try {
+ if (process.env.REDIS_URL) {
+ client = redis.createClient({
+ url: process.env.REDIS_URL
+ });
+
+ client.on('error', (err) => {
+ console.log('Redis Client Error', err);
+ isConnected = false;
+ });
+
+ client.on('connect', () => {
+ console.log('✅ Redis подключен');
+ isConnected = true;
+ });
+
+ await client.connect();
+ } else {
+ console.log('⚠️ Redis URL не найден, кэширование отключено');
+ }
+ } catch (error) {
+ console.error('⚠️ Ошибка подключения Redis:', error);
+ isConnected = false;
+ }
+}
+
+// Получить значение
+async function get(key) {
+ if (!isConnected || !client) return null;
+
+ try {
+ const value = await client.get(key);
+ return value ? JSON.parse(value) : null;
+ } catch (error) {
+ console.error('Redis get error:', error);
+ return null;
+ }
+}
+
+// Установить значение с TTL
+async function set(key, value, ttl = 3600) {
+ if (!isConnected || !client) return false;
+
+ try {
+ await client.setEx(key, ttl, JSON.stringify(value));
+ return true;
+ } catch (error) {
+ console.error('Redis set error:', error);
+ return false;
+ }
+}
+
+// Удалить значение
+async function del(key) {
+ if (!isConnected || !client) return false;
+
+ try {
+ await client.del(key);
+ return true;
+ } catch (error) {
+ console.error('Redis del error:', error);
+ return false;
+ }
+}
+
+// Очистить паттерн ключей
+async function clearPattern(pattern) {
+ if (!isConnected || !client) return false;
+
+ try {
+ const keys = await client.keys(pattern);
+ if (keys.length > 0) {
+ await client.del(keys);
+ }
+ return true;
+ } catch (error) {
+ console.error('Redis clearPattern error:', error);
+ return false;
+ }
+}
+
+module.exports = {
+ initRedis,
+ get,
+ set,
+ del,
+ clearPattern,
+ isConnected: () => isConnected
+};
+
diff --git a/backend/utils/statistics.js b/backend/utils/statistics.js
new file mode 100644
index 0000000..ae7049b
--- /dev/null
+++ b/backend/utils/statistics.js
@@ -0,0 +1,89 @@
+const Post = require('../models/Post');
+const User = require('../models/User');
+
+// Увеличить счётчик просмотров поста
+async function incrementPostViews(postId) {
+ try {
+ await Post.findByIdAndUpdate(postId, { $inc: { views: 1 } });
+ } catch (error) {
+ console.error('Ошибка увеличения просмотров:', error);
+ }
+}
+
+// Получить статистику пользователя
+async function getUserStatistics(userId) {
+ try {
+ const user = await User.findById(userId);
+
+ if (!user) {
+ return null;
+ }
+
+ // Количество постов
+ const postsCount = await Post.countDocuments({ author: userId });
+
+ // Все посты пользователя
+ const userPosts = await Post.find({ author: userId });
+
+ // Общее количество лайков
+ const totalLikes = userPosts.reduce((sum, post) => sum + post.likes.length, 0);
+
+ // Общее количество комментариев
+ const totalComments = userPosts.reduce((sum, post) => sum + post.comments.length, 0);
+
+ // Общее количество репостов
+ const totalReposts = userPosts.reduce((sum, post) => sum + post.reposts.length, 0);
+
+ // Общее количество просмотров
+ const totalViews = userPosts.reduce((sum, post) => sum + (post.views || 0), 0);
+
+ // Средняя вовлечённость (engagement rate)
+ const totalEngagement = totalLikes + totalComments + totalReposts;
+ const engagementRate = totalViews > 0 ? (totalEngagement / totalViews * 100).toFixed(2) : 0;
+
+ return {
+ postsCount,
+ followersCount: user.followers.length,
+ followingCount: user.following.length,
+ totalLikes,
+ totalComments,
+ totalReposts,
+ totalViews,
+ totalEngagement,
+ engagementRate: parseFloat(engagementRate)
+ };
+ } catch (error) {
+ console.error('Ошибка получения статистики:', error);
+ return null;
+ }
+}
+
+// Получить топ посты пользователя
+async function getUserTopPosts(userId, limit = 5) {
+ try {
+ const posts = await Post.find({ author: userId })
+ .sort({ views: -1 })
+ .limit(limit)
+ .populate('author', 'username firstName lastName photoUrl');
+
+ return posts.map(post => ({
+ id: post._id,
+ content: post.content ? post.content.substring(0, 100) : '',
+ likes: post.likes.length,
+ comments: post.comments.length,
+ reposts: post.reposts.length,
+ views: post.views || 0,
+ createdAt: post.createdAt
+ }));
+ } catch (error) {
+ console.error('Ошибка получения топ постов:', error);
+ return [];
+ }
+}
+
+module.exports = {
+ incrementPostViews,
+ getUserStatistics,
+ getUserTopPosts
+};
+
diff --git a/backend/websocket.js b/backend/websocket.js
new file mode 100644
index 0000000..17e272e
--- /dev/null
+++ b/backend/websocket.js
@@ -0,0 +1,76 @@
+const { Server } = require('socket.io');
+const config = require('./config');
+const Notification = require('./models/Notification');
+
+let io = null;
+
+// Инициализация WebSocket сервера
+function initWebSocket(server) {
+ const corsOrigin = config.corsOrigin === '*' ? '*' : config.corsOrigin.split(',');
+
+ io = new Server(server, {
+ cors: {
+ origin: corsOrigin,
+ methods: ['GET', 'POST'],
+ credentials: true
+ },
+ transports: ['websocket', 'polling'], // Поддержка обоих транспортов
+ pingTimeout: 60000,
+ pingInterval: 25000
+ });
+
+ io.on('connection', (socket) => {
+ console.log(`✅ WebSocket подключен: ${socket.id}`);
+
+ // Присоединиться к комнате пользователя
+ socket.on('join', (userId) => {
+ socket.join(`user_${userId}`);
+ console.log(`Пользователь ${userId} присоединился к комнате`);
+ });
+
+ // Отключение
+ socket.on('disconnect', () => {
+ console.log(`❌ WebSocket отключен: ${socket.id}`);
+ });
+ });
+
+ console.log('✅ WebSocket сервер инициализирован');
+ return io;
+}
+
+// Отправить уведомление пользователю в real-time
+function sendNotification(userId, notification) {
+ if (io) {
+ io.to(`user_${userId}`).emit('notification', notification);
+ }
+}
+
+// Отправить обновление поста
+function sendPostUpdate(postId, data) {
+ if (io) {
+ io.emit('post_update', { postId, ...data });
+ }
+}
+
+// Отправить новый комментарий
+function sendNewComment(postId, comment) {
+ if (io) {
+ io.emit('new_comment', { postId, comment });
+ }
+}
+
+// Отправить информацию о том, кто онлайн
+function broadcastOnlineUsers(users) {
+ if (io) {
+ io.emit('online_users', users);
+ }
+}
+
+module.exports = {
+ initWebSocket,
+ sendNotification,
+ sendPostUpdate,
+ sendNewComment,
+ broadcastOnlineUsers
+};
+
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000..410909b
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,27 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+.env
+
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..848bb88
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+ NakamaSpace
+
+
+
+
+
+
+
+
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..cb5a7cd
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "nakama-frontend",
+ "private": true,
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^6.20.0",
+ "@twa-dev/sdk": "^7.0.0",
+ "axios": "^1.6.0",
+ "lucide-react": "^0.292.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.2.37",
+ "@types/react-dom": "^18.2.15",
+ "@vitejs/plugin-react": "^4.2.0",
+ "vite": "^5.0.0"
+ }
+}
+
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
new file mode 100644
index 0000000..5b99957
--- /dev/null
+++ b/frontend/src/App.jsx
@@ -0,0 +1,102 @@
+import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
+import { useState, useEffect } from 'react'
+import { initTelegramApp, getTelegramUser } from './utils/telegram'
+import { verifyAuth } from './utils/api'
+import { initTheme } from './utils/theme'
+import Layout from './components/Layout'
+import Feed from './pages/Feed'
+import Search from './pages/Search'
+import Notifications from './pages/Notifications'
+import Profile from './pages/Profile'
+import UserProfile from './pages/UserProfile'
+import './styles/index.css'
+
+function App() {
+ const [user, setUser] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ // Инициализировать тему
+ initTheme()
+ initApp()
+ }, [])
+
+ const initApp = async () => {
+ try {
+ // Инициализация Telegram Web App
+ initTelegramApp()
+
+ // Получить данные пользователя из Telegram
+ const telegramUser = getTelegramUser()
+
+ if (!telegramUser) {
+ throw new Error('Telegram User не найден')
+ }
+
+ // Верифицировать через API
+ const userData = await verifyAuth()
+ setUser(userData)
+ } catch (err) {
+ console.error('Ошибка инициализации:', err)
+ setError(err.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
+ Ошибка загрузки приложения
+
+
+ {error}
+
+
+ )
+ }
+
+ return (
+
+
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+ )
+}
+
+export default App
+
diff --git a/frontend/src/components/CommentsModal.css b/frontend/src/components/CommentsModal.css
new file mode 100644
index 0000000..ca0104c
--- /dev/null
+++ b/frontend/src/components/CommentsModal.css
@@ -0,0 +1,117 @@
+.comments-modal {
+ max-height: 85vh;
+ display: flex;
+ flex-direction: column;
+ margin-bottom: 80px; /* Отступ для нижнего меню */
+}
+
+.comments-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.empty-comments {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 40px 20px;
+ gap: 8px;
+}
+
+.empty-comments p {
+ color: var(--text-primary);
+ font-size: 16px;
+ font-weight: 500;
+}
+
+.empty-comments span {
+ color: var(--text-secondary);
+ font-size: 14px;
+}
+
+.comment-item {
+ display: flex;
+ gap: 12px;
+}
+
+.comment-avatar {
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ object-fit: cover;
+ flex-shrink: 0;
+}
+
+.comment-content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.comment-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.comment-author {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.comment-time {
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.comment-text {
+ font-size: 14px;
+ line-height: 1.5;
+ color: var(--text-primary);
+ word-wrap: break-word;
+}
+
+.comment-form {
+ display: flex;
+ gap: 8px;
+ padding: 12px 16px;
+ padding-bottom: calc(12px + env(safe-area-inset-bottom));
+ border-top: 1px solid var(--divider-color);
+ background: var(--bg-secondary);
+ position: sticky;
+ bottom: 0;
+ z-index: 10;
+}
+
+.comment-form input {
+ flex: 1;
+ padding: 10px 16px;
+ border-radius: 20px;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ font-size: 15px;
+}
+
+.send-btn {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: var(--button-accent);
+ color: white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.send-btn:disabled {
+ opacity: 0.5;
+}
+
diff --git a/frontend/src/components/CommentsModal.jsx b/frontend/src/components/CommentsModal.jsx
new file mode 100644
index 0000000..ea4500a
--- /dev/null
+++ b/frontend/src/components/CommentsModal.jsx
@@ -0,0 +1,107 @@
+import { useState } from 'react'
+import { X, Send } from 'lucide-react'
+import { commentPost } from '../utils/api'
+import { hapticFeedback } from '../utils/telegram'
+import './CommentsModal.css'
+
+export default function CommentsModal({ post, onClose, onUpdate }) {
+ const [comment, setComment] = useState('')
+ const [loading, setLoading] = useState(false)
+ const [comments, setComments] = useState(post.comments || [])
+
+ const handleSubmit = async () => {
+ if (!comment.trim()) return
+
+ try {
+ setLoading(true)
+ hapticFeedback('light')
+
+ const result = await commentPost(post._id, comment)
+ setComments(result.comments)
+ setComment('')
+ hapticFeedback('success')
+ onUpdate()
+ } catch (error) {
+ console.error('Ошибка добавления комментария:', error)
+ hapticFeedback('error')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const formatDate = (date) => {
+ const d = new Date(date)
+ const now = new Date()
+ const diff = Math.floor((now - d) / 1000) // секунды
+
+ if (diff < 60) return 'только что'
+ if (diff < 3600) return `${Math.floor(diff / 60)} мин`
+ if (diff < 86400) return `${Math.floor(diff / 3600)} ч`
+
+ return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
+ }
+
+ return (
+
+
e.stopPropagation()}>
+ {/* Хедер */}
+
+
+
Комментарии ({comments.length})
+
+
+
+ {/* Список комментариев */}
+
+ {comments.length === 0 ? (
+
+
Пока нет комментариев
+
Будьте первым!
+
+ ) : (
+ comments.map((c, index) => (
+
+

+
+
+
+ {c.author.firstName} {c.author.lastName}
+
+ {formatDate(c.createdAt)}
+
+
{c.content}
+
+
+ ))
+ )}
+
+
+ {/* Форма добавления комментария */}
+
+ setComment(e.target.value)}
+ onKeyPress={e => e.key === 'Enter' && handleSubmit()}
+ maxLength={500}
+ />
+
+
+
+
+ )
+}
+
diff --git a/frontend/src/components/CreatePostModal.css b/frontend/src/components/CreatePostModal.css
new file mode 100644
index 0000000..191da56
--- /dev/null
+++ b/frontend/src/components/CreatePostModal.css
@@ -0,0 +1,268 @@
+.modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: flex-end;
+ z-index: 1000;
+ animation: fadeIn 0.2s;
+}
+
+.modal-content {
+ background: var(--bg-secondary);
+ border-radius: 16px 16px 0 0;
+ max-height: calc(100vh - 80px); /* Учёт нижнего меню */
+ overflow-y: auto;
+ width: 100%;
+ animation: slideUp 0.3s ease-out;
+ margin-bottom: 80px;
+}
+
+.create-post-modal {
+ display: flex;
+ flex-direction: column;
+}
+
+.modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16px;
+ border-bottom: 1px solid var(--divider-color);
+ position: sticky;
+ top: 0;
+ background: var(--bg-secondary);
+ z-index: 10;
+}
+
+.modal-header h2 {
+ font-size: 18px;
+ font-weight: 600;
+}
+
+.close-btn {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ background: transparent;
+ color: var(--text-secondary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.submit-btn {
+ padding: 8px 16px;
+ border-radius: 20px;
+ background: var(--button-dark);
+ color: white;
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.submit-btn:disabled {
+ opacity: 0.5;
+}
+
+.modal-body {
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.modal-body textarea {
+ width: 100%;
+ padding: 12px;
+ border: 1px solid var(--border-color);
+ border-radius: 12px;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ font-size: 15px;
+ line-height: 1.5;
+ resize: vertical;
+}
+
+.image-preview {
+ position: relative;
+ border-radius: 12px;
+ overflow: hidden;
+}
+
+.image-preview img {
+ width: 100%;
+ max-height: 300px;
+ object-fit: cover;
+}
+
+.remove-image-btn {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ background: rgba(0, 0, 0, 0.6);
+ color: white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.tags-section {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.section-label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--text-secondary);
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.tags-list {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.tag-btn {
+ padding: 8px 16px;
+ border-radius: 20px;
+ font-size: 14px;
+ font-weight: 600;
+ transition: all 0.2s;
+}
+
+.mentioned-users {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.mentioned-user {
+ padding: 6px 12px;
+ border-radius: 16px;
+ background: var(--button-accent);
+ color: white;
+ font-size: 13px;
+ font-weight: 500;
+}
+
+.nsfw-toggle label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+ user-select: none;
+}
+
+.nsfw-toggle input {
+ width: 20px;
+ height: 20px;
+}
+
+.nsfw-toggle span {
+ font-size: 15px;
+ color: var(--text-primary);
+}
+
+.modal-footer {
+ display: flex;
+ gap: 16px;
+ padding: 16px;
+ border-top: 1px solid var(--divider-color);
+}
+
+.action-icon-btn {
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ background: var(--bg-primary);
+ color: var(--button-accent);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.user-search-modal {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: var(--bg-secondary);
+ z-index: 100;
+ display: flex;
+ flex-direction: column;
+}
+
+.user-search-header {
+ display: flex;
+ gap: 8px;
+ padding: 16px;
+ border-bottom: 1px solid var(--divider-color);
+}
+
+.user-search-header input {
+ flex: 1;
+ padding: 10px 16px;
+ border-radius: 20px;
+ background: var(--search-bg);
+ color: var(--text-primary);
+ font-size: 15px;
+}
+
+.user-search-header button {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: transparent;
+ color: var(--text-secondary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.user-search-results {
+ flex: 1;
+ overflow-y: auto;
+}
+
+.user-result {
+ display: flex;
+ gap: 12px;
+ padding: 12px 16px;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.user-result:active {
+ background: var(--bg-primary);
+}
+
+.user-result img {
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ object-fit: cover;
+}
+
+.user-name {
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.user-username {
+ font-size: 13px;
+ color: var(--text-secondary);
+}
+
diff --git a/frontend/src/components/CreatePostModal.jsx b/frontend/src/components/CreatePostModal.jsx
new file mode 100644
index 0000000..a45ceb2
--- /dev/null
+++ b/frontend/src/components/CreatePostModal.jsx
@@ -0,0 +1,257 @@
+import { useState, useRef } from 'react'
+import { X, Image as ImageIcon, Tag, AtSign } from 'lucide-react'
+import { createPost, searchUsers } from '../utils/api'
+import { hapticFeedback } from '../utils/telegram'
+import './CreatePostModal.css'
+
+const TAGS = [
+ { value: 'furry', label: 'Furry', color: '#FF8A33' },
+ { value: 'anime', label: 'Anime', color: '#4A90E2' },
+ { value: 'other', label: 'Other', color: '#A0A0A0' }
+]
+
+export default function CreatePostModal({ user, onClose, onPostCreated }) {
+ const [content, setContent] = useState('')
+ const [selectedTags, setSelectedTags] = useState([])
+ const [image, setImage] = useState(null)
+ const [imagePreview, setImagePreview] = useState(null)
+ const [isNSFW, setIsNSFW] = useState(false)
+ const [loading, setLoading] = useState(false)
+ const [showUserSearch, setShowUserSearch] = useState(false)
+ const [userSearchQuery, setUserSearchQuery] = useState('')
+ const [searchResults, setSearchResults] = useState([])
+ const [mentionedUsers, setMentionedUsers] = useState([])
+ const fileInputRef = useRef(null)
+
+ const handleImageSelect = (e) => {
+ const file = e.target.files[0]
+ if (file) {
+ setImage(file)
+ const reader = new FileReader()
+ reader.onloadend = () => {
+ setImagePreview(reader.result)
+ }
+ reader.readAsDataURL(file)
+ hapticFeedback('light')
+ }
+ }
+
+ const handleRemoveImage = () => {
+ setImage(null)
+ setImagePreview(null)
+ if (fileInputRef.current) {
+ fileInputRef.current.value = ''
+ }
+ }
+
+ const toggleTag = (tag) => {
+ hapticFeedback('light')
+ if (selectedTags.includes(tag)) {
+ setSelectedTags(selectedTags.filter(t => t !== tag))
+ } else {
+ setSelectedTags([...selectedTags, tag])
+ }
+ }
+
+ const handleUserSearch = async (query) => {
+ setUserSearchQuery(query)
+ if (query.length > 1) {
+ try {
+ const users = await searchUsers(query)
+ setSearchResults(users)
+ } catch (error) {
+ console.error('Ошибка поиска:', error)
+ }
+ } else {
+ setSearchResults([])
+ }
+ }
+
+ const handleMentionUser = (user) => {
+ if (!mentionedUsers.find(u => u._id === user._id)) {
+ setMentionedUsers([...mentionedUsers, user])
+ setContent(prev => prev + `@${user.username} `)
+ }
+ setShowUserSearch(false)
+ setUserSearchQuery('')
+ setSearchResults([])
+ hapticFeedback('light')
+ }
+
+ const handleSubmit = async () => {
+ if (selectedTags.length === 0) {
+ alert('Выберите хотя бы один тег')
+ return
+ }
+
+ if (!content.trim() && !image) {
+ alert('Добавьте текст или изображение')
+ return
+ }
+
+ try {
+ setLoading(true)
+ hapticFeedback('light')
+
+ const formData = new FormData()
+ formData.append('content', content)
+ formData.append('tags', JSON.stringify(selectedTags))
+ formData.append('isNSFW', isNSFW)
+
+ if (image) {
+ formData.append('image', image)
+ }
+
+ if (mentionedUsers.length > 0) {
+ formData.append('mentionedUsers', JSON.stringify(mentionedUsers.map(u => u._id)))
+ }
+
+ const newPost = await createPost(formData)
+ hapticFeedback('success')
+ onPostCreated(newPost)
+ } catch (error) {
+ console.error('Ошибка создания поста:', error)
+ hapticFeedback('error')
+ alert('Ошибка создания поста')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
e.stopPropagation()}>
+ {/* Хедер */}
+
+
+
Создать пост
+
+
+
+ {/* Контент */}
+
+
+ {/* Футер с действиями */}
+
+
+
+
+
+
+
+
+ {/* Поиск пользователей */}
+ {showUserSearch && (
+
+
+ handleUserSearch(e.target.value)}
+ autoFocus
+ />
+
+
+
+ {searchResults.map(u => (
+
handleMentionUser(u)}>
+

+
+
{u.firstName} {u.lastName}
+
@{u.username}
+
+
+ ))}
+
+
+ )}
+
+
+ )
+}
+
diff --git a/frontend/src/components/Layout.css b/frontend/src/components/Layout.css
new file mode 100644
index 0000000..7efab59
--- /dev/null
+++ b/frontend/src/components/Layout.css
@@ -0,0 +1,15 @@
+.layout {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ background-color: var(--bg-primary);
+}
+
+.content {
+ flex: 1;
+ padding-bottom: 80px;
+ max-width: 600px;
+ width: 100%;
+ margin: 0 auto;
+}
+
diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx
new file mode 100644
index 0000000..efb18d0
--- /dev/null
+++ b/frontend/src/components/Layout.jsx
@@ -0,0 +1,15 @@
+import { Outlet } from 'react-router-dom'
+import Navigation from './Navigation'
+import './Layout.css'
+
+export default function Layout({ user }) {
+ return (
+
+
+
+
+
+
+ )
+}
+
diff --git a/frontend/src/components/Navigation.css b/frontend/src/components/Navigation.css
new file mode 100644
index 0000000..564e400
--- /dev/null
+++ b/frontend/src/components/Navigation.css
@@ -0,0 +1,42 @@
+.navigation {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: var(--bg-secondary);
+ border-top: 1px solid var(--divider-color);
+ display: flex;
+ justify-content: space-around;
+ align-items: center;
+ padding: 8px 0 calc(8px + env(safe-area-inset-bottom));
+ z-index: 100;
+ box-shadow: 0 -2px 8px var(--shadow-sm);
+}
+
+.nav-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+ padding: 8px 16px;
+ color: var(--text-secondary);
+ text-decoration: none;
+ font-size: 12px;
+ font-weight: 500;
+ transition: color 0.2s;
+ user-select: none;
+}
+
+.nav-item:active {
+ transform: scale(0.95);
+}
+
+.nav-item.active {
+ color: var(--button-accent);
+}
+
+.nav-item span {
+ font-size: 11px;
+ margin-top: 2px;
+}
+
diff --git a/frontend/src/components/Navigation.jsx b/frontend/src/components/Navigation.jsx
new file mode 100644
index 0000000..d36a82c
--- /dev/null
+++ b/frontend/src/components/Navigation.jsx
@@ -0,0 +1,46 @@
+import { NavLink } from 'react-router-dom'
+import { Home, Search, Bell, User } from 'lucide-react'
+import './Navigation.css'
+
+export default function Navigation() {
+ return (
+
+ )
+}
+
diff --git a/frontend/src/components/PostCard.css b/frontend/src/components/PostCard.css
new file mode 100644
index 0000000..8f3eba7
--- /dev/null
+++ b/frontend/src/components/PostCard.css
@@ -0,0 +1,136 @@
+.post-card {
+ animation: fadeIn 0.3s ease-out;
+}
+
+.post-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 12px;
+}
+
+.post-author {
+ display: flex;
+ gap: 12px;
+ cursor: pointer;
+ user-select: none;
+}
+
+.author-avatar {
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ object-fit: cover;
+}
+
+.author-info {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.author-name {
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.post-date {
+ font-size: 13px;
+ color: var(--text-secondary);
+}
+
+.menu-btn {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ background: transparent;
+ color: var(--text-secondary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.menu-btn:active {
+ background: var(--bg-primary);
+}
+
+.post-content {
+ font-size: 15px;
+ line-height: 1.5;
+ color: var(--text-primary);
+ margin-bottom: 12px;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+}
+
+.post-image {
+ margin: 0 -16px 12px;
+ width: calc(100% + 32px);
+ max-height: 400px;
+ overflow: hidden;
+}
+
+.post-image img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
+.post-tags {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ margin-bottom: 12px;
+}
+
+.post-tag {
+ padding: 4px 12px;
+ border-radius: 12px;
+ font-size: 12px;
+ font-weight: 600;
+ color: white;
+}
+
+.nsfw-badge {
+ padding: 4px 12px;
+ border-radius: 12px;
+ font-size: 12px;
+ font-weight: 600;
+ background: #FF3B30;
+ color: white;
+}
+
+.post-actions {
+ display: flex;
+ gap: 16px;
+ padding-top: 12px;
+ border-top: 1px solid var(--divider-color);
+}
+
+.action-btn {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 12px;
+ border-radius: 20px;
+ background: transparent;
+ color: var(--text-secondary);
+ font-size: 14px;
+ font-weight: 500;
+ transition: all 0.2s;
+}
+
+.action-btn:active {
+ background: var(--bg-primary);
+}
+
+.action-btn.active {
+ color: var(--text-primary);
+}
+
+.action-btn span {
+ min-width: 20px;
+}
+
diff --git a/frontend/src/components/PostCard.jsx b/frontend/src/components/PostCard.jsx
new file mode 100644
index 0000000..ef6f985
--- /dev/null
+++ b/frontend/src/components/PostCard.jsx
@@ -0,0 +1,181 @@
+import { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { Heart, MessageCircle, Share2, MoreVertical } from 'lucide-react'
+import { likePost, commentPost, repostPost, deletePost } from '../utils/api'
+import { hapticFeedback, showConfirm } from '../utils/telegram'
+import PostMenu from './PostMenu'
+import CommentsModal from './CommentsModal'
+import './PostCard.css'
+
+const TAG_COLORS = {
+ furry: '#FF8A33',
+ anime: '#4A90E2',
+ other: '#A0A0A0'
+}
+
+const TAG_NAMES = {
+ furry: 'Furry',
+ anime: 'Anime',
+ other: 'Other'
+}
+
+export default function PostCard({ post, currentUser, onUpdate }) {
+ const navigate = useNavigate()
+ const [liked, setLiked] = useState(post.likes.includes(currentUser.id))
+ const [likesCount, setLikesCount] = useState(post.likes.length)
+ const [reposted, setReposted] = useState(post.reposts.includes(currentUser.id))
+ const [repostsCount, setRepostsCount] = useState(post.reposts.length)
+ const [showMenu, setShowMenu] = useState(false)
+ const [showComments, setShowComments] = useState(false)
+
+ const handleLike = async () => {
+ try {
+ hapticFeedback('light')
+ const result = await likePost(post._id)
+ setLiked(result.liked)
+ setLikesCount(result.likes)
+ if (result.liked) {
+ hapticFeedback('success')
+ }
+ } catch (error) {
+ console.error('Ошибка лайка:', error)
+ }
+ }
+
+ const handleRepost = async () => {
+ try {
+ hapticFeedback('light')
+ const result = await repostPost(post._id)
+ setReposted(result.reposted)
+ setRepostsCount(result.reposts)
+ if (result.reposted) {
+ hapticFeedback('success')
+ }
+ } catch (error) {
+ console.error('Ошибка репоста:', error)
+ }
+ }
+
+ const handleDelete = async () => {
+ const confirmed = await showConfirm('Удалить этот пост?')
+ if (confirmed) {
+ try {
+ await deletePost(post._id)
+ hapticFeedback('success')
+ onUpdate()
+ } catch (error) {
+ console.error('Ошибка удаления:', error)
+ }
+ }
+ }
+
+ const formatDate = (date) => {
+ const d = new Date(date)
+ return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
+ }
+
+ const goToProfile = () => {
+ navigate(`/user/${post.author._id}`)
+ }
+
+ return (
+
+ {/* Хедер поста */}
+
+
+

+
+
+ {post.author.firstName} {post.author.lastName}
+
+
+ @{post.author.username} · {formatDate(post.createdAt)}
+
+
+
+
+
+
+
+ {/* Контент */}
+ {post.content && (
+
+ {post.content}
+
+ )}
+
+ {/* Изображение */}
+ {post.imageUrl && (
+
+

+
+ )}
+
+ {/* Теги */}
+
+ {post.tags.map((tag, index) => (
+
+ {TAG_NAMES[tag]}
+
+ ))}
+ {post.isNSFW && (
+ NSFW
+ )}
+
+
+ {/* Действия */}
+
+
+
+
+
+
+
+
+ {/* Меню поста */}
+ {showMenu && (
+
setShowMenu(false)}
+ onDelete={handleDelete}
+ />
+ )}
+
+ {/* Комментарии */}
+ {showComments && (
+ setShowComments(false)}
+ onUpdate={onUpdate}
+ />
+ )}
+
+ )
+}
+
diff --git a/frontend/src/components/PostMenu.css b/frontend/src/components/PostMenu.css
new file mode 100644
index 0000000..296d080
--- /dev/null
+++ b/frontend/src/components/PostMenu.css
@@ -0,0 +1,41 @@
+.menu-modal {
+ background: var(--bg-secondary);
+ border-radius: 16px 16px 0 0;
+ padding: 8px;
+ animation: slideUp 0.3s ease-out;
+}
+
+.menu-item {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 16px;
+ background: transparent;
+ color: var(--text-primary);
+ font-size: 16px;
+ font-weight: 500;
+ border-radius: 12px;
+ transition: background 0.2s;
+}
+
+.menu-item:active {
+ background: var(--bg-primary);
+}
+
+.menu-item.danger {
+ color: #FF3B30;
+}
+
+.report-modal textarea {
+ width: 100%;
+ padding: 12px;
+ border: 1px solid var(--border-color);
+ border-radius: 12px;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ font-size: 15px;
+ line-height: 1.5;
+ resize: vertical;
+}
+
diff --git a/frontend/src/components/PostMenu.jsx b/frontend/src/components/PostMenu.jsx
new file mode 100644
index 0000000..064a3ce
--- /dev/null
+++ b/frontend/src/components/PostMenu.jsx
@@ -0,0 +1,93 @@
+import { X, Trash2, AlertCircle, Flag } from 'lucide-react'
+import { useState } from 'react'
+import { reportPost } from '../utils/api'
+import { hapticFeedback, showConfirm } from '../utils/telegram'
+import './PostMenu.css'
+
+export default function PostMenu({ post, currentUser, onClose, onDelete }) {
+ const [showReportModal, setShowReportModal] = useState(false)
+ const [reportReason, setReportReason] = useState('')
+ const [submitting, setSubmitting] = useState(false)
+
+ const isOwnPost = post.author._id === currentUser.id
+ const isModerator = currentUser.role === 'moderator' || currentUser.role === 'admin'
+
+ const handleReport = async () => {
+ if (!reportReason.trim()) {
+ alert('Укажите причину жалобы')
+ return
+ }
+
+ try {
+ setSubmitting(true)
+ hapticFeedback('light')
+ await reportPost(post._id, reportReason)
+ hapticFeedback('success')
+ alert('Жалоба отправлена')
+ onClose()
+ } catch (error) {
+ console.error('Ошибка отправки жалобы:', error)
+ hapticFeedback('error')
+ alert('Ошибка отправки жалобы')
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ if (showReportModal) {
+ return (
+
+
e.stopPropagation()}>
+
+
+
Пожаловаться
+
+
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
e.stopPropagation()}>
+ {isOwnPost || isModerator ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ )
+}
+
diff --git a/frontend/src/components/ThemeToggle.css b/frontend/src/components/ThemeToggle.css
new file mode 100644
index 0000000..cd825a6
--- /dev/null
+++ b/frontend/src/components/ThemeToggle.css
@@ -0,0 +1,26 @@
+.theme-toggle {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 16px;
+ border-radius: 12px;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ font-size: 15px;
+ font-weight: 500;
+ transition: all 0.2s;
+}
+
+.theme-toggle:active {
+ transform: scale(0.95);
+ background: var(--divider-color);
+}
+
+.theme-toggle svg {
+ transition: transform 0.3s ease;
+}
+
+.theme-toggle:active svg {
+ transform: rotate(180deg);
+}
+
diff --git a/frontend/src/components/ThemeToggle.jsx b/frontend/src/components/ThemeToggle.jsx
new file mode 100644
index 0000000..40b982b
--- /dev/null
+++ b/frontend/src/components/ThemeToggle.jsx
@@ -0,0 +1,47 @@
+import { useState, useEffect } from 'react'
+import { Sun, Moon, Monitor } from 'lucide-react'
+import { getTheme, setTheme, THEMES } from '../utils/theme'
+import { hapticFeedback } from '../utils/telegram'
+import './ThemeToggle.css'
+
+const THEME_ICONS = {
+ [THEMES.LIGHT]: Sun,
+ [THEMES.DARK]: Moon,
+ [THEMES.AUTO]: Monitor
+}
+
+const THEME_LABELS = {
+ [THEMES.LIGHT]: 'Светлая',
+ [THEMES.DARK]: 'Тёмная',
+ [THEMES.AUTO]: 'Авто'
+}
+
+export default function ThemeToggle({ showLabel = false }) {
+ const [currentTheme, setCurrentTheme] = useState(getTheme())
+
+ useEffect(() => {
+ const theme = getTheme()
+ setCurrentTheme(theme)
+ }, [])
+
+ const handleToggle = () => {
+ hapticFeedback('light')
+
+ const themes = [THEMES.LIGHT, THEMES.DARK, THEMES.AUTO]
+ const currentIndex = themes.indexOf(currentTheme)
+ const nextTheme = themes[(currentIndex + 1) % themes.length]
+
+ setTheme(nextTheme)
+ setCurrentTheme(nextTheme)
+ }
+
+ const Icon = THEME_ICONS[currentTheme]
+
+ return (
+
+ )
+}
+
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
new file mode 100644
index 0000000..c7dd74d
--- /dev/null
+++ b/frontend/src/main.jsx
@@ -0,0 +1,11 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App.jsx'
+import './styles/index.css'
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+ ,
+)
+
diff --git a/frontend/src/pages/Feed.css b/frontend/src/pages/Feed.css
new file mode 100644
index 0000000..210476a
--- /dev/null
+++ b/frontend/src/pages/Feed.css
@@ -0,0 +1,116 @@
+.feed-page {
+ min-height: 100vh;
+}
+
+.feed-header {
+ position: sticky;
+ top: 0;
+ background: var(--bg-secondary);
+ padding: 16px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ border-bottom: 1px solid var(--divider-color);
+ z-index: 10;
+}
+
+.feed-header h1 {
+ font-size: 24px;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.create-btn {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: var(--button-dark);
+ color: white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 2px 8px var(--shadow-md);
+}
+
+.feed-filters {
+ display: flex;
+ gap: 8px;
+ padding: 12px 16px;
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--divider-color);
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+}
+
+.feed-filters::-webkit-scrollbar {
+ display: none;
+}
+
+.filter-btn {
+ padding: 8px 16px;
+ border-radius: 20px;
+ background: var(--bg-primary);
+ color: var(--text-secondary);
+ font-size: 14px;
+ font-weight: 500;
+ white-space: nowrap;
+ transition: all 0.2s;
+}
+
+.filter-btn.active {
+ background: var(--button-dark);
+ color: white;
+}
+
+.feed-content {
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.loading-state {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 60px 20px;
+}
+
+.empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 60px 20px;
+ gap: 16px;
+}
+
+.empty-state p {
+ color: var(--text-secondary);
+ font-size: 16px;
+}
+
+.btn-primary {
+ padding: 12px 24px;
+ border-radius: 12px;
+ background: var(--button-dark);
+ color: white;
+ font-size: 16px;
+ font-weight: 600;
+}
+
+.load-more-btn {
+ width: 100%;
+ padding: 12px;
+ border-radius: 12px;
+ background: var(--bg-secondary);
+ color: var(--button-accent);
+ font-size: 15px;
+ font-weight: 600;
+ margin-top: 8px;
+}
+
+.load-more-btn:disabled {
+ opacity: 0.5;
+}
+
diff --git a/frontend/src/pages/Feed.jsx b/frontend/src/pages/Feed.jsx
new file mode 100644
index 0000000..f54d751
--- /dev/null
+++ b/frontend/src/pages/Feed.jsx
@@ -0,0 +1,147 @@
+import { useState, useEffect } from 'react'
+import { getPosts } from '../utils/api'
+import PostCard from '../components/PostCard'
+import CreatePostModal from '../components/CreatePostModal'
+import { Plus } from 'lucide-react'
+import { hapticFeedback } from '../utils/telegram'
+import './Feed.css'
+
+export default function Feed({ user }) {
+ const [posts, setPosts] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [showCreateModal, setShowCreateModal] = useState(false)
+ const [filter, setFilter] = useState('all')
+ const [page, setPage] = useState(1)
+ const [hasMore, setHasMore] = useState(true)
+
+ useEffect(() => {
+ loadPosts()
+ }, [filter])
+
+ const loadPosts = async (pageNum = 1) => {
+ try {
+ setLoading(true)
+ const params = {}
+ if (filter !== 'all') {
+ params.tag = filter
+ }
+ params.page = pageNum
+
+ const data = await getPosts(params)
+
+ if (pageNum === 1) {
+ setPosts(data.posts)
+ } else {
+ setPosts(prev => [...prev, ...data.posts])
+ }
+
+ setHasMore(pageNum < data.totalPages)
+ setPage(pageNum)
+ } catch (error) {
+ console.error('Ошибка загрузки постов:', error)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleCreatePost = () => {
+ hapticFeedback('light')
+ setShowCreateModal(true)
+ }
+
+ const handlePostCreated = (newPost) => {
+ setPosts(prev => [newPost, ...prev])
+ setShowCreateModal(false)
+ }
+
+ const handleLoadMore = () => {
+ if (!loading && hasMore) {
+ loadPosts(page + 1)
+ }
+ }
+
+ return (
+
+ {/* Хедер */}
+
+
+ {/* Фильтры */}
+
+
+
+
+
+
+
+ {/* Посты */}
+
+ {loading && posts.length === 0 ? (
+
+ ) : posts.length === 0 ? (
+
+
Пока нет постов
+
+
+ ) : (
+ <>
+ {posts.map(post => (
+
+ ))}
+
+ {hasMore && (
+
+ )}
+ >
+ )}
+
+
+ {/* Модальное окно создания поста */}
+ {showCreateModal && (
+
setShowCreateModal(false)}
+ onPostCreated={handlePostCreated}
+ />
+ )}
+
+ )
+}
+
diff --git a/frontend/src/pages/Notifications.css b/frontend/src/pages/Notifications.css
new file mode 100644
index 0000000..b1f6f2d
--- /dev/null
+++ b/frontend/src/pages/Notifications.css
@@ -0,0 +1,184 @@
+.notifications-page {
+ min-height: 100vh;
+}
+
+.notifications-header {
+ position: sticky;
+ top: 0;
+ background: var(--bg-secondary);
+ padding: 16px;
+ border-bottom: 1px solid var(--divider-color);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ z-index: 10;
+}
+
+.notifications-header > div {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.notifications-header h1 {
+ font-size: 24px;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.unread-badge {
+ padding: 4px 10px;
+ border-radius: 12px;
+ background: #FF3B30;
+ color: white;
+ font-size: 13px;
+ font-weight: 700;
+}
+
+.mark-all-btn {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 12px;
+ border-radius: 20px;
+ background: var(--button-accent);
+ color: white;
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.notifications-list {
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+/* Telegram-стиль баблов */
+.notification-bubble {
+ display: flex;
+ gap: 12px;
+ padding: 12px;
+ border-radius: 16px;
+ background: var(--bg-secondary);
+ box-shadow: 0 2px 8px var(--shadow-sm);
+ cursor: pointer;
+ transition: all 0.2s;
+ position: relative;
+}
+
+.notification-bubble.unread {
+ background: #E8F4FD;
+ border-left: 3px solid var(--button-accent);
+}
+
+.notification-bubble:active {
+ transform: scale(0.98);
+ box-shadow: 0 1px 4px var(--shadow-sm);
+}
+
+.bubble-avatar {
+ position: relative;
+ flex-shrink: 0;
+}
+
+.bubble-avatar img {
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ object-fit: cover;
+}
+
+.bubble-icon {
+ position: absolute;
+ bottom: -2px;
+ right: -2px;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 2px solid var(--bg-secondary);
+}
+
+.bubble-content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.bubble-text {
+ font-size: 15px;
+ line-height: 1.4;
+}
+
+.bubble-username {
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.bubble-action {
+ color: var(--text-secondary);
+}
+
+.bubble-post-preview {
+ padding: 8px 12px;
+ border-radius: 10px;
+ background: var(--bg-primary);
+ font-size: 14px;
+ color: var(--text-secondary);
+ line-height: 1.4;
+}
+
+.bubble-image-preview {
+ width: 100%;
+ max-width: 200px;
+ height: 120px;
+ border-radius: 10px;
+ overflow: hidden;
+}
+
+.bubble-image-preview img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.bubble-time {
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.bubble-unread-dot {
+ position: absolute;
+ top: 16px;
+ right: 12px;
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--button-accent);
+}
+
+/* Разные стили для разных типов уведомлений */
+.notification-bubble.unread:has(.bubble-icon[style*="FF3B30"]) {
+ background: #FFE8E6;
+ border-left-color: #FF3B30;
+}
+
+.notification-bubble.unread:has(.bubble-icon[style*="34C759"]) {
+ background: #E6F9EB;
+ border-left-color: #34C759;
+}
+
+.notification-bubble.unread:has(.bubble-icon[style*="5856D6"]) {
+ background: #EEEAFD;
+ border-left-color: #5856D6;
+}
+
+.notification-bubble.unread:has(.bubble-icon[style*="FF9500"]) {
+ background: #FFF3E0;
+ border-left-color: #FF9500;
+}
+
diff --git a/frontend/src/pages/Notifications.jsx b/frontend/src/pages/Notifications.jsx
new file mode 100644
index 0000000..9623d3e
--- /dev/null
+++ b/frontend/src/pages/Notifications.jsx
@@ -0,0 +1,195 @@
+import { useState, useEffect } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { Heart, MessageCircle, Share2, UserPlus, AtSign, CheckCheck } from 'lucide-react'
+import { getNotifications, markNotificationRead, markAllNotificationsRead } from '../utils/api'
+import { hapticFeedback } from '../utils/telegram'
+import './Notifications.css'
+
+const NOTIFICATION_ICONS = {
+ follow: UserPlus,
+ like: Heart,
+ comment: MessageCircle,
+ repost: Share2,
+ mention: AtSign
+}
+
+const NOTIFICATION_COLORS = {
+ follow: '#007AFF',
+ like: '#FF3B30',
+ comment: '#34C759',
+ repost: '#5856D6',
+ mention: '#FF9500'
+}
+
+const NOTIFICATION_TEXTS = {
+ follow: 'подписался на вас',
+ like: 'лайкнул ваш пост',
+ comment: 'прокомментировал ваш пост',
+ repost: 'репостнул ваш пост',
+ mention: 'упомянул вас в посте'
+}
+
+export default function Notifications({ user }) {
+ const navigate = useNavigate()
+ const [notifications, setNotifications] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [unreadCount, setUnreadCount] = useState(0)
+
+ useEffect(() => {
+ loadNotifications()
+ }, [])
+
+ const loadNotifications = async () => {
+ try {
+ setLoading(true)
+ const data = await getNotifications()
+ setNotifications(data.notifications)
+ setUnreadCount(data.unreadCount)
+ } catch (error) {
+ console.error('Ошибка загрузки уведомлений:', error)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleNotificationClick = async (notification) => {
+ hapticFeedback('light')
+
+ // Отметить как прочитанное
+ if (!notification.read) {
+ try {
+ await markNotificationRead(notification._id)
+ setNotifications(prev =>
+ prev.map(n => n._id === notification._id ? { ...n, read: true } : n)
+ )
+ setUnreadCount(prev => Math.max(0, prev - 1))
+ } catch (error) {
+ console.error('Ошибка отметки:', error)
+ }
+ }
+
+ // Переход
+ if (notification.type === 'follow') {
+ navigate(`/user/${notification.sender._id}`)
+ } else if (notification.post) {
+ // Можно добавить переход к посту
+ navigate('/feed')
+ }
+ }
+
+ const handleMarkAllRead = async () => {
+ try {
+ hapticFeedback('light')
+ await markAllNotificationsRead()
+ setNotifications(prev => prev.map(n => ({ ...n, read: true })))
+ setUnreadCount(0)
+ hapticFeedback('success')
+ } catch (error) {
+ console.error('Ошибка отметки всех:', error)
+ }
+ }
+
+ const formatTime = (date) => {
+ const d = new Date(date)
+ const now = new Date()
+ const diff = Math.floor((now - d) / 1000) // секунды
+
+ if (diff < 60) return 'только что'
+ if (diff < 3600) return `${Math.floor(diff / 60)} мин назад`
+ if (diff < 86400) return `${Math.floor(diff / 3600)} ч назад`
+ if (diff < 604800) return `${Math.floor(diff / 86400)} д назад`
+
+ return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
+ }
+
+ return (
+
+ {/* Хедер */}
+
+
+
Уведомления
+ {unreadCount > 0 && (
+ {unreadCount}
+ )}
+
+ {unreadCount > 0 && (
+
+ )}
+
+
+ {/* Список уведомлений */}
+
+ {loading ? (
+
+ ) : notifications.length === 0 ? (
+
+
Пока нет уведомлений
+
Здесь будут появляться ваши уведомления
+
+ ) : (
+ notifications.map(notification => {
+ const Icon = NOTIFICATION_ICONS[notification.type]
+ const color = NOTIFICATION_COLORS[notification.type]
+
+ return (
+
handleNotificationClick(notification)}
+ >
+
+

+
+
+
+
+
+
+
+
+ {notification.sender.firstName} {notification.sender.lastName}
+
+ {' '}
+
+ {NOTIFICATION_TEXTS[notification.type]}
+
+
+
+ {notification.post && notification.post.content && (
+
+ {notification.post.content.slice(0, 50)}
+ {notification.post.content.length > 50 && '...'}
+
+ )}
+
+ {notification.post && notification.post.imageUrl && (
+
+

+
+ )}
+
+
+ {formatTime(notification.createdAt)}
+
+
+
+ {!notification.read && (
+
+ )}
+
+ )
+ })
+ )}
+
+
+ )
+}
+
diff --git a/frontend/src/pages/Profile.css b/frontend/src/pages/Profile.css
new file mode 100644
index 0000000..feffdff
--- /dev/null
+++ b/frontend/src/pages/Profile.css
@@ -0,0 +1,340 @@
+.profile-page {
+ min-height: 100vh;
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.profile-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 16px 0;
+}
+
+.profile-header h1 {
+ font-size: 28px;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.settings-btn {
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.profile-info {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 16px;
+ padding: 24px;
+}
+
+.profile-avatar {
+ width: 100px;
+ height: 100px;
+ border-radius: 50%;
+ object-fit: cover;
+ border: 4px solid var(--bg-primary);
+}
+
+.profile-details {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+}
+
+.profile-name {
+ font-size: 22px;
+ font-weight: 700;
+ color: var(--text-primary);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.profile-username {
+ font-size: 15px;
+ color: var(--text-secondary);
+}
+
+.profile-bio {
+ position: relative;
+ width: 100%;
+ padding: 12px;
+ background: var(--bg-primary);
+ border-radius: 12px;
+ text-align: center;
+}
+
+.profile-bio p {
+ font-size: 14px;
+ line-height: 1.5;
+ color: var(--text-secondary);
+}
+
+.edit-bio-btn {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ background: var(--bg-secondary);
+ color: var(--text-secondary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.add-bio-btn {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 16px;
+ border-radius: 20px;
+ background: var(--bg-primary);
+ color: var(--text-secondary);
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.profile-stats {
+ display: flex;
+ align-items: center;
+ gap: 24px;
+ width: 100%;
+ justify-content: center;
+ padding-top: 16px;
+ border-top: 1px solid var(--divider-color);
+}
+
+.stat-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+}
+
+.stat-value {
+ font-size: 20px;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.stat-label {
+ font-size: 13px;
+ color: var(--text-secondary);
+}
+
+.stat-divider {
+ width: 1px;
+ height: 40px;
+ background: var(--divider-color);
+}
+
+/* Донаты */
+.donate-section {
+ padding: 20px;
+}
+
+.donate-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 16px;
+}
+
+.donate-header h3 {
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 4px;
+}
+
+.donate-header p {
+ font-size: 14px;
+ color: var(--text-secondary);
+}
+
+.donate-btn {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 14px;
+ border-radius: 12px;
+ background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
+ color: white;
+ font-size: 16px;
+ font-weight: 600;
+ margin-bottom: 12px;
+ box-shadow: 0 4px 12px rgba(255, 215, 0, 0.3);
+}
+
+.donate-info {
+ text-align: center;
+}
+
+.donate-info p {
+ font-size: 12px;
+ color: var(--text-secondary);
+ line-height: 1.4;
+}
+
+/* Быстрые настройки */
+.quick-settings {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.quick-settings h3 {
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--text-primary);
+ padding: 0 4px;
+}
+
+.setting-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 16px;
+}
+
+.setting-name {
+ font-size: 15px;
+ font-weight: 500;
+ color: var(--text-primary);
+ margin-bottom: 4px;
+}
+
+.setting-desc {
+ font-size: 13px;
+ color: var(--text-secondary);
+}
+
+/* Toggle переключатель */
+.toggle {
+ position: relative;
+ display: inline-block;
+ width: 52px;
+ height: 32px;
+}
+
+.toggle input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.toggle-slider {
+ position: absolute;
+ cursor: pointer;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: var(--border-color);
+ transition: 0.3s;
+ border-radius: 32px;
+}
+
+.toggle-slider:before {
+ position: absolute;
+ content: "";
+ height: 26px;
+ width: 26px;
+ left: 3px;
+ bottom: 3px;
+ background-color: white;
+ transition: 0.3s;
+ border-radius: 50%;
+}
+
+.toggle input:checked + .toggle-slider {
+ background-color: #34C759;
+}
+
+.toggle input:checked + .toggle-slider:before {
+ transform: translateX(20px);
+}
+
+/* Модальные окна настроек */
+.settings-modal .modal-body {
+ max-height: 70vh;
+ overflow-y: auto;
+}
+
+.settings-section {
+ padding: 16px 0;
+}
+
+.settings-section:not(:last-child) {
+ border-bottom: 1px solid var(--divider-color);
+}
+
+.settings-section h3 {
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 16px;
+}
+
+.setting-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 0;
+}
+
+.setting-row:not(:last-child) {
+ border-bottom: 1px solid var(--divider-color);
+}
+
+.radio-group {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.radio-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px;
+ border-radius: 12px;
+ background: var(--bg-primary);
+ cursor: pointer;
+}
+
+.radio-item input {
+ width: 20px;
+ height: 20px;
+ accent-color: var(--button-accent);
+}
+
+.radio-item span {
+ font-size: 15px;
+ color: var(--text-primary);
+}
+
+.char-count {
+ text-align: right;
+ font-size: 12px;
+ color: var(--text-secondary);
+ margin-top: 8px;
+}
+
diff --git a/frontend/src/pages/Profile.jsx b/frontend/src/pages/Profile.jsx
new file mode 100644
index 0000000..26da7c2
--- /dev/null
+++ b/frontend/src/pages/Profile.jsx
@@ -0,0 +1,312 @@
+import { useState } from 'react'
+import { Settings, Heart, Edit2, Star, Shield } from 'lucide-react'
+import { updateProfile } from '../utils/api'
+import { hapticFeedback, openTelegramLink } from '../utils/telegram'
+import ThemeToggle from '../components/ThemeToggle'
+import './Profile.css'
+
+export default function Profile({ user, setUser }) {
+ const [showSettings, setShowSettings] = useState(false)
+ const [showEditBio, setShowEditBio] = useState(false)
+ const [bio, setBio] = useState(user.bio || '')
+ const [settings, setSettings] = useState(user.settings || {
+ whitelist: {
+ noFurry: false,
+ onlyAnime: false,
+ noNSFW: true
+ },
+ searchPreference: 'mixed'
+ })
+ const [saving, setSaving] = useState(false)
+
+ const handleSaveBio = async () => {
+ try {
+ setSaving(true)
+ hapticFeedback('light')
+
+ const updatedUser = await updateProfile({ bio })
+ setUser({ ...user, bio })
+ setShowEditBio(false)
+ hapticFeedback('success')
+ } catch (error) {
+ console.error('Ошибка сохранения:', error)
+ hapticFeedback('error')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleSaveSettings = async () => {
+ try {
+ setSaving(true)
+ hapticFeedback('light')
+
+ const updatedUser = await updateProfile({ settings })
+ setUser({ ...user, settings })
+ setShowSettings(false)
+ hapticFeedback('success')
+ } catch (error) {
+ console.error('Ошибка сохранения:', error)
+ hapticFeedback('error')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleDonate = () => {
+ hapticFeedback('light')
+ // В будущем здесь будет интеграция Telegram Stars
+ openTelegramLink('https://t.me/donate')
+ }
+
+ const updateWhitelistSetting = async (key, value) => {
+ const newSettings = {
+ ...settings,
+ whitelist: {
+ ...settings.whitelist,
+ [key]: value
+ }
+ }
+ setSettings(newSettings)
+
+ // Сохранить сразу на сервер
+ try {
+ await updateProfile({ settings: newSettings })
+ hapticFeedback('success')
+ } catch (error) {
+ console.error('Ошибка сохранения настроек:', error)
+ hapticFeedback('error')
+ }
+ }
+
+ const updateSearchPreference = (value) => {
+ setSettings({
+ ...settings,
+ searchPreference: value
+ })
+ }
+
+ return (
+
+ {/* Хедер */}
+
+
Профиль
+
+
+
+ {/* Информация о пользователе */}
+
+

+
+
+
+ {user.firstName} {user.lastName}
+ {(user.role === 'moderator' || user.role === 'admin') && (
+
+ )}
+
+
@{user.username}
+
+ {user.bio ? (
+
+
{user.bio}
+
+
+ ) : (
+
+ )}
+
+
+
+
+ {user.followersCount || 0}
+ Подписчики
+
+
+
+ {user.followingCount || 0}
+ Подписки
+
+
+
+
+
+ {/* Быстрые настройки */}
+
+
Быстрые настройки
+
+
+
+
Тема оформления
+
Светлая / Тёмная / Авто
+
+
+
+
+
+
+
Скрыть контент 18+
+
Не показывать посты с пометкой NSFW
+
+
+
+
+
+ {/* Модальное окно редактирования bio */}
+ {showEditBio && (
+
setShowEditBio(false)}>
+
e.stopPropagation()}>
+
+
Описание профиля
+
+
+
+
+
+
+ )}
+
+ {/* Модальное окно настроек */}
+ {showSettings && (
+
setShowSettings(false)}>
+
e.stopPropagation()}>
+
+
Настройки
+
+
+
+
+
+
Фильтры контента
+
+
+
+
Без Furry
+
Скрыть посты с тегом Furry
+
+
+
+
+
+
+
Только Anime
+
Показывать только Anime
+
+
+
+
+
+
+
Без NSFW
+
Скрыть контент 18+
+
+
+
+
+
+
+
+
+
+ )}
+
+ )
+}
+
diff --git a/frontend/src/pages/Search.css b/frontend/src/pages/Search.css
new file mode 100644
index 0000000..9e8c338
--- /dev/null
+++ b/frontend/src/pages/Search.css
@@ -0,0 +1,293 @@
+.search-page {
+ min-height: 100vh;
+}
+
+.search-header {
+ position: sticky;
+ top: 0;
+ background: var(--bg-secondary);
+ padding: 16px;
+ border-bottom: 1px solid var(--divider-color);
+ z-index: 10;
+}
+
+.search-header h1 {
+ font-size: 24px;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.search-modes {
+ display: flex;
+ gap: 8px;
+ padding: 12px 16px;
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--divider-color);
+}
+
+.mode-btn {
+ padding: 8px 16px;
+ border-radius: 20px;
+ background: var(--bg-primary);
+ color: var(--text-secondary);
+ font-size: 14px;
+ font-weight: 600;
+ transition: all 0.2s;
+}
+
+.mode-btn.active {
+ background: var(--button-dark);
+ color: white;
+}
+
+.search-container {
+ padding: 16px;
+ background: var(--bg-secondary);
+ position: relative;
+}
+
+.search-input-wrapper {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 16px;
+ background: var(--search-bg);
+ border-radius: 24px;
+}
+
+.search-icon {
+ color: var(--search-icon);
+ flex-shrink: 0;
+}
+
+.search-input-wrapper input {
+ flex: 1;
+ background: transparent;
+ color: var(--text-primary);
+ font-size: 16px;
+}
+
+.clear-btn {
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ background: var(--text-secondary);
+ color: white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.tag-suggestions {
+ position: absolute;
+ top: 70px;
+ left: 16px;
+ right: 16px;
+ background: var(--bg-secondary);
+ border-radius: 12px;
+ box-shadow: 0 4px 12px var(--shadow-lg);
+ overflow: hidden;
+ z-index: 100;
+ animation: scaleIn 0.2s;
+}
+
+.tag-suggestion {
+ width: 100%;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 16px;
+ background: transparent;
+ transition: background 0.2s;
+}
+
+.tag-suggestion:not(:last-child) {
+ border-bottom: 1px solid var(--divider-color);
+}
+
+.tag-suggestion:active {
+ background: var(--bg-primary);
+}
+
+.tag-name {
+ font-size: 15px;
+ color: var(--text-primary);
+ font-weight: 500;
+}
+
+.tag-count {
+ font-size: 13px;
+ color: var(--text-secondary);
+}
+
+.search-results {
+ padding: 16px;
+ min-height: 400px;
+}
+
+.results-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 12px;
+}
+
+.result-item {
+ position: relative;
+ aspect-ratio: 1;
+ overflow: hidden;
+ cursor: pointer;
+ padding: 0;
+}
+
+.result-item img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ transition: transform 0.2s;
+}
+
+.result-item:active img {
+ transform: scale(1.05);
+}
+
+.result-overlay {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ padding: 8px;
+ background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.result-source {
+ font-size: 11px;
+ font-weight: 600;
+ color: white;
+ text-transform: uppercase;
+}
+
+.result-rating {
+ font-size: 11px;
+ font-weight: 600;
+ color: white;
+ padding: 2px 6px;
+ border-radius: 4px;
+ background: rgba(255, 255, 255, 0.2);
+}
+
+/* Просмотрщик изображений */
+.image-viewer {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.95);
+ z-index: 1000;
+ display: flex;
+ flex-direction: column;
+ animation: fadeIn 0.2s;
+}
+
+.viewer-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 16px;
+ background: rgba(0, 0, 0, 0.5);
+}
+
+.viewer-btn {
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.1);
+ color: white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ backdrop-filter: blur(10px);
+}
+
+.viewer-counter {
+ font-size: 16px;
+ font-weight: 600;
+ color: white;
+}
+
+.viewer-content {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+}
+
+.viewer-content img {
+ max-width: 100%;
+ max-height: 100%;
+ object-fit: contain;
+}
+
+.viewer-nav {
+ position: absolute;
+ top: 50%;
+ left: 0;
+ right: 0;
+ transform: translateY(-50%);
+ display: flex;
+ justify-content: space-between;
+ padding: 0 16px;
+ pointer-events: none;
+}
+
+.nav-btn {
+ width: 56px;
+ height: 56px;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.15);
+ color: white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ backdrop-filter: blur(10px);
+ pointer-events: all;
+}
+
+.nav-btn:disabled {
+ opacity: 0.3;
+}
+
+.viewer-info {
+ padding: 16px;
+ background: rgba(0, 0, 0, 0.5);
+ backdrop-filter: blur(10px);
+}
+
+.info-tags {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ margin-bottom: 12px;
+}
+
+.info-tag {
+ padding: 4px 10px;
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.15);
+ color: white;
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.info-stats {
+ display: flex;
+ gap: 16px;
+ font-size: 13px;
+ color: rgba(255, 255, 255, 0.7);
+}
+
diff --git a/frontend/src/pages/Search.jsx b/frontend/src/pages/Search.jsx
new file mode 100644
index 0000000..f12cec4
--- /dev/null
+++ b/frontend/src/pages/Search.jsx
@@ -0,0 +1,294 @@
+import { useState, useEffect } from 'react'
+import { Search as SearchIcon, ChevronLeft, ChevronRight, Download, X } from 'lucide-react'
+import { searchFurry, searchAnime, getFurryTags, getAnimeTags } from '../utils/api'
+import { hapticFeedback } from '../utils/telegram'
+import './Search.css'
+
+export default function Search({ user }) {
+ const [mode, setMode] = useState(user.settings?.searchPreference || 'mixed')
+ const [query, setQuery] = useState('')
+ const [results, setResults] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [tagSuggestions, setTagSuggestions] = useState([])
+ const [currentIndex, setCurrentIndex] = useState(0)
+ const [showViewer, setShowViewer] = useState(false)
+
+ useEffect(() => {
+ if (query.length > 1) {
+ loadTagSuggestions()
+ } else {
+ setTagSuggestions([])
+ }
+ }, [query, mode])
+
+ const loadTagSuggestions = async () => {
+ try {
+ let tags = []
+
+ if (mode === 'furry' || mode === 'mixed') {
+ const furryTags = await getFurryTags(query)
+ tags = [...tags, ...furryTags.map(t => ({ ...t, source: 'e621' }))]
+ }
+
+ if (mode === 'anime' || mode === 'mixed') {
+ const animeTags = await getAnimeTags(query)
+ tags = [...tags, ...animeTags.map(t => ({ ...t, source: 'gelbooru' }))]
+ }
+
+ // Убрать дубликаты
+ const uniqueTags = tags.reduce((acc, tag) => {
+ if (!acc.find(t => t.name === tag.name)) {
+ acc.push(tag)
+ }
+ return acc
+ }, [])
+
+ setTagSuggestions(uniqueTags.slice(0, 10))
+ } catch (error) {
+ console.error('Ошибка загрузки тегов:', error)
+ }
+ }
+
+ const handleSearch = async (searchQuery = query) => {
+ if (!searchQuery.trim()) return
+
+ try {
+ setLoading(true)
+ hapticFeedback('light')
+ setResults([])
+
+ let allResults = []
+
+ if (mode === 'furry' || mode === 'mixed') {
+ const furryResults = await searchFurry(searchQuery, { limit: 30 })
+ allResults = [...allResults, ...furryResults]
+ }
+
+ if (mode === 'anime' || mode === 'mixed') {
+ const animeResults = await searchAnime(searchQuery, { limit: 30 })
+ allResults = [...allResults, ...animeResults]
+ }
+
+ // Перемешать результаты если mixed режим
+ if (mode === 'mixed') {
+ allResults = allResults.sort(() => Math.random() - 0.5)
+ }
+
+ setResults(allResults)
+ setTagSuggestions([])
+
+ if (allResults.length > 0) {
+ hapticFeedback('success')
+ }
+ } catch (error) {
+ console.error('Ошибка поиска:', error)
+ hapticFeedback('error')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleTagClick = (tagName) => {
+ setQuery(tagName)
+ handleSearch(tagName)
+ }
+
+ const openViewer = (index) => {
+ setCurrentIndex(index)
+ setShowViewer(true)
+ hapticFeedback('light')
+ }
+
+ const handleNext = () => {
+ if (currentIndex < results.length - 1) {
+ setCurrentIndex(currentIndex + 1)
+ hapticFeedback('light')
+ }
+ }
+
+ const handlePrev = () => {
+ if (currentIndex > 0) {
+ setCurrentIndex(currentIndex - 1)
+ hapticFeedback('light')
+ }
+ }
+
+ const handleDownload = async () => {
+ const currentImage = results[currentIndex]
+ if (!currentImage) return
+
+ try {
+ hapticFeedback('light')
+ const response = await fetch(currentImage.url)
+ const blob = await response.blob()
+ const url = window.URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = `nakama-${currentImage.id}.jpg`
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ window.URL.revokeObjectURL(url)
+ hapticFeedback('success')
+ } catch (error) {
+ console.error('Ошибка скачивания:', error)
+ hapticFeedback('error')
+ }
+ }
+
+ return (
+
+ {/* Хедер */}
+
+
Поиск
+
+
+ {/* Режимы поиска */}
+
+
+
+
+
+
+ {/* Строка поиска */}
+
+
+
+ setQuery(e.target.value)}
+ onKeyPress={e => e.key === 'Enter' && handleSearch()}
+ />
+ {query && (
+
+ )}
+
+
+ {/* Подсказки тегов */}
+ {tagSuggestions.length > 0 && (
+
+ {tagSuggestions.map((tag, index) => (
+
+ ))}
+
+ )}
+
+
+ {/* Результаты */}
+
+ {loading ? (
+
+ ) : results.length === 0 && query ? (
+
+
Ничего не найдено
+
Попробуйте другие теги
+
+ ) : results.length === 0 ? (
+
+
+
Введите теги для поиска
+
Используйте e621 и gelbooru
+
+ ) : (
+
+ {results.map((item, index) => (
+
openViewer(index)}
+ >
+

+
+ {item.source}
+ {item.rating}
+
+
+ ))}
+
+ )}
+
+
+ {/* Просмотрщик изображений */}
+ {showViewer && results[currentIndex] && (
+
setShowViewer(false)}>
+
+
+
+ {currentIndex + 1} / {results.length}
+
+
+
+
+
e.stopPropagation()}>
+

+
+
+
+
+
+
+
+
+
+ {results[currentIndex].tags.slice(0, 5).map((tag, i) => (
+ {tag}
+ ))}
+
+
+ Score: {results[currentIndex].score}
+ Source: {results[currentIndex].source}
+
+
+
+ )}
+
+ )
+}
+
diff --git a/frontend/src/pages/UserProfile.css b/frontend/src/pages/UserProfile.css
new file mode 100644
index 0000000..e18d797
--- /dev/null
+++ b/frontend/src/pages/UserProfile.css
@@ -0,0 +1,144 @@
+.user-profile-page {
+ min-height: 100vh;
+}
+
+.user-profile-header {
+ position: sticky;
+ top: 0;
+ background: var(--bg-secondary);
+ padding: 16px;
+ border-bottom: 1px solid var(--divider-color);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ z-index: 10;
+}
+
+.user-profile-header h1 {
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.back-btn {
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ background: transparent;
+ color: var(--text-primary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.user-info {
+ margin: 16px;
+ padding: 24px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 16px;
+}
+
+.user-avatar {
+ width: 100px;
+ height: 100px;
+ border-radius: 50%;
+ object-fit: cover;
+ border: 4px solid var(--bg-primary);
+}
+
+.user-details {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+}
+
+.user-name {
+ font-size: 22px;
+ font-weight: 700;
+ color: var(--text-primary);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.user-username {
+ font-size: 15px;
+ color: var(--text-secondary);
+}
+
+.user-bio {
+ width: 100%;
+ padding: 12px;
+ background: var(--bg-primary);
+ border-radius: 12px;
+ text-align: center;
+}
+
+.user-bio p {
+ font-size: 14px;
+ line-height: 1.5;
+ color: var(--text-secondary);
+}
+
+.user-stats {
+ display: flex;
+ align-items: center;
+ gap: 24px;
+ width: 100%;
+ justify-content: center;
+ padding-top: 16px;
+ border-top: 1px solid var(--divider-color);
+}
+
+.follow-btn {
+ width: 100%;
+ padding: 12px;
+ border-radius: 12px;
+ background: var(--button-dark);
+ color: white;
+ font-size: 16px;
+ font-weight: 600;
+}
+
+.follow-btn.following {
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ border: 1px solid var(--border-color);
+}
+
+.user-posts {
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.user-posts h3 {
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.posts-list {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.error-state {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100vh;
+ padding: 20px;
+}
+
+.error-state p {
+ font-size: 16px;
+ color: var(--text-secondary);
+}
+
diff --git a/frontend/src/pages/UserProfile.jsx b/frontend/src/pages/UserProfile.jsx
new file mode 100644
index 0000000..46d1fda
--- /dev/null
+++ b/frontend/src/pages/UserProfile.jsx
@@ -0,0 +1,151 @@
+import { useState, useEffect } from 'react'
+import { useParams, useNavigate } from 'react-router-dom'
+import { ChevronLeft, Shield } from 'lucide-react'
+import { getUserProfile, getUserPosts, followUser } from '../utils/api'
+import { hapticFeedback } from '../utils/telegram'
+import PostCard from '../components/PostCard'
+import './UserProfile.css'
+
+export default function UserProfile({ currentUser }) {
+ const { id } = useParams()
+ const navigate = useNavigate()
+ const [user, setUser] = useState(null)
+ const [posts, setPosts] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [following, setFollowing] = useState(false)
+ const [followersCount, setFollowersCount] = useState(0)
+
+ useEffect(() => {
+ loadProfile()
+ loadPosts()
+ }, [id])
+
+ const loadProfile = async () => {
+ try {
+ setLoading(true)
+ const data = await getUserProfile(id)
+ setUser(data)
+ setFollowing(data.isFollowing)
+ setFollowersCount(data.followersCount)
+ } catch (error) {
+ console.error('Ошибка загрузки профиля:', error)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const loadPosts = async () => {
+ try {
+ const data = await getUserPosts(id)
+ setPosts(data.posts)
+ } catch (error) {
+ console.error('Ошибка загрузки постов:', error)
+ }
+ }
+
+ const handleFollow = async () => {
+ try {
+ hapticFeedback('light')
+ const result = await followUser(id)
+ setFollowing(result.following)
+ setFollowersCount(result.followersCount)
+ hapticFeedback('success')
+ } catch (error) {
+ console.error('Ошибка подписки:', error)
+ }
+ }
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ if (!user) {
+ return (
+
+
Пользователь не найден
+
+ )
+ }
+
+ return (
+
+ {/* Хедер */}
+
+
+
Профиль
+
+
+
+ {/* Информация */}
+
+

+
+
+
+ {user.firstName} {user.lastName}
+ {(user.role === 'moderator' || user.role === 'admin') && (
+
+ )}
+
+
@{user.username}
+
+ {user.bio && (
+
+ )}
+
+
+
+
+ {followersCount}
+ Подписчики
+
+
+
+ {user.followingCount}
+ Подписки
+
+
+
+ {/* Кнопка подписки */}
+ {currentUser.id !== user.id && (
+
+ )}
+
+
+ {/* Посты пользователя */}
+
+
Посты ({posts.length})
+
+ {posts.length === 0 ? (
+
+ ) : (
+
+ {posts.map(post => (
+
+ ))}
+
+ )}
+
+
+ )
+}
+
diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css
new file mode 100644
index 0000000..79cb89a
--- /dev/null
+++ b/frontend/src/styles/index.css
@@ -0,0 +1,206 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+:root {
+ /* Светлая тема (по умолчанию) */
+ --bg-primary: #F2F3F5;
+ --bg-secondary: #FFFFFF;
+ --text-primary: #1C1C1E;
+ --text-secondary: #5C5C5C;
+ --border-color: #C7C7CC;
+ --divider-color: #E5E5EA;
+
+ /* Теги */
+ --tag-furry: #FF8A33;
+ --tag-anime: #4A90E2;
+ --tag-other: #A0A0A0;
+
+ /* Кнопки */
+ --button-dark: #1C1C1E;
+ --button-accent: #007AFF;
+
+ /* Поиск */
+ --search-bg: #E6E6E8;
+ --search-icon: #5C5C5C;
+
+ /* Тени */
+ --shadow-sm: rgba(0, 0, 0, 0.04);
+ --shadow-md: rgba(0, 0, 0, 0.08);
+ --shadow-lg: rgba(0, 0, 0, 0.12);
+}
+
+/* Тёмная тема */
+[data-theme="dark"] {
+ --bg-primary: #000000;
+ --bg-secondary: #1C1C1E;
+ --text-primary: #FFFFFF;
+ --text-secondary: #8E8E93;
+ --border-color: #38383A;
+ --divider-color: #2C2C2E;
+
+ /* Теги остаются яркими */
+ --tag-furry: #FF8A33;
+ --tag-anime: #4A90E2;
+ --tag-other: #A0A0A0;
+
+ /* Кнопки */
+ --button-dark: #FFFFFF;
+ --button-accent: #0A84FF;
+
+ /* Поиск */
+ --search-bg: #2C2C2E;
+ --search-icon: #8E8E93;
+
+ /* Тени для тёмной темы */
+ --shadow-sm: rgba(255, 255, 255, 0.04);
+ --shadow-md: rgba(255, 255, 255, 0.08);
+ --shadow-lg: rgba(255, 255, 255, 0.12);
+}
+
+/* Иконки в тёмной теме */
+[data-theme="dark"] svg {
+ color: var(--text-secondary);
+}
+
+[data-theme="dark"] button svg,
+[data-theme="dark"] a svg {
+ color: inherit;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
+ background-color: var(--bg-primary);
+ color: var(--text-primary);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ overflow-x: hidden;
+}
+
+#root {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+/* Скроллбар */
+::-webkit-scrollbar {
+ width: 6px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--bg-primary);
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--border-color);
+ border-radius: 3px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--text-secondary);
+}
+
+/* Анимации */
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes slideUp {
+ from {
+ transform: translateY(100%);
+ }
+ to {
+ transform: translateY(0);
+ }
+}
+
+@keyframes scaleIn {
+ from {
+ transform: scale(0.9);
+ opacity: 0;
+ }
+ to {
+ transform: scale(1);
+ opacity: 1;
+ }
+}
+
+.fade-in {
+ animation: fadeIn 0.3s ease-out;
+}
+
+.slide-up {
+ animation: slideUp 0.3s ease-out;
+}
+
+.scale-in {
+ animation: scaleIn 0.2s ease-out;
+}
+
+/* Утилиты */
+.container {
+ max-width: 600px;
+ margin: 0 auto;
+ padding: 0 16px;
+}
+
+.card {
+ background: var(--bg-secondary);
+ border-radius: 16px;
+ padding: 16px;
+ box-shadow: 0 2px 8px var(--shadow-md);
+ transition: transform 0.2s, box-shadow 0.2s;
+}
+
+.card:active {
+ transform: scale(0.98);
+ box-shadow: 0 1px 4px var(--shadow-sm);
+}
+
+button {
+ font-family: inherit;
+ border: none;
+ outline: none;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+button:active {
+ transform: scale(0.95);
+}
+
+input, textarea {
+ font-family: inherit;
+ border: none;
+ outline: none;
+}
+
+a {
+ color: var(--button-accent);
+ text-decoration: none;
+}
+
+/* Загрузка */
+.spinner {
+ width: 40px;
+ height: 40px;
+ border: 3px solid var(--divider-color);
+ border-top-color: var(--button-accent);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
new file mode 100644
index 0000000..d12a1fc
--- /dev/null
+++ b/frontend/src/utils/api.js
@@ -0,0 +1,165 @@
+import axios from 'axios'
+import { getTelegramInitData, getMockUser, isDevelopment } from './telegram'
+
+// API URL из переменных окружения
+const API_URL = import.meta.env.VITE_API_URL || (
+ import.meta.env.DEV
+ ? 'http://localhost:3000/api'
+ : '/api' // Для production используем относительный путь
+)
+
+// Создать инстанс axios с настройками
+const api = axios.create({
+ baseURL: API_URL,
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+})
+
+// Добавить interceptor для добавления Telegram Init Data
+api.interceptors.request.use((config) => {
+ const initData = getTelegramInitData()
+
+ // В dev режиме создаем mock initData
+ if (!initData && isDevelopment()) {
+ const mockUser = getMockUser()
+ config.headers['x-telegram-init-data'] = `user=${JSON.stringify(mockUser)}`
+ } else {
+ config.headers['x-telegram-init-data'] = initData
+ }
+
+ return config
+})
+
+// Auth API
+export const verifyAuth = async () => {
+ const response = await api.post('/auth/verify')
+ return response.data.user
+}
+
+// Posts API
+export const getPosts = async (params = {}) => {
+ const response = await api.get('/posts', { params })
+ return response.data
+}
+
+export const createPost = async (formData) => {
+ const response = await api.post('/posts', formData, {
+ headers: {
+ 'Content-Type': 'multipart/form-data'
+ }
+ })
+ return response.data.post
+}
+
+export const likePost = async (postId) => {
+ const response = await api.post(`/posts/${postId}/like`)
+ return response.data
+}
+
+export const commentPost = async (postId, content) => {
+ const response = await api.post(`/posts/${postId}/comment`, { content })
+ return response.data
+}
+
+export const repostPost = async (postId) => {
+ const response = await api.post(`/posts/${postId}/repost`)
+ return response.data
+}
+
+export const deletePost = async (postId) => {
+ const response = await api.delete(`/posts/${postId}`)
+ return response.data
+}
+
+// Users API
+export const getUserProfile = async (userId) => {
+ const response = await api.get(`/users/${userId}`)
+ return response.data.user
+}
+
+export const getUserPosts = async (userId, params = {}) => {
+ const response = await api.get(`/users/${userId}/posts`, { params })
+ return response.data
+}
+
+export const followUser = async (userId) => {
+ const response = await api.post(`/users/${userId}/follow`)
+ return response.data
+}
+
+export const updateProfile = async (data) => {
+ const response = await api.put('/users/profile', data)
+ return response.data
+}
+
+export const searchUsers = async (query) => {
+ const response = await api.get(`/users/search/${query}`)
+ return response.data.users
+}
+
+// Notifications API
+export const getNotifications = async (params = {}) => {
+ const response = await api.get('/notifications', { params })
+ return response.data
+}
+
+export const markNotificationRead = async (notificationId) => {
+ const response = await api.put(`/notifications/${notificationId}/read`)
+ return response.data
+}
+
+export const markAllNotificationsRead = async () => {
+ const response = await api.put('/notifications/read-all')
+ return response.data
+}
+
+// Search API
+export const searchFurry = async (query, params = {}) => {
+ const response = await api.get('/search/furry', { params: { query, ...params } })
+ return response.data.posts
+}
+
+export const searchAnime = async (query, params = {}) => {
+ const response = await api.get('/search/anime', { params: { query, ...params } })
+ return response.data.posts
+}
+
+export const getFurryTags = async (query) => {
+ const response = await api.get('/search/furry/tags', { params: { query } })
+ return response.data.tags
+}
+
+export const getAnimeTags = async (query) => {
+ const response = await api.get('/search/anime/tags', { params: { query } })
+ return response.data.tags
+}
+
+// Moderation API
+export const reportPost = async (postId, reason) => {
+ const response = await api.post('/moderation/report', { postId, reason })
+ return response.data
+}
+
+export const getReports = async (params = {}) => {
+ const response = await api.get('/moderation/reports', { params })
+ return response.data
+}
+
+export const updateReport = async (reportId, data) => {
+ const response = await api.put(`/moderation/reports/${reportId}`, data)
+ return response.data
+}
+
+export const setPostNSFW = async (postId, isNSFW) => {
+ const response = await api.put(`/moderation/posts/${postId}/nsfw`, { isNSFW })
+ return response.data
+}
+
+export const banUser = async (userId, banned, days) => {
+ const response = await api.put(`/moderation/users/${userId}/ban`, { banned, days })
+ return response.data
+}
+
+export default api
+
diff --git a/frontend/src/utils/telegram.js b/frontend/src/utils/telegram.js
new file mode 100644
index 0000000..b3317aa
--- /dev/null
+++ b/frontend/src/utils/telegram.js
@@ -0,0 +1,128 @@
+// Утилиты для работы с Telegram Web App
+
+let tg = null
+
+export const initTelegramApp = () => {
+ if (typeof window !== 'undefined' && window.Telegram?.WebApp) {
+ tg = window.Telegram.WebApp
+ tg.ready()
+ tg.expand()
+
+ // Установить цвета темы
+ tg.setHeaderColor('#F2F3F5')
+ tg.setBackgroundColor('#F2F3F5')
+
+ return tg
+ }
+ return null
+}
+
+export const getTelegramApp = () => {
+ return tg || window.Telegram?.WebApp
+}
+
+export const getTelegramUser = () => {
+ const app = getTelegramApp()
+ return app?.initDataUnsafe?.user || null
+}
+
+export const getTelegramInitData = () => {
+ const app = getTelegramApp()
+ return app?.initData || ''
+}
+
+export const showAlert = (message) => {
+ const app = getTelegramApp()
+ if (app) {
+ app.showAlert(message)
+ } else {
+ alert(message)
+ }
+}
+
+export const showConfirm = (message) => {
+ const app = getTelegramApp()
+ return new Promise((resolve) => {
+ if (app) {
+ app.showConfirm(message, resolve)
+ } else {
+ resolve(confirm(message))
+ }
+ })
+}
+
+export const showPopup = (params) => {
+ const app = getTelegramApp()
+ return new Promise((resolve) => {
+ if (app) {
+ app.showPopup(params, resolve)
+ } else {
+ alert(params.message)
+ resolve()
+ }
+ })
+}
+
+export const openTelegramLink = (url) => {
+ const app = getTelegramApp()
+ if (app) {
+ app.openTelegramLink(url)
+ } else {
+ window.open(url, '_blank')
+ }
+}
+
+export const openLink = (url) => {
+ const app = getTelegramApp()
+ if (app) {
+ app.openLink(url)
+ } else {
+ window.open(url, '_blank')
+ }
+}
+
+export const hapticFeedback = (type = 'light') => {
+ const app = getTelegramApp()
+ if (app?.HapticFeedback) {
+ switch (type) {
+ case 'light':
+ app.HapticFeedback.impactOccurred('light')
+ break
+ case 'medium':
+ app.HapticFeedback.impactOccurred('medium')
+ break
+ case 'heavy':
+ app.HapticFeedback.impactOccurred('heavy')
+ break
+ case 'success':
+ app.HapticFeedback.notificationOccurred('success')
+ break
+ case 'warning':
+ app.HapticFeedback.notificationOccurred('warning')
+ break
+ case 'error':
+ app.HapticFeedback.notificationOccurred('error')
+ break
+ default:
+ app.HapticFeedback.impactOccurred('light')
+ }
+ }
+}
+
+// Для разработки: мок данные если не в Telegram
+export const getMockUser = () => {
+ // Генерируем случайные данные для тестирования
+ const randomId = Math.floor(Math.random() * 1000000000)
+ return {
+ id: randomId,
+ first_name: 'Dev',
+ last_name: 'User',
+ username: `dev_user_${randomId}`,
+ photo_url: `https://api.dicebear.com/7.x/avataaars/svg?seed=${randomId}` // Генератор аватаров
+ }
+}
+
+export const isDevelopment = () => {
+ return !window.Telegram?.WebApp?.initDataUnsafe?.user
+}
+
diff --git a/frontend/src/utils/theme.js b/frontend/src/utils/theme.js
new file mode 100644
index 0000000..fcf0bd9
--- /dev/null
+++ b/frontend/src/utils/theme.js
@@ -0,0 +1,80 @@
+// Управление темой приложения
+
+const THEME_KEY = 'nakama_theme'
+
+export const THEMES = {
+ LIGHT: 'light',
+ DARK: 'dark',
+ AUTO: 'auto'
+}
+
+// Получить текущую тему
+export const getTheme = () => {
+ const saved = localStorage.getItem(THEME_KEY)
+ return saved || THEMES.AUTO
+}
+
+// Сохранить тему
+export const setTheme = (theme) => {
+ localStorage.setItem(THEME_KEY, theme)
+ applyTheme(theme)
+}
+
+// Определить системную тему
+export const getSystemTheme = () => {
+ if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
+ return THEMES.DARK
+ }
+ return THEMES.LIGHT
+}
+
+// Применить тему
+export const applyTheme = (theme) => {
+ let actualTheme = theme
+
+ if (theme === THEMES.AUTO) {
+ actualTheme = getSystemTheme()
+ }
+
+ document.documentElement.setAttribute('data-theme', actualTheme)
+
+ // Установить цвета Telegram Mini App
+ if (window.Telegram?.WebApp) {
+ const tg = window.Telegram.WebApp
+
+ if (actualTheme === THEMES.DARK) {
+ tg.setHeaderColor('#1C1C1E')
+ tg.setBackgroundColor('#000000')
+ } else {
+ tg.setHeaderColor('#F2F3F5')
+ tg.setBackgroundColor('#F2F3F5')
+ }
+ }
+}
+
+// Инициализация темы
+export const initTheme = () => {
+ const theme = getTheme()
+ applyTheme(theme)
+
+ // Слушать изменения системной темы
+ if (window.matchMedia) {
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
+ const currentTheme = getTheme()
+ if (currentTheme === THEMES.AUTO) {
+ applyTheme(THEMES.AUTO)
+ }
+ })
+ }
+}
+
+// Переключить тему
+export const toggleTheme = () => {
+ const current = getTheme()
+ const themes = [THEMES.LIGHT, THEMES.DARK, THEMES.AUTO]
+ const currentIndex = themes.indexOf(current)
+ const nextTheme = themes[(currentIndex + 1) % themes.length]
+ setTheme(nextTheme)
+ return nextTheme
+}
+
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 0000000..92e1dca
--- /dev/null
+++ b/frontend/vite.config.js
@@ -0,0 +1,30 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 5173,
+ host: true, // Слушать на всех интерфейсах
+ proxy: {
+ '/api': {
+ target: process.env.VITE_API_URL || 'http://localhost:3000',
+ changeOrigin: true,
+ secure: false
+ }
+ }
+ },
+ build: {
+ outDir: 'dist',
+ sourcemap: false,
+ rollupOptions: {
+ output: {
+ manualChunks: {
+ 'react-vendor': ['react', 'react-dom', 'react-router-dom'],
+ 'ui-vendor': ['lucide-react']
+ }
+ }
+ }
+ }
+})
+
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..6f5d1b2
--- /dev/null
+++ b/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "nakama-space",
+ "version": "1.0.0",
+ "description": "NakamaSpace - Telegram Mini App социальная сеть",
+ "main": "backend/server.js",
+ "scripts": {
+ "dev": "concurrently \"npm run server\" \"npm run client\"",
+ "server": "nodemon backend/server.js",
+ "client": "cd frontend && npm run dev",
+ "build": "cd frontend && npm run build",
+ "start": "node backend/server.js"
+ },
+ "keywords": ["telegram", "mini-app", "social-network"],
+ "author": "",
+ "license": "MIT",
+ "dependencies": {
+ "express": "^4.18.2",
+ "mongoose": "^8.0.0",
+ "cors": "^2.8.5",
+ "dotenv": "^16.3.1",
+ "axios": "^1.6.0",
+ "multer": "^1.4.5-lts.1",
+ "crypto": "^1.0.1",
+ "bcryptjs": "^2.4.3",
+ "jsonwebtoken": "^9.0.2",
+ "express-rate-limit": "^7.1.5",
+ "redis": "^4.6.11",
+ "socket.io": "^4.6.0",
+ "i18next": "^23.7.8",
+ "socket.io-client": "^4.6.0"
+ },
+ "devDependencies": {
+ "nodemon": "^3.0.1",
+ "concurrently": "^8.2.2"
+ }
+}
+
diff --git a/start.sh b/start.sh
new file mode 100755
index 0000000..266972f
--- /dev/null
+++ b/start.sh
@@ -0,0 +1,54 @@
+#!/bin/bash
+
+# NakamaSpace - Скрипт быстрого запуска
+
+echo "🚀 Запуск NakamaSpace..."
+
+# Проверка MongoDB
+if ! pgrep -x "mongod" > /dev/null; then
+ echo "⚠️ MongoDB не запущена, пытаюсь запустить..."
+
+ # Для macOS
+ if [[ "$OSTYPE" == "darwin"* ]]; then
+ brew services start mongodb-community
+ # Для Linux
+ elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
+ sudo systemctl start mongod
+ fi
+
+ sleep 2
+fi
+
+# Проверка .env файла
+if [ ! -f .env ]; then
+ echo "⚠️ Файл .env не найден, создаю из примера..."
+ cp .env.example .env
+ echo "❗ Не забудьте настроить .env файл!"
+fi
+
+# Проверка frontend/.env файла
+if [ ! -f frontend/.env ]; then
+ echo "⚠️ Файл frontend/.env не найден, создаю из примера..."
+ cp frontend/.env.example frontend/.env
+fi
+
+# Проверка node_modules
+if [ ! -d node_modules ]; then
+ echo "📦 Установка зависимостей backend..."
+ npm install
+fi
+
+if [ ! -d frontend/node_modules ]; then
+ echo "📦 Установка зависимостей frontend..."
+ cd frontend && npm install && cd ..
+fi
+
+echo "✅ Всё готово!"
+echo ""
+echo "Запускаю приложение..."
+echo "Backend: http://localhost:3000"
+echo "Frontend: http://localhost:5173"
+echo ""
+
+npm run dev
+
diff --git a/update-server.sh b/update-server.sh
new file mode 100755
index 0000000..2675fce
--- /dev/null
+++ b/update-server.sh
@@ -0,0 +1,62 @@
+#!/bin/bash
+
+# Скрипт обновления NakamaSpace на сервере
+# Использование: ./update-server.sh
+
+echo "🚀 Обновление NakamaSpace..."
+
+# 1. Перейти в директорию проекта
+cd /var/www/nakama || exit 1
+
+# 2. Сделать бэкап (опционально)
+echo "📦 Создание бэкапа..."
+sudo tar -czf ~/nakama-backup-$(date +%Y%m%d_%H%M%S).tar.gz . 2>/dev/null
+
+# 3. Получить новый код (если используете Git)
+if [ -d .git ]; then
+ echo "🔄 Обновление кода из Git..."
+ git pull
+fi
+
+# 4. Обновить backend зависимости
+echo "📦 Обновление backend зависимостей..."
+npm install --production
+
+# 5. Обновить и пересобрать frontend
+echo "🎨 Пересборка frontend..."
+cd frontend
+npm install
+npm run build
+cd ..
+
+# 6. Обновить MongoDB (отключить NSFW фильтр для всех)
+echo "🗄️ Обновление настроек пользователей в MongoDB..."
+mongosh nakama --eval '
+db.users.updateMany(
+ {},
+ { $set: {
+ "settings.whitelist.noNSFW": false,
+ "settings.whitelist.noFurry": false,
+ "settings.whitelist.onlyAnime": false
+ }}
+)
+' --quiet
+
+# 7. Перезапустить backend
+echo "🔄 Перезапуск backend..."
+pm2 restart nakama-backend
+
+# 8. Проверить статус
+echo ""
+echo "✅ Обновление завершено!"
+echo ""
+echo "Проверка статуса:"
+pm2 status
+
+echo ""
+echo "Последние логи:"
+pm2 logs nakama-backend --lines 20 --nostream
+
+echo ""
+echo "Проверьте приложение: https://nakama.glpshchn.ru"
+