diff --git a/MUSIC_SETUP.md b/MUSIC_SETUP.md new file mode 100644 index 0000000..39455e2 --- /dev/null +++ b/MUSIC_SETUP.md @@ -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. Треки в постах воспроизводятся через общий плеер + diff --git a/WIND.md b/WIND.md new file mode 100644 index 0000000..574b26b --- /dev/null +++ b/WIND.md @@ -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 +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 (замените на номер процесса) +taskkill /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 Убить процесс (замените на номер из вывода выше) +taskkill /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 в репозитории + +--- + +**Удачной разработки! 🚀** + diff --git a/backend/bot.js b/backend/bot.js index 9e68a42..4151fd8 100644 --- a/backend/bot.js +++ b/backend/bot.js @@ -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 + ? `🎵 ${metadata.title}\n👤 ${metadata.artist || 'Unknown'}\n\nИз Nakama Music` + : 'Из 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 }; diff --git a/backend/models/Album.js b/backend/models/Album.js new file mode 100644 index 0000000..c336583 --- /dev/null +++ b/backend/models/Album.js @@ -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); + diff --git a/backend/models/Artist.js b/backend/models/Artist.js new file mode 100644 index 0000000..3e9aca5 --- /dev/null +++ b/backend/models/Artist.js @@ -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); + diff --git a/backend/models/FavoriteTrack.js b/backend/models/FavoriteTrack.js new file mode 100644 index 0000000..1fcd69e --- /dev/null +++ b/backend/models/FavoriteTrack.js @@ -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); + diff --git a/backend/models/Post.js b/backend/models/Post.js index 2225d1c..bca1e26 100644 --- a/backend/models/Post.js +++ b/backend/models/Post.js @@ -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 diff --git a/backend/models/Track.js b/backend/models/Track.js new file mode 100644 index 0000000..f12952f --- /dev/null +++ b/backend/models/Track.js @@ -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); + diff --git a/backend/routes/bot.js b/backend/routes/bot.js index 9825cac..a16d904 100644 --- a/backend/routes/bot.js +++ b/backend/routes/bot.js @@ -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 { diff --git a/backend/routes/music.js b/backend/routes/music.js new file mode 100644 index 0000000..10502bd --- /dev/null +++ b/backend/routes/music.js @@ -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; + diff --git a/backend/routes/posts.js b/backend/routes/posts.js index d53dd56..2f72150 100644 --- a/backend/routes/posts.js +++ b/backend/routes/posts.js @@ -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 по логике, // но управляется отдельно diff --git a/backend/server.js b/backend/server.js index c6c2f7b..df457e8 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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) => { diff --git a/backup-cron.sh b/backup-cron.sh old mode 100755 new mode 100644 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8e1047b..d48d08d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { }> } /> } /> - } /> + } /> + } /> + } /> + } /> } /> } /> } /> @@ -211,7 +220,11 @@ function AppContent() { function App() { return ( - + + + + + ) } diff --git a/frontend/src/components/FullPlayer.css b/frontend/src/components/FullPlayer.css new file mode 100644 index 0000000..355041a --- /dev/null +++ b/frontend/src/components/FullPlayer.css @@ -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; + } +} + diff --git a/frontend/src/components/FullPlayer.jsx b/frontend/src/components/FullPlayer.jsx new file mode 100644 index 0000000..08380be --- /dev/null +++ b/frontend/src/components/FullPlayer.jsx @@ -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( +
+
+ + + {currentIndex + 1} / {queue.length} + +
+
+ +
+
+ {currentTrack.coverImage ? ( + {currentTrack.title} + ) : ( + + )} +
+ +
+

{currentTrack.title}

+

{currentTrack.artist?.name || 'Unknown Artist'}

