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 = {
|
||||
sendPhotoToUser,
|
||||
sendPhotosToUser,
|
||||
sendAudioToUser,
|
||||
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,
|
||||
ref: 'User'
|
||||
}],
|
||||
// Прикрепленный музыкальный трек
|
||||
attachedTrack: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Track',
|
||||
default: null
|
||||
},
|
||||
isNSFW: {
|
||||
type: Boolean,
|
||||
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 router = express.Router();
|
||||
const { sendPhotoToUser, sendPhotosToUser } = require('../bot');
|
||||
const { sendPhotoToUser, sendPhotosToUser, sendAudioToUser } = require('../bot');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
const Track = require('../models/Track');
|
||||
|
||||
// Endpoint для отправки одного фото в ЛС
|
||||
router.post('/send-photo', authenticate, async (req, res) => {
|
||||
|
|
@ -75,6 +76,60 @@ router.post('/send-photos', authenticate, async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// Endpoint для отправки трека в ЛС
|
||||
router.post('/send-track', authenticate, async (req, res) => {
|
||||
try {
|
||||
const { userId, trackId } = req.body;
|
||||
|
||||
if (!userId || !trackId) {
|
||||
return res.status(400).json({ error: 'userId и trackId обязательны' });
|
||||
}
|
||||
|
||||
// Преобразуем userId в число
|
||||
const telegramUserId = typeof userId === 'string' ? parseInt(userId, 10) : userId;
|
||||
|
||||
if (isNaN(telegramUserId)) {
|
||||
return res.status(400).json({ error: 'userId должен быть числом' });
|
||||
}
|
||||
|
||||
// Получить трек из базы
|
||||
const track = await Track.findById(trackId).populate('artist album');
|
||||
|
||||
if (!track) {
|
||||
return res.status(404).json({ error: 'Трек не найден' });
|
||||
}
|
||||
|
||||
// Отправить трек
|
||||
const result = await sendAudioToUser(telegramUserId, track.fileUrl, {
|
||||
title: track.title,
|
||||
artist: track.artist?.name,
|
||||
duration: track.file?.duration
|
||||
});
|
||||
|
||||
// Увеличить счетчик скачиваний
|
||||
track.stats.downloads += 1;
|
||||
await track.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Трек отправлен в ваш Telegram',
|
||||
result
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Ошибка отправки трека:', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
response: error.response?.data,
|
||||
userId: req.body?.userId,
|
||||
trackId: req.body?.trackId
|
||||
});
|
||||
res.status(500).json({
|
||||
error: 'Ошибка отправки трека',
|
||||
details: error.response?.data?.description || error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Отправить сообщение всем пользователям (только админы)
|
||||
router.post('/broadcast', authenticate, async (req, res) => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -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('mentionedUsers', 'username firstName lastName')
|
||||
.populate('comments.author', 'username firstName lastName photoUrl')
|
||||
.populate({
|
||||
path: 'attachedTrack',
|
||||
populate: { path: 'artist album' }
|
||||
})
|
||||
.exec();
|
||||
|
||||
if (!post) {
|
||||
|
|
@ -100,6 +104,10 @@ router.get('/', authenticate, async (req, res) => {
|
|||
|
||||
let posts = await Post.find(query)
|
||||
.populate('author', 'username firstName lastName photoUrl')
|
||||
.populate({
|
||||
path: 'attachedTrack',
|
||||
populate: { path: 'artist album' }
|
||||
})
|
||||
.populate('mentionedUsers', 'username firstName lastName')
|
||||
.populate('comments.author', 'username firstName lastName photoUrl')
|
||||
.sort({ createdAt: -1 })
|
||||
|
|
@ -126,7 +134,7 @@ router.get('/', authenticate, async (req, res) => {
|
|||
// Создать пост
|
||||
router.post('/', authenticate, strictPostLimiter, postCreationLimiter, fileUploadLimiter, uploadPostImages, async (req, res) => {
|
||||
try {
|
||||
const { content, tags, mentionedUsers, isNSFW, isHomo, externalImages } = req.body;
|
||||
const { content, tags, mentionedUsers, isNSFW, isHomo, externalImages, attachedTrackId } = req.body;
|
||||
|
||||
// Валидация контента
|
||||
if (content && !validatePostContent(content)) {
|
||||
|
|
@ -198,6 +206,7 @@ router.post('/', authenticate, strictPostLimiter, postCreationLimiter, fileUploa
|
|||
tags: parsedTags,
|
||||
hashtags,
|
||||
mentionedUsers: mentionedUsers ? JSON.parse(mentionedUsers) : [],
|
||||
attachedTrack: attachedTrackId || null,
|
||||
isNSFW: isNSFW === 'true',
|
||||
// Флаг гомосексуального контента - полный аналог NSFW по логике,
|
||||
// но управляется отдельно
|
||||
|
|
|
|||
|
|
@ -253,6 +253,7 @@ app.use('/api/mod-app', require('./routes/modApp'));
|
|||
app.use('/api/moderation-auth', require('./routes/moderationAuth'));
|
||||
app.use('/api/minio', require('./routes/minio-test'));
|
||||
app.use('/api/tags', require('./routes/tags'));
|
||||
app.use('/api/music', require('./routes/music'));
|
||||
|
||||
// Базовый роут
|
||||
app.get('/', (req, res) => {
|
||||
|
|
|
|||
|
|
@ -4,15 +4,21 @@ import { initTelegramApp } from './utils/telegram'
|
|||
import { verifyAuth } from './utils/api'
|
||||
import { initTheme } from './utils/theme'
|
||||
import { startInitDataChecker, stopInitDataChecker } from './utils/initDataChecker'
|
||||
import { MusicPlayerProvider } from './contexts/MusicPlayerContext'
|
||||
import Layout from './components/Layout'
|
||||
import Feed from './pages/Feed'
|
||||
import Search from './pages/Search'
|
||||
import Media from './pages/Media'
|
||||
import MediaFurry from './pages/MediaFurry'
|
||||
import MediaAnime from './pages/MediaAnime'
|
||||
import MediaMusic from './pages/MediaMusic'
|
||||
import Notifications from './pages/Notifications'
|
||||
import Profile from './pages/Profile'
|
||||
import UserProfile from './pages/UserProfile'
|
||||
import CommentsPage from './pages/CommentsPage'
|
||||
import PostMenuPage from './pages/PostMenuPage'
|
||||
import MonthlyLadder from './pages/MonthlyLadder'
|
||||
import MiniPlayer from './components/MiniPlayer'
|
||||
import FullPlayer from './components/FullPlayer'
|
||||
import './styles/index.css'
|
||||
|
||||
function AppContent() {
|
||||
|
|
@ -196,7 +202,10 @@ function AppContent() {
|
|||
<Route path="/" element={<Layout user={user} />}>
|
||||
<Route index element={<Navigate to="/feed" replace />} />
|
||||
<Route path="feed" element={<Feed user={user} />} />
|
||||
<Route path="search" element={<Search user={user} />} />
|
||||
<Route path="media" element={<Media user={user} />} />
|
||||
<Route path="media/furry" element={<MediaFurry user={user} />} />
|
||||
<Route path="media/anime" element={<MediaAnime user={user} />} />
|
||||
<Route path="media/music" element={<MediaMusic user={user} />} />
|
||||
<Route path="notifications" element={<Notifications user={user} />} />
|
||||
<Route path="profile" element={<Profile user={user} setUser={setUser} />} />
|
||||
<Route path="user/:id" element={<UserProfile currentUser={user} />} />
|
||||
|
|
@ -211,7 +220,11 @@ function AppContent() {
|
|||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AppContent />
|
||||
<MusicPlayerProvider>
|
||||
<AppContent />
|
||||
<MiniPlayer />
|
||||
<FullPlayer />
|
||||
</MusicPlayerProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { Home, Search, Bell, User } from 'lucide-react'
|
||||
import { Home, Layers, Bell, User } from 'lucide-react'
|
||||
import './Navigation.css'
|
||||
|
||||
export default function Navigation() {
|
||||
|
|
@ -14,11 +14,11 @@ export default function Navigation() {
|
|||
)}
|
||||
</NavLink>
|
||||
|
||||
<NavLink to="/search" className="nav-item">
|
||||
<NavLink to="/media" className="nav-item">
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<Search size={24} strokeWidth={isActive ? 2.5 : 2} />
|
||||
<span>Поиск</span>
|
||||
<Layers size={24} strokeWidth={isActive ? 2.5 : 2} />
|
||||
<span>Media</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
|
|
|
|||
|
|
@ -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