Update files
This commit is contained in:
parent
3bf1dbc779
commit
fe45fbb159
|
|
@ -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. Треки в постах воспроизводятся через общий плеер
|
||||||
|
|
||||||
|
|
@ -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 в репозитории
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Удачной разработки! 🚀**
|
||||||
|
|
||||||
|
|
@ -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 = {
|
module.exports = {
|
||||||
sendPhotoToUser,
|
sendPhotoToUser,
|
||||||
sendPhotosToUser,
|
sendPhotosToUser,
|
||||||
|
sendAudioToUser,
|
||||||
handleWebAppData
|
handleWebAppData
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
@ -46,6 +46,12 @@ const PostSchema = new mongoose.Schema({
|
||||||
type: mongoose.Schema.Types.ObjectId,
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
ref: 'User'
|
ref: 'User'
|
||||||
}],
|
}],
|
||||||
|
// Прикрепленный музыкальный трек
|
||||||
|
attachedTrack: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'Track',
|
||||||
|
default: null
|
||||||
|
},
|
||||||
isNSFW: {
|
isNSFW: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { sendPhotoToUser, sendPhotosToUser } = require('../bot');
|
const { sendPhotoToUser, sendPhotosToUser, sendAudioToUser } = require('../bot');
|
||||||
const { authenticate } = require('../middleware/auth');
|
const { authenticate } = require('../middleware/auth');
|
||||||
|
const Track = require('../models/Track');
|
||||||
|
|
||||||
// Endpoint для отправки одного фото в ЛС
|
// Endpoint для отправки одного фото в ЛС
|
||||||
router.post('/send-photo', authenticate, async (req, res) => {
|
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) => {
|
router.post('/broadcast', authenticate, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
@ -21,6 +21,10 @@ router.get('/:id', authenticate, async (req, res) => {
|
||||||
.populate('author', 'username firstName lastName photoUrl')
|
.populate('author', 'username firstName lastName photoUrl')
|
||||||
.populate('mentionedUsers', 'username firstName lastName')
|
.populate('mentionedUsers', 'username firstName lastName')
|
||||||
.populate('comments.author', 'username firstName lastName photoUrl')
|
.populate('comments.author', 'username firstName lastName photoUrl')
|
||||||
|
.populate({
|
||||||
|
path: 'attachedTrack',
|
||||||
|
populate: { path: 'artist album' }
|
||||||
|
})
|
||||||
.exec();
|
.exec();
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
|
|
@ -100,6 +104,10 @@ router.get('/', authenticate, async (req, res) => {
|
||||||
|
|
||||||
let posts = await Post.find(query)
|
let posts = await Post.find(query)
|
||||||
.populate('author', 'username firstName lastName photoUrl')
|
.populate('author', 'username firstName lastName photoUrl')
|
||||||
|
.populate({
|
||||||
|
path: 'attachedTrack',
|
||||||
|
populate: { path: 'artist album' }
|
||||||
|
})
|
||||||
.populate('mentionedUsers', 'username firstName lastName')
|
.populate('mentionedUsers', 'username firstName lastName')
|
||||||
.populate('comments.author', 'username firstName lastName photoUrl')
|
.populate('comments.author', 'username firstName lastName photoUrl')
|
||||||
.sort({ createdAt: -1 })
|
.sort({ createdAt: -1 })
|
||||||
|
|
@ -126,7 +134,7 @@ router.get('/', authenticate, async (req, res) => {
|
||||||
// Создать пост
|
// Создать пост
|
||||||
router.post('/', authenticate, strictPostLimiter, postCreationLimiter, fileUploadLimiter, uploadPostImages, async (req, res) => {
|
router.post('/', authenticate, strictPostLimiter, postCreationLimiter, fileUploadLimiter, uploadPostImages, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { content, tags, mentionedUsers, isNSFW, isHomo, externalImages } = req.body;
|
const { content, tags, mentionedUsers, isNSFW, isHomo, externalImages, attachedTrackId } = req.body;
|
||||||
|
|
||||||
// Валидация контента
|
// Валидация контента
|
||||||
if (content && !validatePostContent(content)) {
|
if (content && !validatePostContent(content)) {
|
||||||
|
|
@ -198,6 +206,7 @@ router.post('/', authenticate, strictPostLimiter, postCreationLimiter, fileUploa
|
||||||
tags: parsedTags,
|
tags: parsedTags,
|
||||||
hashtags,
|
hashtags,
|
||||||
mentionedUsers: mentionedUsers ? JSON.parse(mentionedUsers) : [],
|
mentionedUsers: mentionedUsers ? JSON.parse(mentionedUsers) : [],
|
||||||
|
attachedTrack: attachedTrackId || null,
|
||||||
isNSFW: isNSFW === 'true',
|
isNSFW: isNSFW === 'true',
|
||||||
// Флаг гомосексуального контента - полный аналог NSFW по логике,
|
// Флаг гомосексуального контента - полный аналог NSFW по логике,
|
||||||
// но управляется отдельно
|
// но управляется отдельно
|
||||||
|
|
|
||||||
|
|
@ -253,6 +253,7 @@ app.use('/api/mod-app', require('./routes/modApp'));
|
||||||
app.use('/api/moderation-auth', require('./routes/moderationAuth'));
|
app.use('/api/moderation-auth', require('./routes/moderationAuth'));
|
||||||
app.use('/api/minio', require('./routes/minio-test'));
|
app.use('/api/minio', require('./routes/minio-test'));
|
||||||
app.use('/api/tags', require('./routes/tags'));
|
app.use('/api/tags', require('./routes/tags'));
|
||||||
|
app.use('/api/music', require('./routes/music'));
|
||||||
|
|
||||||
// Базовый роут
|
// Базовый роут
|
||||||
app.get('/', (req, res) => {
|
app.get('/', (req, res) => {
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,21 @@ import { initTelegramApp } from './utils/telegram'
|
||||||
import { verifyAuth } from './utils/api'
|
import { verifyAuth } from './utils/api'
|
||||||
import { initTheme } from './utils/theme'
|
import { initTheme } from './utils/theme'
|
||||||
import { startInitDataChecker, stopInitDataChecker } from './utils/initDataChecker'
|
import { startInitDataChecker, stopInitDataChecker } from './utils/initDataChecker'
|
||||||
|
import { MusicPlayerProvider } from './contexts/MusicPlayerContext'
|
||||||
import Layout from './components/Layout'
|
import Layout from './components/Layout'
|
||||||
import Feed from './pages/Feed'
|
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 Notifications from './pages/Notifications'
|
||||||
import Profile from './pages/Profile'
|
import Profile from './pages/Profile'
|
||||||
import UserProfile from './pages/UserProfile'
|
import UserProfile from './pages/UserProfile'
|
||||||
import CommentsPage from './pages/CommentsPage'
|
import CommentsPage from './pages/CommentsPage'
|
||||||
import PostMenuPage from './pages/PostMenuPage'
|
import PostMenuPage from './pages/PostMenuPage'
|
||||||
import MonthlyLadder from './pages/MonthlyLadder'
|
import MonthlyLadder from './pages/MonthlyLadder'
|
||||||
|
import MiniPlayer from './components/MiniPlayer'
|
||||||
|
import FullPlayer from './components/FullPlayer'
|
||||||
import './styles/index.css'
|
import './styles/index.css'
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
|
|
@ -196,7 +202,10 @@ function AppContent() {
|
||||||
<Route path="/" element={<Layout user={user} />}>
|
<Route path="/" element={<Layout user={user} />}>
|
||||||
<Route index element={<Navigate to="/feed" replace />} />
|
<Route index element={<Navigate to="/feed" replace />} />
|
||||||
<Route path="feed" element={<Feed user={user} />} />
|
<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="notifications" element={<Notifications user={user} />} />
|
||||||
<Route path="profile" element={<Profile user={user} setUser={setUser} />} />
|
<Route path="profile" element={<Profile user={user} setUser={setUser} />} />
|
||||||
<Route path="user/:id" element={<UserProfile currentUser={user} />} />
|
<Route path="user/:id" element={<UserProfile currentUser={user} />} />
|
||||||
|
|
@ -211,7 +220,11 @@ function AppContent() {
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AppContent />
|
<MusicPlayerProvider>
|
||||||
|
<AppContent />
|
||||||
|
<MiniPlayer />
|
||||||
|
<FullPlayer />
|
||||||
|
</MusicPlayerProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { NavLink } from 'react-router-dom'
|
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'
|
import './Navigation.css'
|
||||||
|
|
||||||
export default function Navigation() {
|
export default function Navigation() {
|
||||||
|
|
@ -14,11 +14,11 @@ export default function Navigation() {
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
<NavLink to="/search" className="nav-item">
|
<NavLink to="/media" className="nav-item">
|
||||||
{({ isActive }) => (
|
{({ isActive }) => (
|
||||||
<>
|
<>
|
||||||
<Search size={24} strokeWidth={isActive ? 2.5 : 2} />
|
<Layers size={24} strokeWidth={isActive ? 2.5 : 2} />
|
||||||
<span>Поиск</span>
|
<span>Media</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
22
logs.txt
22
logs.txt
|
|
@ -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
|
||||||
Loading…
Reference in New Issue