+ {currentTrack.album && ( +

{currentTrack.album.title}

+ )} +
+ +
+
+
+
+
+ {formatTime(progress)} + {formatTime(duration)} +
+
+ +
+ + + + + +
+ +
+ + + + + +
+ + {showVolume && ( +
+ + + {Math.round(volume * 100)}% +
+ )} +
+
, + document.body + ) +} + diff --git a/frontend/src/components/MiniPlayer.css b/frontend/src/components/MiniPlayer.css new file mode 100644 index 0000000..09d9748 --- /dev/null +++ b/frontend/src/components/MiniPlayer.css @@ -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); +} + diff --git a/frontend/src/components/MiniPlayer.jsx b/frontend/src/components/MiniPlayer.jsx new file mode 100644 index 0000000..2f7d963 --- /dev/null +++ b/frontend/src/components/MiniPlayer.jsx @@ -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 ( +
+
+ +
+
+ {currentTrack.coverImage ? ( + {currentTrack.title} + ) : ( + + )} +
+ +
+
{currentTrack.title}
+
{currentTrack.artist?.name || 'Unknown'}
+
+ +
+ + +
+
+
+ ) +} + diff --git a/frontend/src/components/MusicAttachment.css b/frontend/src/components/MusicAttachment.css new file mode 100644 index 0000000..ca40a02 --- /dev/null +++ b/frontend/src/components/MusicAttachment.css @@ -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; +} + diff --git a/frontend/src/components/MusicAttachment.jsx b/frontend/src/components/MusicAttachment.jsx new file mode 100644 index 0000000..d7edbb9 --- /dev/null +++ b/frontend/src/components/MusicAttachment.jsx @@ -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 ( +
+
+ {track.coverImage ? ( + {track.title} + ) : ( + + )} +
+ +
+
{track.title}
+
{track.artist?.name || 'Unknown'}
+
+ + + + {showRemove && ( + + )} +
+ ) +} + diff --git a/frontend/src/components/Navigation.jsx b/frontend/src/components/Navigation.jsx index d36a82c..2b0d91c 100644 --- a/frontend/src/components/Navigation.jsx +++ b/frontend/src/components/Navigation.jsx @@ -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() { )} - + {({ isActive }) => ( <> - - Поиск + + Media )} diff --git a/frontend/src/contexts/MusicPlayerContext.jsx b/frontend/src/contexts/MusicPlayerContext.jsx new file mode 100644 index 0000000..7ddbb6f --- /dev/null +++ b/frontend/src/contexts/MusicPlayerContext.jsx @@ -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 ( + + {children} + + ) +} + diff --git a/frontend/src/pages/Media.css b/frontend/src/pages/Media.css new file mode 100644 index 0000000..9e69407 --- /dev/null +++ b/frontend/src/pages/Media.css @@ -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); + } +} + diff --git a/frontend/src/pages/Media.jsx b/frontend/src/pages/Media.jsx new file mode 100644 index 0000000..8d504fb --- /dev/null +++ b/frontend/src/pages/Media.jsx @@ -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 ( +
+
+

Media

+
+ +
+ {categories.map((category) => { + const Icon = category.icon + return ( + + ) + })} +
+
+ ) +} + diff --git a/frontend/src/pages/MediaAnime.jsx b/frontend/src/pages/MediaAnime.jsx new file mode 100644 index 0000000..f2e9876 --- /dev/null +++ b/frontend/src/pages/MediaAnime.jsx @@ -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 ( +
+
+ +

Anime

+ {results.length > 0 && ( + + )} +
+ +
+
+ + { + setQuery(e.target.value) + setShowTagSuggestions(true) + }} + onKeyPress={e => { + if (e.key === 'Enter') { + handleSearch() + } + }} + /> + {query && ( + + )} + +
+ + {tagSuggestions.length > 0 && showTagSuggestions && ( +
+ {tagSuggestions.map((tag, index) => ( + + ))} +
+ )} +
+ +
+ {loading ? ( +
+
+

Поиск...

+
+ ) : results.length === 0 && query ? ( +
+

Ничего не найдено

+ Попробуйте другие теги +
+ ) : results.length === 0 ? ( +
+ +

Введите теги для поиска

+ Используйте gelbooru теги +
+ ) : ( + <> +
+ {results.map((item, index) => { + const imageId = `${item.source}-${item.id}` + const isSelected = selectedImages.includes(imageId) + + return ( +
openViewer(index)} + > + {`Result +
+ {item.source} + {item.rating} +
+ {selectionMode && ( +
+ {isSelected && } +
+ )} +
+ ) + })} +
+ + {hasMore && !loadingMore && ( +
+ +
+ )} + + {loadingMore && ( +
+
+

Загрузка...

+
+ )} + + {selectionMode && selectedImages.length > 0 && ( +
+ +
+ )} + + )} +
+ + {showViewer && results[currentIndex] && createPortal( +
+
+ + + {currentIndex + 1} / {results.length} + +
+ + +
+
+ +
e.stopPropagation()} + > + {isVideoUrl(results[currentIndex].url) ? ( +
+ +
+ + +
+ +
+
+ {results[currentIndex].tags.slice(0, 5).map((tag, i) => ( + {tag} + ))} +
+
+ Score: {results[currentIndex].score} + Source: {results[currentIndex].source} +
+
+
, + document.body + )} + + {showCreatePost && ( + setShowCreatePost(false)} + onPostCreated={() => { + setShowCreatePost(false) + setShowViewer(false) + }} + initialImage={results[currentIndex]?.url} + /> + )} +
+ ) +} + diff --git a/frontend/src/pages/MediaFurry.jsx b/frontend/src/pages/MediaFurry.jsx new file mode 100644 index 0000000..f8027ac --- /dev/null +++ b/frontend/src/pages/MediaFurry.jsx @@ -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 ( +
+
+ +

Furry

+ {results.length > 0 && ( + + )} +
+ +
+
+ + { + setQuery(e.target.value) + setShowTagSuggestions(true) + }} + onKeyPress={e => { + if (e.key === 'Enter') { + handleSearch() + } + }} + /> + {query && ( + + )} + +
+ + {tagSuggestions.length > 0 && showTagSuggestions && ( +
+ {tagSuggestions.map((tag, index) => ( + + ))} +
+ )} +
+ +
+ {loading ? ( +
+
+

Поиск...

+
+ ) : results.length === 0 && query ? ( +
+

Ничего не найдено

+ Попробуйте другие теги +
+ ) : results.length === 0 ? ( +
+ +

Введите теги для поиска

+ Используйте e621 теги +
+ ) : ( + <> +
+ {results.map((item, index) => { + const imageId = `${item.source}-${item.id}` + const isSelected = selectedImages.includes(imageId) + + return ( +
openViewer(index)} + > + {`Result +
+ {item.source} + {item.rating} +
+ {selectionMode && ( +
+ {isSelected && } +
+ )} +
+ ) + })} +
+ + {hasMore && !loadingMore && ( +
+ +
+ )} + + {loadingMore && ( +
+
+

Загрузка...

+
+ )} + + {selectionMode && selectedImages.length > 0 && ( +
+ +
+ )} + + )} +
+ + {showViewer && results[currentIndex] && createPortal( +
+
+ + + {currentIndex + 1} / {results.length} + +
+ + +
+
+ +
e.stopPropagation()} + > + {isVideoUrl(results[currentIndex].url) ? ( +
+ +
+ + +
+ +
+
+ {results[currentIndex].tags.slice(0, 5).map((tag, i) => ( + {tag} + ))} +
+
+ Score: {results[currentIndex].score} + Source: {results[currentIndex].source} +
+
+
, + document.body + )} + + {showCreatePost && ( + setShowCreatePost(false)} + onPostCreated={() => { + setShowCreatePost(false) + setShowViewer(false) + }} + initialImage={results[currentIndex]?.url} + /> + )} +
+ ) +} + diff --git a/frontend/src/pages/MediaMusic.css b/frontend/src/pages/MediaMusic.css new file mode 100644 index 0000000..c8bf650 --- /dev/null +++ b/frontend/src/pages/MediaMusic.css @@ -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; +} + diff --git a/frontend/src/pages/MediaMusic.jsx b/frontend/src/pages/MediaMusic.jsx new file mode 100644 index 0000000..890d111 --- /dev/null +++ b/frontend/src/pages/MediaMusic.jsx @@ -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 ( +
+
+ {track.coverImage ? ( + {track.title} + ) : ( + + )} +
+ +
+
{track.title}
+
{track.artist?.name || 'Unknown'}
+
+ +
+ + + +
+
+ ) + } + + return ( +
+
+ +

Music

+
+
+ + {/* Табы */} +
+ + + +
+ + {/* Поиск */} + {activeTab === 'search' && ( +
+
+ + setQuery(e.target.value)} + onKeyPress={e => { + if (e.key === 'Enter') { + handleSearch() + } + }} + /> + {query && ( + + )} + +
+ + {loading ? ( +
+
+

Поиск...

+
+ ) : searchResults.tracks.length === 0 && !query ? ( +
+ +

Введите запрос для поиска

+ Ищите треки, исполнителей и альбомы +
+ ) : ( +
+ {searchResults.tracks.length > 0 && ( +
+

Треки

+
+ {searchResults.tracks.map(track => renderTrackItem(track, searchResults.tracks))} +
+
+ )} + + {searchResults.artists.length > 0 && ( +
+

Исполнители

+
+ {searchResults.artists.map(artist => ( +
+
{artist.name}
+
+ {artist.stats.tracks} треков +
+
+ ))} +
+
+ )} + + {searchResults.albums.length > 0 && ( +
+

Альбомы

+
+ {searchResults.albums.map(album => ( +
+
+ {album.coverImage ? ( + {album.title} + ) : ( + + )} +
+
+
{album.title}
+
{album.artist?.name}
+
+
+ ))} +
+
+ )} + + {searchResults.tracks.length === 0 && + searchResults.artists.length === 0 && + searchResults.albums.length === 0 && ( +
+

Ничего не найдено

+ Попробуйте другой запрос +
+ )} +
+ )} +
+ )} + + {/* Все треки */} + {activeTab === 'tracks' && ( +
+ {loading ? ( +
+
+

Загрузка...

+
+ ) : tracks.length === 0 ? ( +
+ +

Нет треков

+ Загрузите первый трек +
+ ) : ( +
+ {tracks.map(track => renderTrackItem(track, tracks))} +
+ )} +
+ )} + + {/* Избранное */} + {activeTab === 'favorites' && ( +
+ {loading ? ( +
+
+

Загрузка...

+
+ ) : favorites.length === 0 ? ( +
+ +

Нет избранных треков

+ Добавьте треки в избранное +
+ ) : ( +
+ {favorites.map(track => renderTrackItem(track, favorites))} +
+ )} +
+ )} +
+ ) +} + diff --git a/frontend/src/pages/MediaSearch.css b/frontend/src/pages/MediaSearch.css new file mode 100644 index 0000000..99b37a3 --- /dev/null +++ b/frontend/src/pages/MediaSearch.css @@ -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; +} + diff --git a/frontend/src/utils/musicApi.js b/frontend/src/utils/musicApi.js new file mode 100644 index 0000000..55e5af3 --- /dev/null +++ b/frontend/src/utils/musicApi.js @@ -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 +} + diff --git a/logs.txt b/logs.txt index e69de29..4864e62 100644 --- a/logs.txt +++ b/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 diff --git a/moderation/backend-py/RUN_THIS.sh b/moderation/backend-py/RUN_THIS.sh old mode 100755 new mode 100644 diff --git a/moderation/backend-py/docker-start.sh b/moderation/backend-py/docker-start.sh old mode 100755 new mode 100644 diff --git a/moderation/backend-py/run.py b/moderation/backend-py/run.py old mode 100755 new mode 100644 diff --git a/moderation/backend-py/start.sh b/moderation/backend-py/start.sh old mode 100755 new mode 100644 diff --git a/moderation/backend-py/test_email.py b/moderation/backend-py/test_email.py old mode 100755 new mode 100644 diff --git a/setup-remote-storage.sh b/setup-remote-storage.sh old mode 100755 new mode 100644 diff --git a/start.sh b/start.sh old mode 100755 new mode 100644 diff --git a/update-server.sh b/update-server.sh old mode 100755 new mode 100644