Update files

This commit is contained in:
glpshchn 2025-12-15 10:28:47 +03:00
parent 3bf1dbc779
commit fe45fbb159
39 changed files with 4956 additions and 9 deletions

152
MUSIC_SETUP.md Normal file
View File

@ -0,0 +1,152 @@
# Настройка музыкального модуля Nakama
## Установка зависимостей
Для работы музыкального модуля требуется установить дополнительный пакет:
```bash
npm install adm-zip
```
Этот пакет используется для распаковки ZIP-архивов при загрузке альбомов.
## Структура файлов
### Backend
**Модели:**
- `backend/models/Artist.js` - Исполнители
- `backend/models/Album.js` - Альбомы
- `backend/models/Track.js` - Треки
- `backend/models/FavoriteTrack.js` - Избранные треки пользователей
**Routes:**
- `backend/routes/music.js` - API для музыки (загрузка, поиск, избранное)
- `backend/routes/bot.js` - Обновлен для отправки треков в Telegram
- `backend/routes/posts.js` - Обновлен для поддержки музыкальных вложений
**Бот:**
- `backend/bot.js` - Добавлена функция `sendAudioToUser()` для отправки треков
### Frontend
**Страницы:**
- `frontend/src/pages/Media.jsx` - Главная страница Media с категориями
- `frontend/src/pages/MediaFurry.jsx` - Поиск Furry контента
- `frontend/src/pages/MediaAnime.jsx` - Поиск Anime контента
- `frontend/src/pages/MediaMusic.jsx` - Музыкальный сервис
**Компоненты:**
- `frontend/src/components/MiniPlayer.jsx` - Мини-плеер (внизу экрана)
- `frontend/src/components/FullPlayer.jsx` - Полный плеер
- `frontend/src/components/MusicAttachment.jsx` - Отображение музыки в постах
**Контекст:**
- `frontend/src/contexts/MusicPlayerContext.jsx` - Управление состоянием плеера
**API:**
- `frontend/src/utils/musicApi.js` - API функции для работы с музыкой
## Функционал
### 1. Media Hub
- Главная страница с тремя категориями: Furry, Anime, Music
- Квадратные кнопки-карточки с цветовой кодировкой
- Анимации и состояния как в остальном приложении
### 2. Furry / Anime
- Отдельные страницы для каждой категории
- Поиск по тегам (e621 / gelbooru)
- Автокомплит тегов
- Просмотр изображений
- Добавление в посты
- Отправка в Telegram
- Режим выбора нескольких изображений
### 3. Music Service
**Загрузка:**
- Загрузка отдельных треков
- Загрузка альбомов из ZIP архива
- Автоматическое создание исполнителей и альбомов
**Поиск:**
- Поиск по трекам, исполнителям, альбомам
- Фильтрация результатов
**Плеер:**
- Мини-плеер (закреплен внизу)
- Полный плеер (открывается по клику)
- Управление воспроизведением (play/pause/next/prev)
- Прогресс-бар с перемоткой
- Регулировка громкости
- Очередь воспроизведения
**Избранное:**
- Добавление треков в избранное
- Просмотр избранных треков
**Telegram интеграция:**
- Отправка треков в личные сообщения
- Треки отправляются как аудио файлы с метаданными
### 4. Музыка в постах
- Прикрепление треков к постам
- Воспроизведение через общий плеер
- Отображение в ленте
## API Endpoints
### Music
**GET** `/api/music/search?q=query&type=all` - Поиск музыки
**GET** `/api/music/tracks?limit=50&page=1` - Список треков
**GET** `/api/music/tracks/:trackId` - Получить трек
**GET** `/api/music/albums/:albumId` - Получить альбом с треками
**POST** `/api/music/upload-track` - Загрузить трек
- FormData: track (file), title, artistName, albumTitle, trackNumber, year, genre
**POST** `/api/music/upload-album` - Загрузить альбом (ZIP)
- FormData: album (zip), artistName, albumTitle, year, genre
**POST** `/api/music/favorites/:trackId` - Добавить в избранное
**DELETE** `/api/music/favorites/:trackId` - Удалить из избранного
**GET** `/api/music/favorites` - Получить избранные треки
**POST** `/api/music/tracks/:trackId/play` - Отметить прослушивание
### Bot
**POST** `/api/bot/send-track` - Отправить трек в Telegram
```json
{
"userId": 123456789,
"trackId": "track_id"
}
```
## Навигация
Вкладка "Search" переименована в "Media" с иконкой Layers.
## Цветовая схема
- **Furry:** `var(--tag-furry)` - оранжевый (#FF8A33)
- **Anime:** `var(--tag-anime)` - синий (#4A90E2)
- **Music:** `#9b59b6` - фиолетовый
## Примечания
1. Музыкальные файлы хранятся в `backend/uploads/music/`
2. Поддерживаемые форматы: MP3, WAV, OGG, M4A
3. Максимальный размер файла: 50MB
4. ZIP архивы автоматически распаковываются и создают альбом
5. Плеер работает глобально - музыка продолжает играть при навигации
6. Треки в постах воспроизводятся через общий плеер

679
WIND.md Normal file
View File

@ -0,0 +1,679 @@
# 🪟 Инструкция по развертыванию Nakama на Windows
## 📋 Содержание
1. [Предварительные требования](#предварительные-требования)
2. [Установка зависимостей](#установка-зависимостей)
3. [Настройка окружения](#настройка-окружения)
4. [Запуск MongoDB](#запуск-mongodb)
5. [Запуск приложения](#запуск-приложения)
6. [Отключение проверки initData для разработки](#отключение-проверки-initdata-для-разработки)
7. [Тестирование без Telegram](#тестирование-без-telegram)
8. [Полезные команды](#полезные-команды)
9. [Устранение проблем](#устранение-проблем)
---
## 🔧 Предварительные требования
### 1. Node.js
Установите Node.js версии 18 или выше:
- Скачайте с [nodejs.org](https://nodejs.org/)
- Проверьте установку:
```cmd
node --version
npm --version
```
### 2. MongoDB
Установите MongoDB Community Edition:
- Скачайте с [mongodb.com/try/download/community](https://www.mongodb.com/try/download/community)
- Или используйте MongoDB в Docker (см. ниже)
### 3. Git (опционально)
Для работы с репозиторием:
- Скачайте с [git-scm.com](https://git-scm.com/)
---
## 📦 Установка зависимостей
### 1. Клонируйте репозиторий (если еще не клонирован)
```cmd
git clone <repository-url>
cd nakama
```
### 2. Установите зависимости для backend
```cmd
npm install
```
### 3. Установите дополнительные пакеты для музыкального модуля
```cmd
npm install adm-zip
```
### 4. Установите зависимости для frontend
```cmd
cd frontend
npm install
cd ..
```
---
## ⚙️ Настройка окружения
### 1. Создайте файл `.env` в корне проекта
```cmd
REM Скопируйте пример
copy ENV_EXAMPLE.txt .env
```
**Для PowerShell:**
```powershell
# Скопируйте пример
Copy-Item ENV_EXAMPLE.txt .env
```
### 2. Минимальная конфигурация для локальной разработки
Откройте `.env` в текстовом редакторе и настройте:
```env
# Режим разработки
NODE_ENV=development
PORT=3000
# MongoDB (локальная база)
MONGODB_URI=mongodb://localhost:27017/nakama-dev
# JWT Secrets (можно использовать любые строки для разработки)
JWT_SECRET=dev_jwt_secret_change_me_in_production_32chars
JWT_ACCESS_SECRET=dev_access_secret_32chars_minimum_length
JWT_REFRESH_SECRET=dev_refresh_secret_32chars_minimum_length
# Telegram Bot Configuration
# ⚠️ Для разработки без Telegram оставьте пустыми или используйте тестовый токен
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
MODERATION_BOT_TOKEN=
# API ключи для поиска (опционально)
GELBOORU_API_KEY=
GELBOORU_USER_ID=
E621_USERNAME=
E621_API_KEY=
# Frontend URL
FRONTEND_URL=http://localhost:5173
VITE_API_URL=http://localhost:3000/api
# CORS (разрешить все для разработки)
CORS_ORIGIN=*
# Redis (опционально, можно оставить пустым)
REDIS_URL=
# MinIO (отключить для локальной разработки)
MINIO_ENABLED=false
# Rate Limiting (мягкие лимиты для разработки)
RATE_LIMIT_GENERAL=1000
RATE_LIMIT_POSTS=100
RATE_LIMIT_INTERACTIONS=200
# Email (отключить для локальной разработки)
EMAIL_PROVIDER=
```
### 3. Создайте `.env` для frontend
```cmd
cd frontend
echo VITE_API_URL=http://localhost:3000/api > .env
cd ..
```
**Для PowerShell:**
```powershell
cd frontend
"VITE_API_URL=http://localhost:3000/api" | Out-File -Encoding UTF8 .env
cd ..
```
---
## 🗄️ Запуск MongoDB
### Вариант 1: MongoDB как сервис Windows
1. Запустите MongoDB из меню Пуск или через службы:
```cmd
net start MongoDB
```
2. Проверьте подключение:
```cmd
mongosh
REM Должно подключиться к mongodb://localhost:27017
```
### Вариант 2: MongoDB в Docker
```cmd
docker run -d -p 27017:27017 --name mongodb-nakama mongo:latest
```
### Вариант 3: MongoDB вручную
Если MongoDB не установлен как сервис:
```cmd
REM Перейдите в папку MongoDB
cd "C:\Program Files\MongoDB\Server\7.0\bin"
REM Создайте папку для данных (если не существует)
if not exist C:\data\db mkdir C:\data\db
REM Запустите MongoDB
mongod --dbpath C:\data\db
```
---
## 🚀 Запуск приложения
### 1. Запуск Backend
В корневой папке проекта:
```cmd
npm run server
```
Или для автоматической перезагрузки при изменениях:
```cmd
npm run dev
```
Backend будет доступен на `http://localhost:3000`
### 2. Запуск Frontend
Откройте **новое окно Command Prompt** (Win+R → `cmd`):
```cmd
cd C:\путь\к\проекту\nakama\frontend
npm run dev
```
Frontend будет доступен на `http://localhost:5173`
### 3. Запуск обоих одновременно
Из корневой папки:
```cmd
npm run dev
```
Эта команда запустит и backend, и frontend одновременно.
---
## 🔓 Отключение проверки initData для разработки
Для тестирования без Telegram Mini App нужно временно отключить проверку initData.
### Метод 1: Переменная окружения (Рекомендуется)
1. Добавьте в `.env`:
```env
DISABLE_TELEGRAM_AUTH=true
DEV_USER_ID=123456789
```
2. Создайте файл `backend/middleware/devAuth.js`:
```javascript
// Middleware для разработки без Telegram
const User = require('../models/User');
const devAuthenticate = async (req, res, next) => {
// Включено только если DISABLE_TELEGRAM_AUTH=true
if (process.env.DISABLE_TELEGRAM_AUTH !== 'true') {
return require('./auth').authenticate(req, res, next);
}
console.log('⚠️ DEV MODE: Telegram auth disabled');
try {
const devUserId = process.env.DEV_USER_ID || '123456789';
// Найти или создать тестового пользователя
let user = await User.findOne({ telegramId: devUserId });
if (!user) {
user = new User({
telegramId: devUserId,
username: 'DevUser',
firstName: 'Dev',
lastName: 'User',
photoUrl: null
});
await user.save();
console.log('✅ Created dev user:', user.username);
}
// Инициализировать настройки
if (!user.settings) {
user.settings = {
searchPreference: 'furry',
whitelist: { noNSFW: false, noHomo: false }
};
await user.save();
}
req.user = user;
req.telegramUser = {
id: user.telegramId,
username: user.username,
firstName: user.firstName,
lastName: user.lastName
};
next();
} catch (error) {
console.error('❌ Dev auth error:', error);
res.status(500).json({ error: 'Dev auth error' });
}
};
module.exports = { devAuthenticate };
```
3. Обновите `backend/routes/*.js` (все роуты с авторизацией):
```javascript
// Было:
const { authenticate } = require('../middleware/auth');
// Стало:
const { authenticate } = require('../middleware/auth');
const { devAuthenticate } = require('../middleware/devAuth');
// Используйте devAuthenticate вместо authenticate:
const authMiddleware = process.env.DISABLE_TELEGRAM_AUTH === 'true'
? devAuthenticate
: authenticate;
router.get('/', authMiddleware, async (req, res) => {
// ...
});
```
### Метод 2: Модификация auth.js (Быстрый способ)
Отредактируйте `backend/middleware/auth.js`:
```javascript
const authenticate = async (req, res, next) => {
// 🔥 DEV MODE: Skip Telegram validation
if (process.env.DISABLE_TELEGRAM_AUTH === 'true') {
console.log('⚠️ DEV MODE: Skipping Telegram auth');
const devUserId = process.env.DEV_USER_ID || '123456789';
let user = await User.findOne({ telegramId: devUserId });
if (!user) {
user = new User({
telegramId: devUserId,
username: 'DevUser',
firstName: 'Dev',
lastName: 'User'
});
await user.save();
}
req.user = user;
req.telegramUser = {
id: user.telegramId,
username: user.username,
firstName: user.firstName,
lastName: user.lastName
};
return next();
}
// Остальной код без изменений...
try {
const authHeader = req.headers.authorization || '';
// ... существующий код ...
```
### Метод 3: Mock Telegram WebApp
Для фронтенда создайте файл `frontend\src\utils\mockTelegram.js`:
```javascript
// Mock Telegram WebApp для разработки
export const mockTelegram = () => {
if (window.Telegram?.WebApp) return; // Уже есть
const mockInitData = 'query_id=mock&user=%7B%22id%22%3A123456789%2C%22first_name%22%3A%22Dev%22%2C%22last_name%22%3A%22User%22%2C%22username%22%3A%22devuser%22%7D&auth_date=1234567890&hash=mockhash';
window.Telegram = {
WebApp: {
initData: mockInitData,
initDataUnsafe: {
query_id: 'mock',
user: {
id: 123456789,
first_name: 'Dev',
last_name: 'User',
username: 'devuser'
},
auth_date: 1234567890,
hash: 'mockhash'
},
version: '6.0',
platform: 'web',
colorScheme: 'light',
themeParams: {},
isExpanded: true,
viewportHeight: 600,
viewportStableHeight: 600,
isClosingConfirmationEnabled: false,
headerColor: '#ffffff',
backgroundColor: '#ffffff',
BackButton: { isVisible: false },
MainButton: { isVisible: false },
ready: () => console.log('Mock Telegram ready'),
expand: () => console.log('Mock Telegram expand'),
close: () => console.log('Mock Telegram close'),
disableVerticalSwipes: () => {},
enableClosingConfirmation: () => {},
disableClosingConfirmation: () => {}
}
};
console.log('✅ Mock Telegram WebApp initialized');
};
```
Импортируйте и используйте в `frontend/src/App.jsx`:
```javascript
// В начале файла
import { mockTelegram } from './utils/mockTelegram'
// В useEffect перед initTelegramApp()
useEffect(() => {
// Mock Telegram для разработки
if (import.meta.env.DEV && !window.Telegram?.WebApp) {
mockTelegram()
}
initTheme()
// ...
}, [])
```
---
## 🧪 Тестирование без Telegram
### 1. Настройте среду разработки
В `.env`:
```env
DISABLE_TELEGRAM_AUTH=true
DEV_USER_ID=123456789
NODE_ENV=development
```
### 2. Запустите приложение
**Окно CMD 1 - Backend:**
```cmd
npm run server
```
**Окно CMD 2 - Frontend:**
```cmd
cd frontend
npm run dev
```
### 3. Откройте браузер
Перейдите на `http://localhost:5173`
Приложение должно работать без Telegram Mini App!
### 4. Тестирование API напрямую
Используйте **Postman**, **Thunder Client** или **curl** (если установлен):
```cmd
REM Получить посты
curl http://localhost:3000/api/posts
REM Создать пост (с mock auth) - для PowerShell
```
**Для PowerShell:**
```powershell
# Получить посты
Invoke-RestMethod -Uri http://localhost:3000/api/posts -Method Get
# Создать пост
$body = @{
content = "Test post"
tags = @("furry", "art")
} | ConvertTo-Json
Invoke-RestMethod -Uri http://localhost:3000/api/posts -Method Post -Body $body -ContentType "application/json"
```
**Рекомендуется использовать Postman или Thunder Client для удобства.**
---
## 📝 Полезные команды
### npm скрипты
```cmd
REM Backend
npm run server & REM Запустить backend
npm run dev & REM Backend + Frontend одновременно
REM Frontend
cd frontend
npm run dev & REM Запустить frontend dev server
npm run build & REM Собрать production build
npm run preview & REM Preview production build
```
### MongoDB
```cmd
REM Подключиться к базе
mongosh mongodb://localhost:27017/nakama-dev
```
Внутри mongosh:
```javascript
// Показать базы
show dbs
// Использовать базу
use nakama-dev
// Показать коллекции
show collections
// Найти пользователей
db.users.find().pretty()
// Очистить посты
db.posts.deleteMany({})
```
### Остановка процессов
```cmd
REM Найти процесс на порту
netstat -ano | findstr :3000
netstat -ano | findstr :5173
REM Убить процесс по PID (замените <PID> на номер процесса)
taskkill /PID <PID> /F
REM Пример:
taskkill /PID 12345 /F
```
---
## 🔧 Устранение проблем
### Ошибка: "MongoDB connection failed"
**Решение:**
1. Проверьте, что MongoDB запущен:
```cmd
net start MongoDB
```
или
```cmd
mongosh
```
2. Проверьте `MONGODB_URI` в `.env`:
```env
MONGODB_URI=mongodb://localhost:27017/nakama-dev
```
### Ошибка: "Port 3000 already in use"
**Решение:**
```cmd
REM Найти процесс
netstat -ano | findstr :3000
REM Убить процесс (замените <PID> на номер из вывода выше)
taskkill /PID <PID> /F
REM Или измените порт в .env
REM PORT=3001
```
### Ошибка: "Cannot find module 'adm-zip'"
**Решение:**
```cmd
npm install adm-zip
```
### Frontend не подключается к Backend
**Решение:**
1. Проверьте `VITE_API_URL` в `frontend\.env`:
```env
VITE_API_URL=http://localhost:3000/api
```
2. Проверьте CORS в backend `.env`:
```env
CORS_ORIGIN=*
```
3. Перезапустите frontend:
```cmd
cd frontend
npm run dev
```
### Ошибка: "Требуется авторизация"
**Решение:**
1. Убедитесь, что `DISABLE_TELEGRAM_AUTH=true` в `.env`
2. Реализуйте devAuth middleware (см. выше)
3. Перезапустите backend
### Медленная работа на Windows
**Решение:**
1. Добавьте папки в исключения антивируса:
- `node_modules`
- Папка проекта целиком
2. Используйте WSL2 (Windows Subsystem for Linux) для лучшей производительности:
```cmd
wsl --install
```
Перезагрузите компьютер после установки, затем установите проект в WSL.
3. Очистите кеш npm:
```cmd
npm cache clean --force
```
---
## 🎯 Чек-лист запуска
- [ ] Node.js установлен (v18+)
- [ ] MongoDB запущен
- [ ] `.env` создан и настроен
- [ ] `npm install` выполнен в корне проекта
- [ ] `npm install` выполнен в `frontend\`
- [ ] `npm install adm-zip` выполнен
- [ ] `DISABLE_TELEGRAM_AUTH=true` в `.env` (для разработки)
- [ ] Backend запущен (`npm run server`)
- [ ] Frontend запущен (`cd frontend && npm run dev`)
- [ ] Браузер открыт на `http://localhost:5173`
- [ ] Приложение работает!
---
## 📚 Дополнительные ресурсы
- [Node.js документация](https://nodejs.org/docs/)
- [MongoDB документация](https://www.mongodb.com/docs/)
- [Vite документация](https://vitejs.dev/)
- [React документация](https://react.dev/)
- [Express.js документация](https://expressjs.com/)
---
## 🆘 Поддержка
Если возникли проблемы:
1. Проверьте логи в консоли backend и frontend
2. Проверьте `.env` файлы
3. Очистите `node_modules` и переустановите:
```cmd
REM Удалить node_modules
rmdir /s /q node_modules
rmdir /s /q frontend\node_modules
REM Переустановить зависимости
npm install
cd frontend
npm install
cd ..
```
4. Создайте issue в репозитории
---
**Удачной разработки! 🚀**

View File

@ -294,9 +294,68 @@ async function handleWebAppData(userId, dataString) {
}
}
// Отправить аудио трек пользователю
async function sendAudioToUser(userId, audioUrl, metadata = {}) {
if (!TELEGRAM_API) {
throw new Error('TELEGRAM_BOT_TOKEN не установлен');
}
if (!userId || (typeof userId !== 'number' && typeof userId !== 'string')) {
throw new Error('userId должен быть числом или строкой');
}
if (!audioUrl || typeof audioUrl !== 'string') {
throw new Error('audioUrl обязателен и должен быть строкой');
}
try {
let finalAudioUrl = audioUrl;
// Если это относительный URL (локальный файл), используем публичный URL
if (finalAudioUrl.startsWith('/')) {
const baseUrl = process.env.FRONTEND_URL || process.env.API_URL || 'https://nakama.glpshchn.ru';
finalAudioUrl = `${baseUrl}${finalAudioUrl}`;
}
console.log('Отправка аудио:', {
userId,
audioUrl: finalAudioUrl,
metadata
});
// Формируем caption
const caption = metadata.title
? `🎵 <b>${metadata.title}</b>\n👤 ${metadata.artist || 'Unknown'}\n\n<i>Из Nakama Music</i>`
: 'Из Nakama Music';
// Отправляем аудио
const response = await axios.post(`${TELEGRAM_API}/sendAudio`, {
chat_id: userId,
audio: finalAudioUrl,
caption: caption,
parse_mode: 'HTML',
title: metadata.title || 'Unknown Track',
performer: metadata.artist || 'Unknown Artist',
duration: metadata.duration || undefined
});
console.log('Аудио успешно отправлено:', response.data);
return response.data;
} catch (error) {
console.error('Ошибка отправки аудио:', {
error: error.response?.data || error.message,
userId,
audioUrl
});
throw error;
}
}
module.exports = {
sendPhotoToUser,
sendPhotosToUser,
sendAudioToUser,
handleWebAppData
};

54
backend/models/Album.js Normal file
View File

@ -0,0 +1,54 @@
const mongoose = require('mongoose');
const albumSchema = new mongoose.Schema({
title: {
type: String,
required: true,
trim: true,
index: true
},
artist: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Artist',
required: true,
index: true
},
coverImage: {
type: String,
default: null
},
year: {
type: Number,
default: null
},
genre: {
type: String,
default: ''
},
description: {
type: String,
default: ''
},
// Статистика
stats: {
tracks: { type: Number, default: 0 },
duration: { type: Number, default: 0 }, // в секундах
plays: { type: Number, default: 0 }
},
// Кто добавил альбом
addedBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
}
}, {
timestamps: true
});
// Индексы для поиска
albumSchema.index({ title: 'text' });
albumSchema.index({ artist: 1, createdAt: -1 });
albumSchema.index({ createdAt: -1 });
module.exports = mongoose.model('Album', albumSchema);

54
backend/models/Artist.js Normal file
View File

@ -0,0 +1,54 @@
const mongoose = require('mongoose');
const artistSchema = new mongoose.Schema({
name: {
type: String,
required: true,
trim: true,
index: true
},
// Нормализованное имя для поиска (lowercase, no spaces)
normalizedName: {
type: String,
required: true,
lowercase: true,
index: true
},
description: {
type: String,
default: ''
},
coverImage: {
type: String,
default: null
},
// Статистика
stats: {
albums: { type: Number, default: 0 },
tracks: { type: Number, default: 0 },
plays: { type: Number, default: 0 }
},
// Кто добавил исполнителя
addedBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
}
}, {
timestamps: true
});
// Индексы для поиска
artistSchema.index({ name: 'text', normalizedName: 'text' });
artistSchema.index({ createdAt: -1 });
// Хук для автоматического создания normalizedName
artistSchema.pre('save', function(next) {
if (this.isModified('name')) {
this.normalizedName = this.name.toLowerCase().replace(/\s+/g, '');
}
next();
});
module.exports = mongoose.model('Artist', artistSchema);

View File

@ -0,0 +1,25 @@
const mongoose = require('mongoose');
const favoriteTrackSchema = new mongoose.Schema({
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true
},
track: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Track',
required: true,
index: true
}
}, {
timestamps: true
});
// Составной индекс для быстрого поиска и предотвращения дублей
favoriteTrackSchema.index({ user: 1, track: 1 }, { unique: true });
favoriteTrackSchema.index({ createdAt: -1 });
module.exports = mongoose.model('FavoriteTrack', favoriteTrackSchema);

View File

@ -46,6 +46,12 @@ const PostSchema = new mongoose.Schema({
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
}],
// Прикрепленный музыкальный трек
attachedTrack: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Track',
default: null
},
isNSFW: {
type: Boolean,
default: false

76
backend/models/Track.js Normal file
View File

@ -0,0 +1,76 @@
const mongoose = require('mongoose');
const trackSchema = new mongoose.Schema({
title: {
type: String,
required: true,
trim: true,
index: true
},
artist: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Artist',
required: true,
index: true
},
album: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Album',
default: null,
index: true
},
// URL файла (хранится в MinIO или локально)
fileUrl: {
type: String,
required: true
},
// Метаданные файла
file: {
size: { type: Number, required: true }, // в байтах
mimeType: { type: String, required: true },
duration: { type: Number, default: 0 } // в секундах
},
// Обложка трека (если отличается от обложки альбома)
coverImage: {
type: String,
default: null
},
// Порядковый номер в альбоме
trackNumber: {
type: Number,
default: 0
},
// Метаданные
year: {
type: Number,
default: null
},
genre: {
type: String,
default: ''
},
// Статистика
stats: {
plays: { type: Number, default: 0 },
favorites: { type: Number, default: 0 },
downloads: { type: Number, default: 0 }
},
// Кто добавил трек
addedBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
}
}, {
timestamps: true
});
// Индексы для поиска
trackSchema.index({ title: 'text' });
trackSchema.index({ artist: 1, createdAt: -1 });
trackSchema.index({ album: 1, trackNumber: 1 });
trackSchema.index({ createdAt: -1 });
trackSchema.index({ 'stats.plays': -1 });
module.exports = mongoose.model('Track', trackSchema);

View File

@ -1,7 +1,8 @@
const express = require('express');
const router = express.Router();
const { sendPhotoToUser, sendPhotosToUser } = require('../bot');
const { sendPhotoToUser, sendPhotosToUser, sendAudioToUser } = require('../bot');
const { authenticate } = require('../middleware/auth');
const Track = require('../models/Track');
// Endpoint для отправки одного фото в ЛС
router.post('/send-photo', authenticate, async (req, res) => {
@ -75,6 +76,60 @@ router.post('/send-photos', authenticate, async (req, res) => {
}
});
// Endpoint для отправки трека в ЛС
router.post('/send-track', authenticate, async (req, res) => {
try {
const { userId, trackId } = req.body;
if (!userId || !trackId) {
return res.status(400).json({ error: 'userId и trackId обязательны' });
}
// Преобразуем userId в число
const telegramUserId = typeof userId === 'string' ? parseInt(userId, 10) : userId;
if (isNaN(telegramUserId)) {
return res.status(400).json({ error: 'userId должен быть числом' });
}
// Получить трек из базы
const track = await Track.findById(trackId).populate('artist album');
if (!track) {
return res.status(404).json({ error: 'Трек не найден' });
}
// Отправить трек
const result = await sendAudioToUser(telegramUserId, track.fileUrl, {
title: track.title,
artist: track.artist?.name,
duration: track.file?.duration
});
// Увеличить счетчик скачиваний
track.stats.downloads += 1;
await track.save();
res.json({
success: true,
message: 'Трек отправлен в ваш Telegram',
result
});
} catch (error) {
console.error('Ошибка отправки трека:', {
error: error.message,
stack: error.stack,
response: error.response?.data,
userId: req.body?.userId,
trackId: req.body?.trackId
});
res.status(500).json({
error: 'Ошибка отправки трека',
details: error.response?.data?.description || error.message
});
}
});
// Отправить сообщение всем пользователям (только админы)
router.post('/broadcast', authenticate, async (req, res) => {
try {

522
backend/routes/music.js Normal file
View File

@ -0,0 +1,522 @@
const express = require('express');
const router = express.Router();
const multer = require('multer');
const path = require('path');
const fs = require('fs').promises;
const AdmZip = require('adm-zip');
const { authenticate } = require('../middleware/auth');
const Artist = require('../models/Artist');
const Album = require('../models/Album');
const Track = require('../models/Track');
const FavoriteTrack = require('../models/FavoriteTrack');
// Настройка multer для загрузки файлов
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
const uploadDir = path.join(__dirname, '../uploads/music');
try {
await fs.mkdir(uploadDir, { recursive: true });
cb(null, uploadDir);
} catch (error) {
cb(error);
}
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const ext = path.extname(file.originalname);
cb(null, `${uniqueSuffix}${ext}`);
}
});
const upload = multer({
storage,
limits: {
fileSize: 50 * 1024 * 1024 // 50MB для треков
},
fileFilter: (req, file, cb) => {
const allowedMimeTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/mp4', 'application/zip'];
if (allowedMimeTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Неподдерживаемый формат файла'));
}
}
});
// Вспомогательная функция для поиска или создания исполнителя
async function findOrCreateArtist(artistName, userId) {
const normalizedName = artistName.toLowerCase().replace(/\s+/g, '');
let artist = await Artist.findOne({ normalizedName });
if (!artist) {
artist = await Artist.create({
name: artistName,
normalizedName,
addedBy: userId
});
}
return artist;
}
// Загрузка одного трека
router.post('/upload-track', authenticate, upload.single('track'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'Файл не загружен' });
}
const { title, artistName, albumTitle, trackNumber, year, genre } = req.body;
if (!title || !artistName) {
// Удалить загруженный файл
await fs.unlink(req.file.path).catch(() => {});
return res.status(400).json({ error: 'Название трека и исполнитель обязательны' });
}
// Найти или создать исполнителя
const artist = await findOrCreateArtist(artistName, req.user._id);
// Найти или создать альбом (если указан)
let album = null;
if (albumTitle) {
album = await Album.findOne({ title: albumTitle, artist: artist._id });
if (!album) {
album = await Album.create({
title: albumTitle,
artist: artist._id,
year: year ? parseInt(year) : null,
genre: genre || '',
addedBy: req.user._id
});
}
}
// Создать трек
const fileUrl = `/uploads/music/${req.file.filename}`;
const track = await Track.create({
title,
artist: artist._id,
album: album ? album._id : null,
fileUrl,
file: {
size: req.file.size,
mimeType: req.file.mimetype,
duration: 0 // TODO: извлечь из метаданных
},
trackNumber: trackNumber ? parseInt(trackNumber) : 0,
year: year ? parseInt(year) : null,
genre: genre || '',
addedBy: req.user._id
});
// Обновить статистику
artist.stats.tracks += 1;
await artist.save();
if (album) {
album.stats.tracks += 1;
await album.save();
}
// Populate для ответа
await track.populate('artist album');
res.json({
success: true,
track
});
} catch (error) {
console.error('Ошибка загрузки трека:', error);
// Удалить файл в случае ошибки
if (req.file) {
await fs.unlink(req.file.path).catch(() => {});
}
res.status(500).json({ error: 'Ошибка загрузки трека' });
}
});
// Загрузка альбома из ZIP
router.post('/upload-album', authenticate, upload.single('album'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'ZIP файл не загружен' });
}
if (req.file.mimetype !== 'application/zip') {
await fs.unlink(req.file.path).catch(() => {});
return res.status(400).json({ error: 'Требуется ZIP файл' });
}
const { artistName, albumTitle, year, genre } = req.body;
if (!artistName || !albumTitle) {
await fs.unlink(req.file.path).catch(() => {});
return res.status(400).json({ error: 'Исполнитель и название альбома обязательны' });
}
// Распаковать ZIP
const zip = new AdmZip(req.file.path);
const zipEntries = zip.getEntries();
// Фильтровать аудио файлы
const audioExtensions = ['.mp3', '.wav', '.ogg', '.m4a', '.flac'];
const audioFiles = zipEntries.filter(entry => {
const ext = path.extname(entry.entryName).toLowerCase();
return audioExtensions.includes(ext) && !entry.isDirectory;
});
if (audioFiles.length === 0) {
await fs.unlink(req.file.path).catch(() => {});
return res.status(400).json({ error: 'В архиве нет аудио файлов' });
}
// Найти или создать исполнителя
const artist = await findOrCreateArtist(artistName, req.user._id);
// Создать альбом
const album = await Album.create({
title: albumTitle,
artist: artist._id,
year: year ? parseInt(year) : null,
genre: genre || '',
addedBy: req.user._id
});
// Извлечь и сохранить треки
const tracks = [];
const uploadDir = path.join(__dirname, '../uploads/music');
for (let i = 0; i < audioFiles.length; i++) {
const entry = audioFiles[i];
const fileName = path.basename(entry.entryName);
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const ext = path.extname(fileName);
const newFileName = `${uniqueSuffix}${ext}`;
const filePath = path.join(uploadDir, newFileName);
// Извлечь файл
zip.extractEntryTo(entry, uploadDir, false, true, false, newFileName);
// Получить размер файла
const stats = await fs.stat(filePath);
// Создать трек
const track = await Track.create({
title: fileName.replace(ext, ''),
artist: artist._id,
album: album._id,
fileUrl: `/uploads/music/${newFileName}`,
file: {
size: stats.size,
mimeType: 'audio/mpeg', // TODO: определить правильный MIME type
duration: 0
},
trackNumber: i + 1,
year: year ? parseInt(year) : null,
genre: genre || '',
addedBy: req.user._id
});
tracks.push(track);
}
// Удалить ZIP после обработки
await fs.unlink(req.file.path).catch(() => {});
// Обновить статистику
artist.stats.tracks += tracks.length;
artist.stats.albums += 1;
await artist.save();
album.stats.tracks = tracks.length;
await album.save();
// Populate для ответа
await album.populate('artist');
res.json({
success: true,
album,
tracks,
message: `Загружено ${tracks.length} треков`
});
} catch (error) {
console.error('Ошибка загрузки альбома:', error);
if (req.file) {
await fs.unlink(req.file.path).catch(() => {});
}
res.status(500).json({ error: 'Ошибка загрузки альбома' });
}
});
// Поиск треков, исполнителей, альбомов
router.get('/search', authenticate, async (req, res) => {
try {
const { q, type = 'all', limit = 20, page = 1 } = req.query;
if (!q || q.trim().length < 1) {
return res.json({
tracks: [],
artists: [],
albums: []
});
}
const skip = (parseInt(page) - 1) * parseInt(limit);
const limitNum = parseInt(limit);
const searchRegex = new RegExp(q.trim(), 'i');
let tracks = [];
let artists = [];
let albums = [];
if (type === 'all' || type === 'tracks') {
tracks = await Track.find({
title: searchRegex
})
.populate('artist album')
.sort({ 'stats.plays': -1, createdAt: -1 })
.limit(limitNum)
.skip(skip);
}
if (type === 'all' || type === 'artists') {
artists = await Artist.find({
name: searchRegex
})
.sort({ 'stats.tracks': -1, createdAt: -1 })
.limit(limitNum)
.skip(skip);
}
if (type === 'all' || type === 'albums') {
albums = await Album.find({
title: searchRegex
})
.populate('artist')
.sort({ 'stats.tracks': -1, createdAt: -1 })
.limit(limitNum)
.skip(skip);
}
res.json({
tracks,
artists,
albums
});
} catch (error) {
console.error('Ошибка поиска:', error);
res.status(500).json({ error: 'Ошибка поиска' });
}
});
// Получить список треков
router.get('/tracks', authenticate, async (req, res) => {
try {
const { limit = 50, page = 1, artistId, albumId } = req.query;
const skip = (parseInt(page) - 1) * parseInt(limit);
const limitNum = parseInt(limit);
const query = {};
if (artistId) query.artist = artistId;
if (albumId) query.album = albumId;
const tracks = await Track.find(query)
.populate('artist album')
.sort({ createdAt: -1 })
.limit(limitNum)
.skip(skip);
const total = await Track.countDocuments(query);
res.json({
tracks,
total,
page: parseInt(page),
pages: Math.ceil(total / limitNum)
});
} catch (error) {
console.error('Ошибка получения треков:', error);
res.status(500).json({ error: 'Ошибка получения треков' });
}
});
// Получить трек по ID
router.get('/tracks/:trackId', authenticate, async (req, res) => {
try {
const track = await Track.findById(req.params.trackId)
.populate('artist album');
if (!track) {
return res.status(404).json({ error: 'Трек не найден' });
}
res.json({ track });
} catch (error) {
console.error('Ошибка получения трека:', error);
res.status(500).json({ error: 'Ошибка получения трека' });
}
});
// Получить альбом по ID с треками
router.get('/albums/:albumId', authenticate, async (req, res) => {
try {
const album = await Album.findById(req.params.albumId)
.populate('artist');
if (!album) {
return res.status(404).json({ error: 'Альбом не найден' });
}
const tracks = await Track.find({ album: album._id })
.populate('artist')
.sort({ trackNumber: 1 });
res.json({ album, tracks });
} catch (error) {
console.error('Ошибка получения альбома:', error);
res.status(500).json({ error: 'Ошибка получения альбома' });
}
});
// Добавить трек в избранное
router.post('/favorites/:trackId', authenticate, async (req, res) => {
try {
const track = await Track.findById(req.params.trackId);
if (!track) {
return res.status(404).json({ error: 'Трек не найден' });
}
// Проверить, не добавлен ли уже
const existing = await FavoriteTrack.findOne({
user: req.user._id,
track: track._id
});
if (existing) {
return res.status(400).json({ error: 'Трек уже в избранном' });
}
await FavoriteTrack.create({
user: req.user._id,
track: track._id
});
// Обновить счетчик
track.stats.favorites += 1;
await track.save();
res.json({ success: true, message: 'Трек добавлен в избранное' });
} catch (error) {
console.error('Ошибка добавления в избранное:', error);
res.status(500).json({ error: 'Ошибка добавления в избранное' });
}
});
// Удалить трек из избранного
router.delete('/favorites/:trackId', authenticate, async (req, res) => {
try {
const favorite = await FavoriteTrack.findOneAndDelete({
user: req.user._id,
track: req.params.trackId
});
if (!favorite) {
return res.status(404).json({ error: 'Трек не найден в избранном' });
}
// Обновить счетчик
const track = await Track.findById(req.params.trackId);
if (track) {
track.stats.favorites = Math.max(0, track.stats.favorites - 1);
await track.save();
}
res.json({ success: true, message: 'Трек удален из избранного' });
} catch (error) {
console.error('Ошибка удаления из избранного:', error);
res.status(500).json({ error: 'Ошибка удаления из избранного' });
}
});
// Получить избранные треки пользователя
router.get('/favorites', authenticate, async (req, res) => {
try {
const { limit = 50, page = 1 } = req.query;
const skip = (parseInt(page) - 1) * parseInt(limit);
const limitNum = parseInt(limit);
const favorites = await FavoriteTrack.find({ user: req.user._id })
.populate({
path: 'track',
populate: { path: 'artist album' }
})
.sort({ createdAt: -1 })
.limit(limitNum)
.skip(skip);
const total = await FavoriteTrack.countDocuments({ user: req.user._id });
const tracks = favorites.map(f => f.track).filter(t => t); // Фильтр на случай удаленных треков
res.json({
tracks,
total,
page: parseInt(page),
pages: Math.ceil(total / limitNum)
});
} catch (error) {
console.error('Ошибка получения избранного:', error);
res.status(500).json({ error: 'Ошибка получения избранного' });
}
});
// Увеличить счетчик прослушиваний
router.post('/tracks/:trackId/play', authenticate, async (req, res) => {
try {
const track = await Track.findById(req.params.trackId);
if (!track) {
return res.status(404).json({ error: 'Трек не найден' });
}
track.stats.plays += 1;
await track.save();
// Также обновить статистику исполнителя
const artist = await Artist.findById(track.artist);
if (artist) {
artist.stats.plays += 1;
await artist.save();
}
// И альбома
if (track.album) {
const album = await Album.findById(track.album);
if (album) {
album.stats.plays += 1;
await album.save();
}
}
res.json({ success: true });
} catch (error) {
console.error('Ошибка обновления счетчика:', error);
res.status(500).json({ error: 'Ошибка обновления счетчика' });
}
});
module.exports = router;

View File

@ -21,6 +21,10 @@ router.get('/:id', authenticate, async (req, res) => {
.populate('author', 'username firstName lastName photoUrl')
.populate('mentionedUsers', 'username firstName lastName')
.populate('comments.author', 'username firstName lastName photoUrl')
.populate({
path: 'attachedTrack',
populate: { path: 'artist album' }
})
.exec();
if (!post) {
@ -100,6 +104,10 @@ router.get('/', authenticate, async (req, res) => {
let posts = await Post.find(query)
.populate('author', 'username firstName lastName photoUrl')
.populate({
path: 'attachedTrack',
populate: { path: 'artist album' }
})
.populate('mentionedUsers', 'username firstName lastName')
.populate('comments.author', 'username firstName lastName photoUrl')
.sort({ createdAt: -1 })
@ -126,7 +134,7 @@ router.get('/', authenticate, async (req, res) => {
// Создать пост
router.post('/', authenticate, strictPostLimiter, postCreationLimiter, fileUploadLimiter, uploadPostImages, async (req, res) => {
try {
const { content, tags, mentionedUsers, isNSFW, isHomo, externalImages } = req.body;
const { content, tags, mentionedUsers, isNSFW, isHomo, externalImages, attachedTrackId } = req.body;
// Валидация контента
if (content && !validatePostContent(content)) {
@ -198,6 +206,7 @@ router.post('/', authenticate, strictPostLimiter, postCreationLimiter, fileUploa
tags: parsedTags,
hashtags,
mentionedUsers: mentionedUsers ? JSON.parse(mentionedUsers) : [],
attachedTrack: attachedTrackId || null,
isNSFW: isNSFW === 'true',
// Флаг гомосексуального контента - полный аналог NSFW по логике,
// но управляется отдельно

View File

@ -253,6 +253,7 @@ app.use('/api/mod-app', require('./routes/modApp'));
app.use('/api/moderation-auth', require('./routes/moderationAuth'));
app.use('/api/minio', require('./routes/minio-test'));
app.use('/api/tags', require('./routes/tags'));
app.use('/api/music', require('./routes/music'));
// Базовый роут
app.get('/', (req, res) => {

0
backup-cron.sh Executable file → Normal file
View File

View File

@ -4,15 +4,21 @@ import { initTelegramApp } from './utils/telegram'
import { verifyAuth } from './utils/api'
import { initTheme } from './utils/theme'
import { startInitDataChecker, stopInitDataChecker } from './utils/initDataChecker'
import { MusicPlayerProvider } from './contexts/MusicPlayerContext'
import Layout from './components/Layout'
import Feed from './pages/Feed'
import Search from './pages/Search'
import Media from './pages/Media'
import MediaFurry from './pages/MediaFurry'
import MediaAnime from './pages/MediaAnime'
import MediaMusic from './pages/MediaMusic'
import Notifications from './pages/Notifications'
import Profile from './pages/Profile'
import UserProfile from './pages/UserProfile'
import CommentsPage from './pages/CommentsPage'
import PostMenuPage from './pages/PostMenuPage'
import MonthlyLadder from './pages/MonthlyLadder'
import MiniPlayer from './components/MiniPlayer'
import FullPlayer from './components/FullPlayer'
import './styles/index.css'
function AppContent() {
@ -196,7 +202,10 @@ function AppContent() {
<Route path="/" element={<Layout user={user} />}>
<Route index element={<Navigate to="/feed" replace />} />
<Route path="feed" element={<Feed user={user} />} />
<Route path="search" element={<Search user={user} />} />
<Route path="media" element={<Media user={user} />} />
<Route path="media/furry" element={<MediaFurry user={user} />} />
<Route path="media/anime" element={<MediaAnime user={user} />} />
<Route path="media/music" element={<MediaMusic user={user} />} />
<Route path="notifications" element={<Notifications user={user} />} />
<Route path="profile" element={<Profile user={user} setUser={setUser} />} />
<Route path="user/:id" element={<UserProfile currentUser={user} />} />
@ -211,7 +220,11 @@ function AppContent() {
function App() {
return (
<BrowserRouter>
<AppContent />
<MusicPlayerProvider>
<AppContent />
<MiniPlayer />
<FullPlayer />
</MusicPlayerProvider>
</BrowserRouter>
)
}

View File

@ -0,0 +1,308 @@
.full-player {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--bg-primary);
z-index: 100;
display: flex;
flex-direction: column;
animation: slideUp 0.3s ease-out;
}
.full-player-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--divider-color);
}
.full-player-close {
background: none;
border: none;
color: var(--text-primary);
cursor: pointer;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s;
}
.full-player-close:active {
transform: scale(0.9);
}
.full-player-queue-info {
font-size: 14px;
color: var(--text-secondary);
}
.full-player-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
gap: 24px;
overflow-y: auto;
}
.full-player-cover {
width: 280px;
height: 280px;
border-radius: 16px;
background: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
box-shadow: 0 8px 24px var(--shadow-lg);
}
.full-player-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.full-player-cover svg {
color: var(--text-secondary);
}
.full-player-info {
text-align: center;
width: 100%;
max-width: 400px;
}
.full-player-title {
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 8px 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.full-player-artist {
font-size: 18px;
color: var(--text-secondary);
margin: 0 0 4px 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.full-player-album {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
opacity: 0.7;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.full-player-progress-section {
width: 100%;
max-width: 400px;
}
.full-player-progress-bar {
width: 100%;
height: 6px;
background: var(--divider-color);
border-radius: 3px;
cursor: pointer;
position: relative;
overflow: hidden;
}
.full-player-progress-fill {
height: 100%;
background: #9b59b6;
border-radius: 3px;
transition: width 0.1s linear;
}
.full-player-time {
display: flex;
justify-content: space-between;
margin-top: 8px;
font-size: 12px;
color: var(--text-secondary);
}
.full-player-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
}
.full-player-control-btn {
background: none;
border: none;
color: var(--text-primary);
cursor: pointer;
padding: 12px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s;
}
.full-player-control-btn:hover {
background: var(--bg-secondary);
}
.full-player-control-btn:active {
transform: scale(0.9);
}
.full-player-control-btn:disabled {
cursor: not-allowed;
}
.full-player-play-btn {
background: #9b59b6;
border: none;
color: white;
cursor: pointer;
width: 72px;
height: 72px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 16px rgba(155, 89, 182, 0.4);
transition: all 0.2s;
}
.full-player-play-btn:hover {
transform: scale(1.05);
box-shadow: 0 6px 20px rgba(155, 89, 182, 0.5);
}
.full-player-play-btn:active {
transform: scale(0.95);
}
.full-player-actions {
display: flex;
gap: 16px;
justify-content: center;
}
.full-player-action-btn {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 12px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s;
}
.full-player-action-btn:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
.full-player-action-btn:active {
transform: scale(0.9);
}
.full-player-action-btn.active {
color: #e74c3c;
}
.full-player-volume {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: var(--bg-secondary);
border-radius: 12px;
width: 100%;
max-width: 300px;
}
.full-player-volume button {
background: none;
border: none;
color: var(--text-primary);
cursor: pointer;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.volume-slider {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 6px;
background: var(--divider-color);
border-radius: 3px;
outline: none;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
background: #9b59b6;
border-radius: 50%;
cursor: pointer;
}
.volume-slider::-moz-range-thumb {
width: 16px;
height: 16px;
background: #9b59b6;
border-radius: 50%;
cursor: pointer;
border: none;
}
.full-player-volume span {
font-size: 14px;
color: var(--text-secondary);
min-width: 40px;
text-align: right;
}
/* Адаптив */
@media (max-width: 400px) {
.full-player-cover {
width: 240px;
height: 240px;
}
.full-player-title {
font-size: 20px;
}
.full-player-artist {
font-size: 16px;
}
.full-player-play-btn {
width: 64px;
height: 64px;
}
}

View File

@ -0,0 +1,241 @@
import { createPortal } from 'react-dom'
import { X, Play, Pause, SkipBack, SkipForward, Heart, Download, Music, Volume2, VolumeX } from 'lucide-react'
import { useMusicPlayer } from '../contexts/MusicPlayerContext'
import { hapticFeedback, getTelegramUser } from '../utils/telegram'
import { useState } from 'react'
import api from '../utils/api'
import './FullPlayer.css'
export default function FullPlayer() {
const {
currentTrack,
isPlaying,
progress,
duration,
volume,
queue,
currentIndex,
isExpanded,
togglePlay,
playNext,
playPrevious,
seek,
changeVolume,
setIsExpanded
} = useMusicPlayer()
const [isFavorite, setIsFavorite] = useState(false)
const [showVolume, setShowVolume] = useState(false)
if (!isExpanded || !currentTrack) return null
const formatTime = (seconds) => {
if (!seconds || isNaN(seconds)) return '0:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
const progressPercent = duration > 0 ? (progress / duration) * 100 : 0
const handleClose = () => {
hapticFeedback('light')
setIsExpanded(false)
}
const handleTogglePlay = () => {
hapticFeedback('light')
togglePlay()
}
const handleNext = () => {
hapticFeedback('light')
playNext()
}
const handlePrevious = () => {
hapticFeedback('light')
playPrevious()
}
const handleProgressClick = (e) => {
const rect = e.currentTarget.getBoundingClientRect()
const x = e.clientX - rect.left
const percent = x / rect.width
const newTime = percent * duration
seek(newTime)
hapticFeedback('light')
}
const handleToggleFavorite = async () => {
try {
hapticFeedback('light')
// TODO: реализовать добавление/удаление из избранного
setIsFavorite(!isFavorite)
hapticFeedback('success')
} catch (error) {
console.error('Ошибка:', error)
hapticFeedback('error')
}
}
const handleDownload = async () => {
try {
hapticFeedback('light')
const telegramUser = getTelegramUser()
if (telegramUser) {
await api.post('/bot/send-track', {
userId: telegramUser.id,
trackId: currentTrack._id
})
hapticFeedback('success')
alert('✅ Трек отправлен в ваш Telegram!')
} else {
alert('Функция доступна только в Telegram')
}
} catch (error) {
console.error('Ошибка:', error)
hapticFeedback('error')
alert('Ошибка отправки')
}
}
const handleVolumeChange = (e) => {
const newVolume = parseFloat(e.target.value)
changeVolume(newVolume)
}
const toggleMute = () => {
hapticFeedback('light')
if (volume > 0) {
changeVolume(0)
} else {
changeVolume(1)
}
}
const hasNext = currentIndex < queue.length - 1
const hasPrevious = currentIndex > 0
return createPortal(
<div className="full-player">
<div className="full-player-header">
<button className="full-player-close" onClick={handleClose}>
<X size={24} />
</button>
<span className="full-player-queue-info">
{currentIndex + 1} / {queue.length}
</span>
<div style={{ width: '40px' }} />
</div>
<div className="full-player-content">
<div className="full-player-cover">
{currentTrack.coverImage ? (
<img src={currentTrack.coverImage} alt={currentTrack.title} />
) : (
<Music size={80} />
)}
</div>
<div className="full-player-info">
<h2 className="full-player-title">{currentTrack.title}</h2>
<p className="full-player-artist">{currentTrack.artist?.name || 'Unknown Artist'}</p>
{currentTrack.album && (
<p className="full-player-album">{currentTrack.album.title}</p>
)}
</div>
<div className="full-player-progress-section">
<div className="full-player-progress-bar" onClick={handleProgressClick}>
<div
className="full-player-progress-fill"
style={{ width: `${progressPercent}%` }}
/>
</div>
<div className="full-player-time">
<span>{formatTime(progress)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
<div className="full-player-controls">
<button
className="full-player-control-btn"
onClick={handlePrevious}
disabled={!hasPrevious}
style={{ opacity: hasPrevious ? 1 : 0.3 }}
>
<SkipBack size={32} />
</button>
<button
className="full-player-play-btn"
onClick={handleTogglePlay}
>
{isPlaying ? (
<Pause size={40} fill="currentColor" />
) : (
<Play size={40} fill="currentColor" />
)}
</button>
<button
className="full-player-control-btn"
onClick={handleNext}
disabled={!hasNext}
style={{ opacity: hasNext ? 1 : 0.3 }}
>
<SkipForward size={32} />
</button>
</div>
<div className="full-player-actions">
<button
className={`full-player-action-btn ${isFavorite ? 'active' : ''}`}
onClick={handleToggleFavorite}
>
<Heart size={24} fill={isFavorite ? 'currentColor' : 'none'} />
</button>
<button
className="full-player-action-btn"
onClick={() => setShowVolume(!showVolume)}
>
{volume === 0 ? <VolumeX size={24} /> : <Volume2 size={24} />}
</button>
<button
className="full-player-action-btn"
onClick={handleDownload}
>
<Download size={24} />
</button>
</div>
{showVolume && (
<div className="full-player-volume">
<button onClick={toggleMute}>
{volume === 0 ? <VolumeX size={20} /> : <Volume2 size={20} />}
</button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={volume}
onChange={handleVolumeChange}
className="volume-slider"
/>
<span>{Math.round(volume * 100)}%</span>
</div>
)}
</div>
</div>,
document.body
)
}

View File

@ -0,0 +1,104 @@
.mini-player {
position: fixed;
bottom: 60px;
left: 0;
right: 0;
background: var(--bg-secondary);
border-top: 1px solid var(--divider-color);
z-index: 45;
cursor: pointer;
transition: transform 0.3s;
box-shadow: 0 -2px 12px var(--shadow-md);
}
.mini-player:active {
transform: scale(0.98);
}
.mini-player-progress {
position: absolute;
top: 0;
left: 0;
height: 2px;
background: #9b59b6;
transition: width 0.1s linear;
}
.mini-player-content {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
}
.mini-player-cover {
width: 48px;
height: 48px;
border-radius: 8px;
background: var(--bg-primary);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.mini-player-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.mini-player-cover svg {
color: var(--text-secondary);
}
.mini-player-info {
flex: 1;
min-width: 0;
}
.mini-player-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mini-player-artist {
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mini-player-controls {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.mini-player-btn {
background: none;
border: none;
color: var(--text-primary);
cursor: pointer;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s;
}
.mini-player-btn:hover {
background: var(--bg-primary);
}
.mini-player-btn:active {
transform: scale(0.9);
}

View File

@ -0,0 +1,68 @@
import { Play, Pause, SkipForward, Music } from 'lucide-react'
import { useMusicPlayer } from '../contexts/MusicPlayerContext'
import { hapticFeedback } from '../utils/telegram'
import './MiniPlayer.css'
export default function MiniPlayer() {
const {
currentTrack,
isPlaying,
progress,
duration,
togglePlay,
playNext,
toggleExpanded
} = useMusicPlayer()
if (!currentTrack) return null
const progressPercent = duration > 0 ? (progress / duration) * 100 : 0
const handleTogglePlay = (e) => {
e.stopPropagation()
hapticFeedback('light')
togglePlay()
}
const handleNext = (e) => {
e.stopPropagation()
hapticFeedback('light')
playNext()
}
const handleExpand = () => {
hapticFeedback('light')
toggleExpanded()
}
return (
<div className="mini-player" onClick={handleExpand}>
<div className="mini-player-progress" style={{ width: `${progressPercent}%` }} />
<div className="mini-player-content">
<div className="mini-player-cover">
{currentTrack.coverImage ? (
<img src={currentTrack.coverImage} alt={currentTrack.title} />
) : (
<Music size={20} />
)}
</div>
<div className="mini-player-info">
<div className="mini-player-title">{currentTrack.title}</div>
<div className="mini-player-artist">{currentTrack.artist?.name || 'Unknown'}</div>
</div>
<div className="mini-player-controls">
<button className="mini-player-btn" onClick={handleTogglePlay}>
{isPlaying ? <Pause size={24} fill="currentColor" /> : <Play size={24} fill="currentColor" />}
</button>
<button className="mini-player-btn" onClick={handleNext}>
<SkipForward size={24} />
</button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,93 @@
.music-attachment {
display: flex;
align-items: center;
gap: 12px;
background: var(--bg-secondary);
border: 1px solid var(--divider-color);
border-radius: 12px;
padding: 12px;
transition: all 0.2s;
}
.music-attachment:hover {
background: var(--bg-primary);
}
.music-attachment-cover {
width: 48px;
height: 48px;
border-radius: 8px;
background: var(--bg-primary);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.music-attachment-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.music-attachment-cover svg {
color: var(--text-secondary);
}
.music-attachment-info {
flex: 1;
min-width: 0;
}
.music-attachment-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.music-attachment-artist {
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.music-attachment-play,
.music-attachment-remove {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s;
flex-shrink: 0;
}
.music-attachment-play:hover,
.music-attachment-remove:hover {
background: var(--bg-primary);
color: var(--text-primary);
}
.music-attachment-play:active,
.music-attachment-remove:active {
transform: scale(0.9);
}
.music-attachment-play {
color: #9b59b6;
}
.music-attachment-remove {
color: #e74c3c;
}

View File

@ -0,0 +1,50 @@
import { Music, Play, X } from 'lucide-react'
import { useMusicPlayer } from '../contexts/MusicPlayerContext'
import { hapticFeedback } from '../utils/telegram'
import './MusicAttachment.css'
export default function MusicAttachment({ track, onRemove, showRemove = false }) {
const { play } = useMusicPlayer()
if (!track) return null
const handlePlay = (e) => {
e.stopPropagation()
hapticFeedback('light')
play(track, [track])
}
const handleRemove = (e) => {
e.stopPropagation()
hapticFeedback('light')
if (onRemove) onRemove()
}
return (
<div className="music-attachment">
<div className="music-attachment-cover">
{track.coverImage ? (
<img src={track.coverImage} alt={track.title} />
) : (
<Music size={20} />
)}
</div>
<div className="music-attachment-info">
<div className="music-attachment-title">{track.title}</div>
<div className="music-attachment-artist">{track.artist?.name || 'Unknown'}</div>
</div>
<button className="music-attachment-play" onClick={handlePlay}>
<Play size={20} />
</button>
{showRemove && (
<button className="music-attachment-remove" onClick={handleRemove}>
<X size={20} />
</button>
)}
</div>
)
}

View File

@ -1,5 +1,5 @@
import { NavLink } from 'react-router-dom'
import { Home, Search, Bell, User } from 'lucide-react'
import { Home, Layers, Bell, User } from 'lucide-react'
import './Navigation.css'
export default function Navigation() {
@ -14,11 +14,11 @@ export default function Navigation() {
)}
</NavLink>
<NavLink to="/search" className="nav-item">
<NavLink to="/media" className="nav-item">
{({ isActive }) => (
<>
<Search size={24} strokeWidth={isActive ? 2.5 : 2} />
<span>Поиск</span>
<Layers size={24} strokeWidth={isActive ? 2.5 : 2} />
<span>Media</span>
</>
)}
</NavLink>

View File

@ -0,0 +1,235 @@
import { createContext, useContext, useState, useRef, useEffect } from 'react'
import { playTrack as recordPlay } from '../utils/musicApi'
const MusicPlayerContext = createContext()
export const useMusicPlayer = () => {
const context = useContext(MusicPlayerContext)
if (!context) {
throw new Error('useMusicPlayer must be used within MusicPlayerProvider')
}
return context
}
export const MusicPlayerProvider = ({ children }) => {
const [currentTrack, setCurrentTrack] = useState(null)
const [isPlaying, setIsPlaying] = useState(false)
const [progress, setProgress] = useState(0)
const [duration, setDuration] = useState(0)
const [volume, setVolume] = useState(1)
const [queue, setQueue] = useState([])
const [currentIndex, setCurrentIndex] = useState(-1)
const [isExpanded, setIsExpanded] = useState(false)
const audioRef = useRef(null)
const progressInterval = useRef(null)
// Инициализация audio элемента
useEffect(() => {
if (!audioRef.current) {
audioRef.current = new Audio()
audioRef.current.addEventListener('ended', handleTrackEnd)
audioRef.current.addEventListener('loadedmetadata', handleLoadedMetadata)
audioRef.current.addEventListener('error', handleError)
}
return () => {
if (audioRef.current) {
audioRef.current.pause()
audioRef.current.removeEventListener('ended', handleTrackEnd)
audioRef.current.removeEventListener('loadedmetadata', handleLoadedMetadata)
audioRef.current.removeEventListener('error', handleError)
}
if (progressInterval.current) {
clearInterval(progressInterval.current)
}
}
}, [])
// Обновление прогресса
useEffect(() => {
if (isPlaying) {
progressInterval.current = setInterval(() => {
if (audioRef.current) {
setProgress(audioRef.current.currentTime)
}
}, 100)
} else {
if (progressInterval.current) {
clearInterval(progressInterval.current)
}
}
return () => {
if (progressInterval.current) {
clearInterval(progressInterval.current)
}
}
}, [isPlaying])
const handleLoadedMetadata = () => {
if (audioRef.current) {
setDuration(audioRef.current.duration)
}
}
const handleTrackEnd = () => {
// Автоматически играть следующий трек
playNext()
}
const handleError = (error) => {
console.error('Ошибка воспроизведения:', error)
setIsPlaying(false)
}
const play = async (track, trackQueue = []) => {
if (!track) return
try {
// Если это новый трек
if (!currentTrack || currentTrack._id !== track._id) {
setCurrentTrack(track)
// Установить очередь
if (trackQueue.length > 0) {
setQueue(trackQueue)
const index = trackQueue.findIndex(t => t._id === track._id)
setCurrentIndex(index !== -1 ? index : 0)
} else {
setQueue([track])
setCurrentIndex(0)
}
// Загрузить трек
if (audioRef.current) {
audioRef.current.src = track.fileUrl
audioRef.current.volume = volume
await audioRef.current.play()
setIsPlaying(true)
}
// Записать прослушивание
try {
await recordPlay(track._id)
} catch (error) {
console.error('Ошибка записи прослушивания:', error)
}
} else {
// Продолжить воспроизведение текущего трека
if (audioRef.current) {
await audioRef.current.play()
setIsPlaying(true)
}
}
} catch (error) {
console.error('Ошибка воспроизведения:', error)
setIsPlaying(false)
}
}
const pause = () => {
if (audioRef.current) {
audioRef.current.pause()
setIsPlaying(false)
}
}
const togglePlay = () => {
if (isPlaying) {
pause()
} else if (currentTrack) {
play(currentTrack)
}
}
const playNext = () => {
if (queue.length === 0) return
const nextIndex = currentIndex + 1
if (nextIndex < queue.length) {
const nextTrack = queue[nextIndex]
play(nextTrack, queue)
} else {
// Конец очереди - остановить
pause()
setProgress(0)
}
}
const playPrevious = () => {
if (queue.length === 0) return
const prevIndex = currentIndex - 1
if (prevIndex >= 0) {
const prevTrack = queue[prevIndex]
play(prevTrack, queue)
}
}
const seek = (time) => {
if (audioRef.current) {
audioRef.current.currentTime = time
setProgress(time)
}
}
const changeVolume = (newVolume) => {
const vol = Math.max(0, Math.min(1, newVolume))
setVolume(vol)
if (audioRef.current) {
audioRef.current.volume = vol
}
}
const addToQueue = (track) => {
setQueue(prev => [...prev, track])
}
const removeFromQueue = (index) => {
setQueue(prev => prev.filter((_, i) => i !== index))
if (index < currentIndex) {
setCurrentIndex(prev => prev - 1)
}
}
const clearQueue = () => {
setQueue([])
setCurrentIndex(-1)
}
const toggleExpanded = () => {
setIsExpanded(!isExpanded)
}
const value = {
currentTrack,
isPlaying,
progress,
duration,
volume,
queue,
currentIndex,
isExpanded,
play,
pause,
togglePlay,
playNext,
playPrevious,
seek,
changeVolume,
addToQueue,
removeFromQueue,
clearQueue,
toggleExpanded,
setIsExpanded
}
return (
<MusicPlayerContext.Provider value={value}>
{children}
</MusicPlayerContext.Provider>
)
}

View File

@ -0,0 +1,139 @@
.media-page {
min-height: 100vh;
background: var(--bg-primary);
padding-bottom: 80px;
}
.media-header {
position: sticky;
top: 0;
z-index: 10;
background: var(--bg-secondary);
border-bottom: 1px solid var(--divider-color);
padding: 16px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px var(--shadow-sm);
}
.media-header h1 {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
padding: 24px 16px;
max-width: 600px;
margin: 0 auto;
}
.media-card {
aspect-ratio: 1;
background: var(--bg-secondary);
border: none;
border-radius: 20px;
padding: 24px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 12px var(--shadow-md);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
position: relative;
overflow: hidden;
}
.media-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--category-color);
opacity: 0.1;
transition: opacity 0.3s;
border-radius: 20px;
}
.media-card:hover::before {
opacity: 0.15;
}
.media-card:active {
transform: scale(0.95);
box-shadow: 0 2px 6px var(--shadow-sm);
}
.media-card-icon {
color: var(--category-color);
transition: transform 0.3s;
z-index: 1;
}
.media-card:hover .media-card-icon {
transform: scale(1.1);
}
.media-card-title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
z-index: 1;
}
/* Анимация появления */
.media-card {
animation: scaleIn 0.3s ease-out;
}
.media-card:nth-child(1) {
animation-delay: 0.05s;
}
.media-card:nth-child(2) {
animation-delay: 0.1s;
}
.media-card:nth-child(3) {
animation-delay: 0.15s;
}
/* Для тёмной темы */
[data-theme="dark"] .media-card::before {
opacity: 0.15;
}
[data-theme="dark"] .media-card:hover::before {
opacity: 0.2;
}
/* Адаптив для маленьких экранов */
@media (max-width: 400px) {
.media-grid {
grid-template-columns: 1fr;
max-width: 200px;
margin: 0 auto;
}
.media-card {
max-width: 200px;
}
}
/* Адаптив для больших экранов */
@media (min-width: 600px) {
.media-grid {
grid-template-columns: repeat(3, 1fr);
}
}

View File

@ -0,0 +1,65 @@
import { useNavigate } from 'react-router-dom'
import { Image, Music, Palette } from 'lucide-react'
import { hapticFeedback } from '../utils/telegram'
import './Media.css'
export default function Media({ user }) {
const navigate = useNavigate()
const categories = [
{
id: 'furry',
name: 'Furry',
icon: Image,
color: 'var(--tag-furry)',
path: '/media/furry'
},
{
id: 'anime',
name: 'Anime',
icon: Palette,
color: 'var(--tag-anime)',
path: '/media/anime'
},
{
id: 'music',
name: 'Music',
icon: Music,
color: '#9b59b6',
path: '/media/music'
}
]
const handleCategoryClick = (category) => {
hapticFeedback('light')
navigate(category.path)
}
return (
<div className="media-page">
<div className="media-header">
<h1>Media</h1>
</div>
<div className="media-grid">
{categories.map((category) => {
const Icon = category.icon
return (
<button
key={category.id}
className="media-card"
onClick={() => handleCategoryClick(category)}
style={{ '--category-color': category.color }}
>
<div className="media-card-icon">
<Icon size={48} strokeWidth={2} />
</div>
<h2 className="media-card-title">{category.name}</h2>
</button>
)
})}
</div>
</div>
)
}

View File

@ -0,0 +1,526 @@
import { useState, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useNavigate } from 'react-router-dom'
import { Search as SearchIcon, ChevronLeft, ChevronRight, Download, X, Plus, ArrowLeft } from 'lucide-react'
import { searchAnime, getAnimeTags } from '../utils/api'
import { hapticFeedback, getTelegramUser } from '../utils/telegram'
import CreatePostModal from '../components/CreatePostModal'
import api from '../utils/api'
import './MediaSearch.css'
export default function MediaAnime({ user }) {
const navigate = useNavigate()
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [tagSuggestions, setTagSuggestions] = useState([])
const [showTagSuggestions, setShowTagSuggestions] = useState(true)
const [currentPage, setCurrentPage] = useState(1)
const [hasMore, setHasMore] = useState(false)
const [currentIndex, setCurrentIndex] = useState(0)
const [showViewer, setShowViewer] = useState(false)
const [selectedImages, setSelectedImages] = useState([])
const [selectionMode, setSelectionMode] = useState(false)
const [showCreatePost, setShowCreatePost] = useState(false)
const touchStartX = useRef(0)
const touchEndX = useRef(0)
const isVideoUrl = (url = '') => {
if (!url) return false
const clean = url.split('?')[0].toLowerCase()
return clean.endsWith('.mp4') || clean.endsWith('.webm') || clean.endsWith('.mov') || clean.endsWith('.m4v')
}
useEffect(() => {
if (query.length > 1 && showTagSuggestions) {
loadTagSuggestions()
} else {
setTagSuggestions([])
}
}, [query, showTagSuggestions])
const loadTagSuggestions = async () => {
try {
const queryParts = query.trim().split(/\s+/)
const lastTag = queryParts[queryParts.length - 1] || query.trim()
if (!lastTag || lastTag.length < 1) {
setTagSuggestions([])
return
}
const animeTags = await getAnimeTags(lastTag)
if (animeTags && Array.isArray(animeTags)) {
setTagSuggestions(animeTags.slice(0, 10))
}
} catch (error) {
console.error('Ошибка загрузки тегов:', error)
setTagSuggestions([])
}
}
const handleSearch = async (searchQuery = query, page = 1, append = false) => {
if (!searchQuery.trim()) return
try {
if (page === 1) {
setLoading(true)
setResults([])
} else {
setLoadingMore(true)
}
hapticFeedback('light')
setShowTagSuggestions(false)
const animeResults = await searchAnime(searchQuery, { limit: 320, page })
const allResults = Array.isArray(animeResults) ? animeResults : []
const hasMoreResults = allResults.length === 320
if (append) {
setResults(prev => [...prev, ...allResults])
} else {
setResults(allResults)
setCurrentPage(1)
}
setHasMore(hasMoreResults)
setCurrentPage(page)
setTagSuggestions([])
if (allResults.length > 0) {
hapticFeedback('success')
} else if (page === 1) {
hapticFeedback('error')
}
} catch (error) {
console.error('Ошибка поиска:', error)
hapticFeedback('error')
if (page === 1) {
setResults([])
}
} finally {
setLoading(false)
setLoadingMore(false)
}
}
const loadMore = () => {
if (!loadingMore && hasMore && query.trim()) {
handleSearch(query, currentPage + 1, true)
}
}
const handleTagClick = (tagName) => {
const queryParts = query.trim().split(/\s+/)
const existingTags = queryParts.slice(0, -1).filter(t => t.trim())
const newQuery = existingTags.length > 0
? [...existingTags, tagName].join(' ')
: tagName
setQuery(newQuery)
setShowTagSuggestions(false)
handleSearch(newQuery)
}
const openViewer = (index) => {
if (selectionMode) {
toggleImageSelection(index)
} else {
setCurrentIndex(index)
setShowViewer(true)
hapticFeedback('light')
}
}
const toggleImageSelection = (index) => {
const imageId = `${results[index].source}-${results[index].id}`
if (selectedImages.includes(imageId)) {
setSelectedImages(selectedImages.filter(id => id !== imageId))
} else {
setSelectedImages([...selectedImages, imageId])
}
hapticFeedback('light')
}
const toggleSelectionMode = () => {
setSelectionMode(!selectionMode)
setSelectedImages([])
hapticFeedback('light')
}
const handleSendSelected = async () => {
if (selectedImages.length === 0) return
try {
hapticFeedback('light')
const telegramUser = getTelegramUser()
if (telegramUser) {
const selectedPhotos = results.filter((img, index) => {
const imageId = `${img.source}-${img.id}`
return selectedImages.includes(imageId)
})
const photos = selectedPhotos.map(img => ({
url: img.url,
caption: `${img.source} - ${img.id}`
}))
await api.post('/bot/send-photos', {
userId: telegramUser.id,
photos: photos
})
hapticFeedback('success')
alert(`${selectedImages.length} изображений отправлено в ваш Telegram!`)
setSelectedImages([])
setSelectionMode(false)
} else {
alert('Функция доступна только в Telegram')
}
} catch (error) {
console.error('Ошибка:', error)
hapticFeedback('error')
alert('Ошибка отправки')
}
}
const handleNext = () => {
if (currentIndex < results.length - 1) {
setCurrentIndex(currentIndex + 1)
hapticFeedback('light')
}
}
const handlePrev = () => {
if (currentIndex > 0) {
setCurrentIndex(currentIndex - 1)
hapticFeedback('light')
}
}
const handleTouchStart = (e) => {
touchStartX.current = e.touches[0].clientX
}
const handleTouchMove = (e) => {
touchEndX.current = e.touches[0].clientX
}
const handleTouchEnd = () => {
const diff = touchStartX.current - touchEndX.current
const threshold = 50
if (Math.abs(diff) > threshold) {
if (diff > 0) {
handleNext()
} else {
handlePrev()
}
}
}
const handleKeyDown = (e) => {
if (e.key === 'ArrowLeft') {
handlePrev()
} else if (e.key === 'ArrowRight') {
handleNext()
} else if (e.key === 'Escape') {
setShowViewer(false)
}
}
useEffect(() => {
if (showViewer) {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}
}, [showViewer, currentIndex])
const handleDownload = async () => {
const currentImage = results[currentIndex]
if (!currentImage) return
try {
hapticFeedback('light')
const telegramUser = getTelegramUser()
if (telegramUser) {
const caption = `${currentImage.source} - ID: ${currentImage.id}\nТеги: ${currentImage.tags.slice(0, 3).join(', ')}`
await api.post('/bot/send-photo', {
userId: telegramUser.id,
photoUrl: currentImage.url,
caption: caption
})
hapticFeedback('success')
alert('✅ Изображение отправлено в ваш Telegram!')
} else {
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')
alert('Ошибка отправки. Проверьте настройки бота.')
}
}
const handleCreatePost = () => {
const currentImage = results[currentIndex]
setShowViewer(false)
setShowCreatePost(true)
hapticFeedback('light')
}
return (
<div className="media-search-page">
<div className="media-search-header">
<button className="back-btn" onClick={() => navigate('/media')}>
<ArrowLeft size={24} />
</button>
<h1 style={{ color: 'var(--tag-anime)' }}>Anime</h1>
{results.length > 0 && (
<button
className={`selection-toggle ${selectionMode ? 'active' : ''}`}
onClick={toggleSelectionMode}
>
{selectionMode ? 'Отмена' : 'Выбрать'}
</button>
)}
</div>
<div className="search-container">
<div className="search-input-wrapper">
<SearchIcon size={20} className="search-icon" />
<input
type="text"
placeholder="Поиск по тегам..."
value={query}
onChange={e => {
setQuery(e.target.value)
setShowTagSuggestions(true)
}}
onKeyPress={e => {
if (e.key === 'Enter') {
handleSearch()
}
}}
/>
{query && (
<button className="clear-btn" onClick={() => setQuery('')}>
<X size={18} />
</button>
)}
<button
className="search-submit-btn"
onClick={() => handleSearch()}
disabled={!query.trim() || loading}
>
<SearchIcon size={20} />
</button>
</div>
{tagSuggestions.length > 0 && showTagSuggestions && (
<div className="tag-suggestions">
{tagSuggestions.map((tag, index) => (
<button
key={index}
className="tag-suggestion"
onClick={() => handleTagClick(tag.name)}
>
<span className="tag-name">{tag.name}</span>
<span className="tag-count">{tag.count?.toLocaleString()}</span>
</button>
))}
</div>
)}
</div>
<div className="search-results">
{loading ? (
<div className="loading-state">
<div className="spinner" />
<p>Поиск...</p>
</div>
) : results.length === 0 && query ? (
<div className="empty-state">
<p>Ничего не найдено</p>
<span>Попробуйте другие теги</span>
</div>
) : results.length === 0 ? (
<div className="empty-state">
<SearchIcon size={48} color="var(--text-secondary)" />
<p>Введите теги для поиска</p>
<span>Используйте gelbooru теги</span>
</div>
) : (
<>
<div className="results-grid">
{results.map((item, index) => {
const imageId = `${item.source}-${item.id}`
const isSelected = selectedImages.includes(imageId)
return (
<div
key={imageId}
className={`result-item card ${isSelected ? 'selected' : ''}`}
onClick={() => openViewer(index)}
>
<img src={item.preview} alt={`Result ${index}`} />
<div className="result-overlay">
<span className="result-source">{item.source}</span>
<span className="result-rating">{item.rating}</span>
</div>
{selectionMode && (
<div className="selection-checkbox">
{isSelected && <span></span>}
</div>
)}
</div>
)
})}
</div>
{hasMore && !loadingMore && (
<div className="load-more-container">
<button className="load-more-btn" onClick={loadMore}>
Загрузить еще
</button>
</div>
)}
{loadingMore && (
<div className="loading-more">
<div className="spinner" />
<p>Загрузка...</p>
</div>
)}
{selectionMode && selectedImages.length > 0 && (
<div className="send-selected-bar">
<button className="send-selected-btn" onClick={handleSendSelected}>
Отправить в Telegram ({selectedImages.length})
</button>
</div>
)}
</>
)}
</div>
{showViewer && results[currentIndex] && createPortal(
<div className="image-viewer">
<div className="viewer-header">
<button className="viewer-btn" onClick={() => setShowViewer(false)}>
<X size={24} />
</button>
<span className="viewer-counter">
{currentIndex + 1} / {results.length}
</span>
<div className="viewer-actions">
<button className="viewer-btn" onClick={handleCreatePost} title="Создать пост">
<Plus size={24} />
</button>
<button className="viewer-btn" onClick={handleDownload} title="Отправить в ЛС">
<Download size={24} />
</button>
</div>
</div>
<div
className="viewer-content"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onClick={(e) => e.stopPropagation()}
>
{isVideoUrl(results[currentIndex].url) ? (
<video
src={results[currentIndex].url}
controls
autoPlay
loop
muted
playsInline
poster={results[currentIndex].preview}
draggable={false}
/>
) : (
<img
src={results[currentIndex].url}
alt="Full view"
draggable={false}
/>
)}
<div className="swipe-hint">
<ChevronLeft size={20} style={{ opacity: currentIndex > 0 ? 1 : 0.3 }} />
<span>Свайпайте для переключения</span>
<ChevronRight size={20} style={{ opacity: currentIndex < results.length - 1 ? 1 : 0.3 }} />
</div>
</div>
<div className="viewer-nav">
<button
className="nav-btn"
onClick={handlePrev}
disabled={currentIndex === 0}
style={{ opacity: currentIndex === 0 ? 0.3 : 1 }}
>
<ChevronLeft size={32} />
</button>
<button
className="nav-btn"
onClick={handleNext}
disabled={currentIndex === results.length - 1}
style={{ opacity: currentIndex === results.length - 1 ? 0.3 : 1 }}
>
<ChevronRight size={32} />
</button>
</div>
<div className="viewer-info">
<div className="info-tags">
{results[currentIndex].tags.slice(0, 5).map((tag, i) => (
<span key={i} className="info-tag">{tag}</span>
))}
</div>
<div className="info-stats">
<span>Score: {results[currentIndex].score}</span>
<span>Source: {results[currentIndex].source}</span>
</div>
</div>
</div>,
document.body
)}
{showCreatePost && (
<CreatePostModal
user={user}
onClose={() => setShowCreatePost(false)}
onPostCreated={() => {
setShowCreatePost(false)
setShowViewer(false)
}}
initialImage={results[currentIndex]?.url}
/>
)}
</div>
)
}

View File

@ -0,0 +1,526 @@
import { useState, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useNavigate } from 'react-router-dom'
import { Search as SearchIcon, ChevronLeft, ChevronRight, Download, X, Plus, ArrowLeft } from 'lucide-react'
import { searchFurry, getFurryTags } from '../utils/api'
import { hapticFeedback, getTelegramUser } from '../utils/telegram'
import CreatePostModal from '../components/CreatePostModal'
import api from '../utils/api'
import './MediaSearch.css'
export default function MediaFurry({ user }) {
const navigate = useNavigate()
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [tagSuggestions, setTagSuggestions] = useState([])
const [showTagSuggestions, setShowTagSuggestions] = useState(true)
const [currentPage, setCurrentPage] = useState(1)
const [hasMore, setHasMore] = useState(false)
const [currentIndex, setCurrentIndex] = useState(0)
const [showViewer, setShowViewer] = useState(false)
const [selectedImages, setSelectedImages] = useState([])
const [selectionMode, setSelectionMode] = useState(false)
const [showCreatePost, setShowCreatePost] = useState(false)
const touchStartX = useRef(0)
const touchEndX = useRef(0)
const isVideoUrl = (url = '') => {
if (!url) return false
const clean = url.split('?')[0].toLowerCase()
return clean.endsWith('.mp4') || clean.endsWith('.webm') || clean.endsWith('.mov') || clean.endsWith('.m4v')
}
useEffect(() => {
if (query.length > 1 && showTagSuggestions) {
loadTagSuggestions()
} else {
setTagSuggestions([])
}
}, [query, showTagSuggestions])
const loadTagSuggestions = async () => {
try {
const queryParts = query.trim().split(/\s+/)
const lastTag = queryParts[queryParts.length - 1] || query.trim()
if (!lastTag || lastTag.length < 1) {
setTagSuggestions([])
return
}
const furryTags = await getFurryTags(lastTag)
if (furryTags && Array.isArray(furryTags)) {
setTagSuggestions(furryTags.slice(0, 10))
}
} catch (error) {
console.error('Ошибка загрузки тегов:', error)
setTagSuggestions([])
}
}
const handleSearch = async (searchQuery = query, page = 1, append = false) => {
if (!searchQuery.trim()) return
try {
if (page === 1) {
setLoading(true)
setResults([])
} else {
setLoadingMore(true)
}
hapticFeedback('light')
setShowTagSuggestions(false)
const furryResults = await searchFurry(searchQuery, { limit: 320, page })
const allResults = Array.isArray(furryResults) ? furryResults : []
const hasMoreResults = allResults.length === 320
if (append) {
setResults(prev => [...prev, ...allResults])
} else {
setResults(allResults)
setCurrentPage(1)
}
setHasMore(hasMoreResults)
setCurrentPage(page)
setTagSuggestions([])
if (allResults.length > 0) {
hapticFeedback('success')
} else if (page === 1) {
hapticFeedback('error')
}
} catch (error) {
console.error('Ошибка поиска:', error)
hapticFeedback('error')
if (page === 1) {
setResults([])
}
} finally {
setLoading(false)
setLoadingMore(false)
}
}
const loadMore = () => {
if (!loadingMore && hasMore && query.trim()) {
handleSearch(query, currentPage + 1, true)
}
}
const handleTagClick = (tagName) => {
const queryParts = query.trim().split(/\s+/)
const existingTags = queryParts.slice(0, -1).filter(t => t.trim())
const newQuery = existingTags.length > 0
? [...existingTags, tagName].join(' ')
: tagName
setQuery(newQuery)
setShowTagSuggestions(false)
handleSearch(newQuery)
}
const openViewer = (index) => {
if (selectionMode) {
toggleImageSelection(index)
} else {
setCurrentIndex(index)
setShowViewer(true)
hapticFeedback('light')
}
}
const toggleImageSelection = (index) => {
const imageId = `${results[index].source}-${results[index].id}`
if (selectedImages.includes(imageId)) {
setSelectedImages(selectedImages.filter(id => id !== imageId))
} else {
setSelectedImages([...selectedImages, imageId])
}
hapticFeedback('light')
}
const toggleSelectionMode = () => {
setSelectionMode(!selectionMode)
setSelectedImages([])
hapticFeedback('light')
}
const handleSendSelected = async () => {
if (selectedImages.length === 0) return
try {
hapticFeedback('light')
const telegramUser = getTelegramUser()
if (telegramUser) {
const selectedPhotos = results.filter((img, index) => {
const imageId = `${img.source}-${img.id}`
return selectedImages.includes(imageId)
})
const photos = selectedPhotos.map(img => ({
url: img.url,
caption: `${img.source} - ${img.id}`
}))
await api.post('/bot/send-photos', {
userId: telegramUser.id,
photos: photos
})
hapticFeedback('success')
alert(`${selectedImages.length} изображений отправлено в ваш Telegram!`)
setSelectedImages([])
setSelectionMode(false)
} else {
alert('Функция доступна только в Telegram')
}
} catch (error) {
console.error('Ошибка:', error)
hapticFeedback('error')
alert('Ошибка отправки')
}
}
const handleNext = () => {
if (currentIndex < results.length - 1) {
setCurrentIndex(currentIndex + 1)
hapticFeedback('light')
}
}
const handlePrev = () => {
if (currentIndex > 0) {
setCurrentIndex(currentIndex - 1)
hapticFeedback('light')
}
}
const handleTouchStart = (e) => {
touchStartX.current = e.touches[0].clientX
}
const handleTouchMove = (e) => {
touchEndX.current = e.touches[0].clientX
}
const handleTouchEnd = () => {
const diff = touchStartX.current - touchEndX.current
const threshold = 50
if (Math.abs(diff) > threshold) {
if (diff > 0) {
handleNext()
} else {
handlePrev()
}
}
}
const handleKeyDown = (e) => {
if (e.key === 'ArrowLeft') {
handlePrev()
} else if (e.key === 'ArrowRight') {
handleNext()
} else if (e.key === 'Escape') {
setShowViewer(false)
}
}
useEffect(() => {
if (showViewer) {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}
}, [showViewer, currentIndex])
const handleDownload = async () => {
const currentImage = results[currentIndex]
if (!currentImage) return
try {
hapticFeedback('light')
const telegramUser = getTelegramUser()
if (telegramUser) {
const caption = `${currentImage.source} - ID: ${currentImage.id}\nТеги: ${currentImage.tags.slice(0, 3).join(', ')}`
await api.post('/bot/send-photo', {
userId: telegramUser.id,
photoUrl: currentImage.url,
caption: caption
})
hapticFeedback('success')
alert('✅ Изображение отправлено в ваш Telegram!')
} else {
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')
alert('Ошибка отправки. Проверьте настройки бота.')
}
}
const handleCreatePost = () => {
const currentImage = results[currentIndex]
setShowViewer(false)
setShowCreatePost(true)
hapticFeedback('light')
}
return (
<div className="media-search-page">
<div className="media-search-header">
<button className="back-btn" onClick={() => navigate('/media')}>
<ArrowLeft size={24} />
</button>
<h1 style={{ color: 'var(--tag-furry)' }}>Furry</h1>
{results.length > 0 && (
<button
className={`selection-toggle ${selectionMode ? 'active' : ''}`}
onClick={toggleSelectionMode}
>
{selectionMode ? 'Отмена' : 'Выбрать'}
</button>
)}
</div>
<div className="search-container">
<div className="search-input-wrapper">
<SearchIcon size={20} className="search-icon" />
<input
type="text"
placeholder="Поиск по тегам..."
value={query}
onChange={e => {
setQuery(e.target.value)
setShowTagSuggestions(true)
}}
onKeyPress={e => {
if (e.key === 'Enter') {
handleSearch()
}
}}
/>
{query && (
<button className="clear-btn" onClick={() => setQuery('')}>
<X size={18} />
</button>
)}
<button
className="search-submit-btn"
onClick={() => handleSearch()}
disabled={!query.trim() || loading}
>
<SearchIcon size={20} />
</button>
</div>
{tagSuggestions.length > 0 && showTagSuggestions && (
<div className="tag-suggestions">
{tagSuggestions.map((tag, index) => (
<button
key={index}
className="tag-suggestion"
onClick={() => handleTagClick(tag.name)}
>
<span className="tag-name">{tag.name}</span>
<span className="tag-count">{tag.count?.toLocaleString()}</span>
</button>
))}
</div>
)}
</div>
<div className="search-results">
{loading ? (
<div className="loading-state">
<div className="spinner" />
<p>Поиск...</p>
</div>
) : results.length === 0 && query ? (
<div className="empty-state">
<p>Ничего не найдено</p>
<span>Попробуйте другие теги</span>
</div>
) : results.length === 0 ? (
<div className="empty-state">
<SearchIcon size={48} color="var(--text-secondary)" />
<p>Введите теги для поиска</p>
<span>Используйте e621 теги</span>
</div>
) : (
<>
<div className="results-grid">
{results.map((item, index) => {
const imageId = `${item.source}-${item.id}`
const isSelected = selectedImages.includes(imageId)
return (
<div
key={imageId}
className={`result-item card ${isSelected ? 'selected' : ''}`}
onClick={() => openViewer(index)}
>
<img src={item.preview} alt={`Result ${index}`} />
<div className="result-overlay">
<span className="result-source">{item.source}</span>
<span className="result-rating">{item.rating}</span>
</div>
{selectionMode && (
<div className="selection-checkbox">
{isSelected && <span></span>}
</div>
)}
</div>
)
})}
</div>
{hasMore && !loadingMore && (
<div className="load-more-container">
<button className="load-more-btn" onClick={loadMore}>
Загрузить еще
</button>
</div>
)}
{loadingMore && (
<div className="loading-more">
<div className="spinner" />
<p>Загрузка...</p>
</div>
)}
{selectionMode && selectedImages.length > 0 && (
<div className="send-selected-bar">
<button className="send-selected-btn" onClick={handleSendSelected}>
Отправить в Telegram ({selectedImages.length})
</button>
</div>
)}
</>
)}
</div>
{showViewer && results[currentIndex] && createPortal(
<div className="image-viewer">
<div className="viewer-header">
<button className="viewer-btn" onClick={() => setShowViewer(false)}>
<X size={24} />
</button>
<span className="viewer-counter">
{currentIndex + 1} / {results.length}
</span>
<div className="viewer-actions">
<button className="viewer-btn" onClick={handleCreatePost} title="Создать пост">
<Plus size={24} />
</button>
<button className="viewer-btn" onClick={handleDownload} title="Отправить в ЛС">
<Download size={24} />
</button>
</div>
</div>
<div
className="viewer-content"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onClick={(e) => e.stopPropagation()}
>
{isVideoUrl(results[currentIndex].url) ? (
<video
src={results[currentIndex].url}
controls
autoPlay
loop
muted
playsInline
poster={results[currentIndex].preview}
draggable={false}
/>
) : (
<img
src={results[currentIndex].url}
alt="Full view"
draggable={false}
/>
)}
<div className="swipe-hint">
<ChevronLeft size={20} style={{ opacity: currentIndex > 0 ? 1 : 0.3 }} />
<span>Свайпайте для переключения</span>
<ChevronRight size={20} style={{ opacity: currentIndex < results.length - 1 ? 1 : 0.3 }} />
</div>
</div>
<div className="viewer-nav">
<button
className="nav-btn"
onClick={handlePrev}
disabled={currentIndex === 0}
style={{ opacity: currentIndex === 0 ? 0.3 : 1 }}
>
<ChevronLeft size={32} />
</button>
<button
className="nav-btn"
onClick={handleNext}
disabled={currentIndex === results.length - 1}
style={{ opacity: currentIndex === results.length - 1 ? 0.3 : 1 }}
>
<ChevronRight size={32} />
</button>
</div>
<div className="viewer-info">
<div className="info-tags">
{results[currentIndex].tags.slice(0, 5).map((tag, i) => (
<span key={i} className="info-tag">{tag}</span>
))}
</div>
<div className="info-stats">
<span>Score: {results[currentIndex].score}</span>
<span>Source: {results[currentIndex].source}</span>
</div>
</div>
</div>,
document.body
)}
{showCreatePost && (
<CreatePostModal
user={user}
onClose={() => setShowCreatePost(false)}
onPostCreated={() => {
setShowCreatePost(false)
setShowViewer(false)
}}
initialImage={results[currentIndex]?.url}
/>
)}
</div>
)
}

View File

@ -0,0 +1,373 @@
.media-music-page {
min-height: 100vh;
background: var(--bg-primary);
padding-bottom: 80px;
}
.media-music-header {
position: sticky;
top: 0;
z-index: 10;
background: var(--bg-secondary);
border-bottom: 1px solid var(--divider-color);
padding: 16px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 8px var(--shadow-sm);
}
.media-music-header h1 {
font-size: 20px;
font-weight: 600;
margin: 0;
flex: 1;
text-align: center;
}
.media-music-header .back-btn {
background: none;
border: none;
color: var(--text-primary);
cursor: pointer;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s;
}
.media-music-header .back-btn:active {
transform: scale(0.9);
}
/* Табы */
.music-tabs {
display: flex;
background: var(--bg-secondary);
border-bottom: 1px solid var(--divider-color);
padding: 0 16px;
gap: 8px;
position: sticky;
top: 57px;
z-index: 9;
}
.music-tab {
flex: 1;
background: none;
border: none;
padding: 12px 16px;
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
border-bottom: 2px solid transparent;
}
.music-tab.active {
color: #9b59b6;
border-bottom-color: #9b59b6;
}
.music-tab:active {
transform: scale(0.95);
}
/* Поиск */
.music-search {
padding: 16px;
}
.search-input-wrapper {
display: flex;
align-items: center;
gap: 8px;
background: var(--search-bg);
border-radius: 12px;
padding: 12px 16px;
margin-bottom: 16px;
}
.search-input-wrapper input {
flex: 1;
background: none;
border: none;
outline: none;
font-size: 16px;
color: var(--text-primary);
}
.search-input-wrapper input::placeholder {
color: var(--text-secondary);
}
.search-icon {
color: var(--search-icon);
}
.clear-btn,
.search-submit-btn {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
transition: transform 0.2s;
}
.clear-btn:active,
.search-submit-btn:active {
transform: scale(0.9);
}
.search-submit-btn {
color: #9b59b6;
}
.search-submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Результаты поиска */
.search-results {
display: flex;
flex-direction: column;
gap: 24px;
}
.results-section h3 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 12px 0;
}
/* Треки */
.tracks-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.track-item {
display: flex;
align-items: center;
gap: 12px;
background: var(--bg-secondary);
border-radius: 12px;
padding: 12px;
transition: all 0.2s;
}
.track-item:active {
transform: scale(0.98);
}
.track-cover {
width: 48px;
height: 48px;
border-radius: 8px;
background: var(--bg-primary);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.track-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.track-cover svg {
color: var(--text-secondary);
}
.track-info {
flex: 1;
min-width: 0;
}
.track-title {
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.track-artist {
font-size: 13px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.track-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.track-btn {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
transition: all 0.2s;
}
.track-btn:hover {
background: var(--bg-primary);
}
.track-btn:active {
transform: scale(0.9);
}
.track-btn.active {
color: #e74c3c;
}
/* Исполнители */
.artists-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.artist-item {
background: var(--bg-secondary);
border-radius: 12px;
padding: 16px;
cursor: pointer;
transition: all 0.2s;
}
.artist-item:active {
transform: scale(0.98);
}
.artist-name {
font-size: 16px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 4px;
}
.artist-stats {
font-size: 13px;
color: var(--text-secondary);
}
/* Альбомы */
.albums-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
}
.album-item {
background: var(--bg-secondary);
border-radius: 12px;
padding: 12px;
cursor: pointer;
transition: all 0.2s;
display: flex;
flex-direction: column;
gap: 8px;
}
.album-item:active {
transform: scale(0.98);
}
.album-cover {
width: 100%;
aspect-ratio: 1;
border-radius: 8px;
background: var(--bg-primary);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.album-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.album-cover svg {
color: var(--text-secondary);
}
.album-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.album-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.album-artist {
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Состояния */
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 64px 24px;
text-align: center;
}
.loading-state p,
.empty-state p {
font-size: 16px;
font-weight: 500;
color: var(--text-primary);
margin: 0;
}
.empty-state span {
font-size: 14px;
color: var(--text-secondary);
}
/* Контейнеры */
.music-tracks,
.music-favorites {
padding: 16px;
}

View File

@ -0,0 +1,346 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { ArrowLeft, Search as SearchIcon, Music, Play, Heart, Download, X } from 'lucide-react'
import { searchMusic, getTracks, addToFavorites, removeFromFavorites, getFavorites } from '../utils/musicApi'
import { hapticFeedback, getTelegramUser } from '../utils/telegram'
import { useMusicPlayer } from '../contexts/MusicPlayerContext'
import api from '../utils/api'
import './MediaMusic.css'
export default function MediaMusic({ user }) {
const navigate = useNavigate()
const { play } = useMusicPlayer()
const [activeTab, setActiveTab] = useState('search') // search, tracks, favorites
const [query, setQuery] = useState('')
const [searchResults, setSearchResults] = useState({ tracks: [], artists: [], albums: [] })
const [tracks, setTracks] = useState([])
const [favorites, setFavorites] = useState([])
const [loading, setLoading] = useState(false)
useEffect(() => {
if (activeTab === 'tracks') {
loadTracks()
} else if (activeTab === 'favorites') {
loadFavorites()
}
}, [activeTab])
const loadTracks = async () => {
try {
setLoading(true)
const data = await getTracks({ limit: 50 })
setTracks(data.tracks || [])
} catch (error) {
console.error('Ошибка загрузки треков:', error)
} finally {
setLoading(false)
}
}
const loadFavorites = async () => {
try {
setLoading(true)
const data = await getFavorites({ limit: 50 })
setFavorites(data.tracks || [])
} catch (error) {
console.error('Ошибка загрузки избранного:', error)
} finally {
setLoading(false)
}
}
const handleSearch = async () => {
if (!query.trim()) return
try {
setLoading(true)
hapticFeedback('light')
const results = await searchMusic(query.trim())
setSearchResults(results)
if (results.tracks.length > 0 || results.artists.length > 0 || results.albums.length > 0) {
hapticFeedback('success')
} else {
hapticFeedback('error')
}
} catch (error) {
console.error('Ошибка поиска:', error)
hapticFeedback('error')
} finally {
setLoading(false)
}
}
const handlePlayTrack = (track, trackList = []) => {
hapticFeedback('light')
// Передаем трек и очередь в плеер
const queue = trackList.length > 0 ? trackList : [track]
play(track, queue)
}
const handleToggleFavorite = async (track) => {
try {
hapticFeedback('light')
// Проверить, есть ли в избранном
const isFavorite = favorites.some(f => f._id === track._id)
if (isFavorite) {
await removeFromFavorites(track._id)
setFavorites(favorites.filter(f => f._id !== track._id))
hapticFeedback('success')
} else {
await addToFavorites(track._id)
setFavorites([...favorites, track])
hapticFeedback('success')
}
} catch (error) {
console.error('Ошибка:', error)
hapticFeedback('error')
alert(error.response?.data?.error || 'Ошибка')
}
}
const handleDownloadTrack = async (track) => {
try {
hapticFeedback('light')
const telegramUser = getTelegramUser()
if (telegramUser) {
await api.post('/bot/send-track', {
userId: telegramUser.id,
trackId: track._id
})
hapticFeedback('success')
alert('✅ Трек отправлен в ваш Telegram!')
} else {
alert('Функция доступна только в Telegram')
}
} catch (error) {
console.error('Ошибка:', error)
hapticFeedback('error')
alert('Ошибка отправки')
}
}
const renderTrackItem = (track, trackList = []) => {
const isFavorite = favorites.some(f => f._id === track._id)
return (
<div key={track._id} className="track-item">
<div className="track-cover">
{track.coverImage ? (
<img src={track.coverImage} alt={track.title} />
) : (
<Music size={24} />
)}
</div>
<div className="track-info">
<div className="track-title">{track.title}</div>
<div className="track-artist">{track.artist?.name || 'Unknown'}</div>
</div>
<div className="track-actions">
<button className="track-btn" onClick={() => handlePlayTrack(track, trackList)}>
<Play size={20} />
</button>
<button
className={`track-btn ${isFavorite ? 'active' : ''}`}
onClick={() => handleToggleFavorite(track)}
>
<Heart size={20} fill={isFavorite ? 'currentColor' : 'none'} />
</button>
<button className="track-btn" onClick={() => handleDownloadTrack(track)}>
<Download size={20} />
</button>
</div>
</div>
)
}
return (
<div className="media-music-page">
<div className="media-music-header">
<button className="back-btn" onClick={() => navigate('/media')}>
<ArrowLeft size={24} />
</button>
<h1 style={{ color: '#9b59b6' }}>Music</h1>
<div style={{ width: '40px' }} />
</div>
{/* Табы */}
<div className="music-tabs">
<button
className={`music-tab ${activeTab === 'search' ? 'active' : ''}`}
onClick={() => setActiveTab('search')}
>
Поиск
</button>
<button
className={`music-tab ${activeTab === 'tracks' ? 'active' : ''}`}
onClick={() => setActiveTab('tracks')}
>
Все треки
</button>
<button
className={`music-tab ${activeTab === 'favorites' ? 'active' : ''}`}
onClick={() => setActiveTab('favorites')}
>
Избранное
</button>
</div>
{/* Поиск */}
{activeTab === 'search' && (
<div className="music-search">
<div className="search-input-wrapper">
<SearchIcon size={20} className="search-icon" />
<input
type="text"
placeholder="Поиск треков, исполнителей..."
value={query}
onChange={e => setQuery(e.target.value)}
onKeyPress={e => {
if (e.key === 'Enter') {
handleSearch()
}
}}
/>
{query && (
<button className="clear-btn" onClick={() => setQuery('')}>
<X size={18} />
</button>
)}
<button
className="search-submit-btn"
onClick={handleSearch}
disabled={!query.trim() || loading}
>
<SearchIcon size={20} />
</button>
</div>
{loading ? (
<div className="loading-state">
<div className="spinner" />
<p>Поиск...</p>
</div>
) : searchResults.tracks.length === 0 && !query ? (
<div className="empty-state">
<Music size={48} color="var(--text-secondary)" />
<p>Введите запрос для поиска</p>
<span>Ищите треки, исполнителей и альбомы</span>
</div>
) : (
<div className="search-results">
{searchResults.tracks.length > 0 && (
<div className="results-section">
<h3>Треки</h3>
<div className="tracks-list">
{searchResults.tracks.map(track => renderTrackItem(track, searchResults.tracks))}
</div>
</div>
)}
{searchResults.artists.length > 0 && (
<div className="results-section">
<h3>Исполнители</h3>
<div className="artists-list">
{searchResults.artists.map(artist => (
<div key={artist._id} className="artist-item">
<div className="artist-name">{artist.name}</div>
<div className="artist-stats">
{artist.stats.tracks} треков
</div>
</div>
))}
</div>
</div>
)}
{searchResults.albums.length > 0 && (
<div className="results-section">
<h3>Альбомы</h3>
<div className="albums-list">
{searchResults.albums.map(album => (
<div key={album._id} className="album-item">
<div className="album-cover">
{album.coverImage ? (
<img src={album.coverImage} alt={album.title} />
) : (
<Music size={32} />
)}
</div>
<div className="album-info">
<div className="album-title">{album.title}</div>
<div className="album-artist">{album.artist?.name}</div>
</div>
</div>
))}
</div>
</div>
)}
{searchResults.tracks.length === 0 &&
searchResults.artists.length === 0 &&
searchResults.albums.length === 0 && (
<div className="empty-state">
<p>Ничего не найдено</p>
<span>Попробуйте другой запрос</span>
</div>
)}
</div>
)}
</div>
)}
{/* Все треки */}
{activeTab === 'tracks' && (
<div className="music-tracks">
{loading ? (
<div className="loading-state">
<div className="spinner" />
<p>Загрузка...</p>
</div>
) : tracks.length === 0 ? (
<div className="empty-state">
<Music size={48} color="var(--text-secondary)" />
<p>Нет треков</p>
<span>Загрузите первый трек</span>
</div>
) : (
<div className="tracks-list">
{tracks.map(track => renderTrackItem(track, tracks))}
</div>
)}
</div>
)}
{/* Избранное */}
{activeTab === 'favorites' && (
<div className="music-favorites">
{loading ? (
<div className="loading-state">
<div className="spinner" />
<p>Загрузка...</p>
</div>
) : favorites.length === 0 ? (
<div className="empty-state">
<Heart size={48} color="var(--text-secondary)" />
<p>Нет избранных треков</p>
<span>Добавьте треки в избранное</span>
</div>
) : (
<div className="tracks-list">
{favorites.map(track => renderTrackItem(track, favorites))}
</div>
)}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,68 @@
/* Общие стили для MediaFurry и MediaAnime */
@import './Search.css';
.media-search-page {
min-height: 100vh;
background: var(--bg-primary);
padding-bottom: 80px;
}
.media-search-header {
position: sticky;
top: 0;
z-index: 10;
background: var(--bg-secondary);
border-bottom: 1px solid var(--divider-color);
padding: 16px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 8px var(--shadow-sm);
}
.media-search-header h1 {
font-size: 20px;
font-weight: 600;
margin: 0;
flex: 1;
text-align: center;
}
.media-search-header .back-btn {
background: none;
border: none;
color: var(--text-primary);
cursor: pointer;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s;
}
.media-search-header .back-btn:active {
transform: scale(0.9);
}
.media-search-header .selection-toggle {
background: none;
border: none;
color: var(--button-accent);
font-size: 14px;
font-weight: 500;
cursor: pointer;
padding: 8px 12px;
border-radius: 8px;
transition: background 0.2s;
white-space: nowrap;
}
.media-search-header .selection-toggle:hover {
background: var(--bg-primary);
}
.media-search-header .selection-toggle.active {
background: var(--button-accent);
color: white;
}

View File

@ -0,0 +1,78 @@
import api from './api'
// Поиск музыки
export const searchMusic = async (query, type = 'all', params = {}) => {
const response = await api.get('/music/search', {
params: { q: query, type, ...params }
})
return response.data
}
// Получить список треков
export const getTracks = async (params = {}) => {
const response = await api.get('/music/tracks', { params })
return response.data
}
// Получить трек по ID
export const getTrack = async (trackId) => {
const response = await api.get(`/music/tracks/${trackId}`)
return response.data.track
}
// Получить альбом с треками
export const getAlbum = async (albumId) => {
const response = await api.get(`/music/albums/${albumId}`)
return response.data
}
// Загрузить трек
export const uploadTrack = async (formData) => {
const response = await api.post('/music/upload-track', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
return response.data
}
// Загрузить альбом (ZIP)
export const uploadAlbum = async (formData) => {
const response = await api.post('/music/upload-album', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
return response.data
}
// Добавить в избранное
export const addToFavorites = async (trackId) => {
const response = await api.post(`/music/favorites/${trackId}`)
return response.data
}
// Удалить из избранного
export const removeFromFavorites = async (trackId) => {
const response = await api.delete(`/music/favorites/${trackId}`)
return response.data
}
// Получить избранные треки
export const getFavorites = async (params = {}) => {
const response = await api.get('/music/favorites', { params })
return response.data
}
// Отметить прослушивание
export const playTrack = async (trackId) => {
const response = await api.post(`/music/tracks/${trackId}/play`)
return response.data
}
// Отправить трек в Telegram
export const sendTrackToTelegram = async (trackId) => {
const response = await api.post('/bot/send-track', { trackId })
return response.data
}

View File

@ -0,0 +1,22 @@
[2025-12-14 23:57:19] WARNING: ⚠️ [WARN] Request completed {method: 'GET', path: '/api/mod-app/users', status: 401, duration: '3ms'}
[2025-12-14 23:57:19] INFO: 📝 [INFO] Request completed {method: 'GET', path: '/api/moderation-auth/config', status: 200, duration: '1ms'}
INFO: 172.17.0.1:41340 - "GET /api/mod-app/users?filter=active HTTP/1.1" 401 Unauthorized
INFO: 172.17.0.1:41356 - "GET /api/moderation-auth/config HTTP/1.1" 200 OK
[2025-12-14 23:57:19] WARNING: ⚠️ [WARN] Request completed {method: 'POST', path: '/api/moderation-auth/telegram', status: 422, duration: '6ms'}
INFO: 172.17.0.1:41370 - "POST /api/moderation-auth/telegram HTTP/1.1" 422 Unprocessable Entity
[2025-12-14 23:57:21] INFO: 🔍 [DEBUG] Incoming request {method: 'GET', path: '/api/mod-app/users', ip: '172.17.0.1'}
[2025-12-14 23:57:21] INFO: 🔍 [DEBUG] Incoming request {method: 'GET', path: '/api/moderation-auth/config', ip: '172.17.0.1'}
[2025-12-14 23:57:21] INFO: 🔍 [DEBUG] Incoming request {method: 'POST', path: '/api/moderation-auth/telegram', ip: '172.17.0.1'}
[2025-12-14 23:57:21] WARNING: ⚠️ [WARN] Request completed {method: 'GET', path: '/api/mod-app/users', status: 401, duration: '2ms'}
[2025-12-14 23:57:21] INFO: 📝 [INFO] Request completed {method: 'GET', path: '/api/moderation-auth/config', status: 200, duration: '2ms'}
INFO: 172.17.0.1:41382 - "GET /api/mod-app/users?filter=active HTTP/1.1" 401 Unauthorized
INFO: 172.17.0.1:41388 - "GET /api/moderation-auth/config HTTP/1.1" 200 OK
[2025-12-14 23:57:21] WARNING: ⚠️ [WARN] Request completed {method: 'POST', path: '/api/moderation-auth/telegram', status: 422, duration: '5ms'}
INFO: 172.17.0.1:41398 - "POST /api/moderation-auth/telegram HTTP/1.1" 422 Unprocessable Entity
end-code', ip: '172.17.0.1'}
[2025-12-14 23:58:10] ERROR: ❌ Ошибка отправки email: Email provider 'smtp' не поддерживается. Используйте 'yandex'
[2025-12-14 23:58:10] ERROR: ❌ [ERROR] Request completed {method: 'POST', path: '/api/moderation-auth/send-code', status: 500, duration: '29ms'}
[ModerationAuth] Проверка пользователя для email aaem9848@gmail.com: {found: True, hasPassword: False, role: admin}
[ModerationAuth] Пользователь найден, отправка кода разрешена
INFO: 172.17.0.1:58968 - "POST /api/moderation-auth/send-code HTTP/1.1" 500 Internal Server Error

0
moderation/backend-py/RUN_THIS.sh Executable file → Normal file
View File

0
moderation/backend-py/docker-start.sh Executable file → Normal file
View File

0
moderation/backend-py/run.py Executable file → Normal file
View File

0
moderation/backend-py/start.sh Executable file → Normal file
View File

0
moderation/backend-py/test_email.py Executable file → Normal file
View File

0
setup-remote-storage.sh Executable file → Normal file
View File

0
start.sh Executable file → Normal file
View File

0
update-server.sh Executable file → Normal file
View File