diff --git a/ALBUM_UPLOAD_GUIDE.md b/ALBUM_UPLOAD_GUIDE.md index 9e504d9..b342f1c 100644 --- a/ALBUM_UPLOAD_GUIDE.md +++ b/ALBUM_UPLOAD_GUIDE.md @@ -213,3 +213,4 @@ Album Disc 2.zip **Удачной загрузки! 🚀** + diff --git a/BUGFIXES_SUMMARY.md b/BUGFIXES_SUMMARY.md new file mode 100644 index 0000000..f4a4060 --- /dev/null +++ b/BUGFIXES_SUMMARY.md @@ -0,0 +1,111 @@ +# 🔧 Исправления проблем + +## ✅ Исправленные проблемы + +### 1. Плеер находится слишком низко (уходит под меню) +**Исправлено в:** `frontend/src/components/MiniPlayer.css` +- Изменено `bottom: 60px` → `bottom: 70px` +- Увеличен `z-index: 45` → `z-index: 60` +- Добавлен `padding-bottom: env(safe-area-inset-bottom)` для безопасной зоны + +### 2. Альбом не загружается (ошибка 413) +**Исправлено в:** +- `backend/server.js` - увеличен лимит express.json до `105mb` +- `backend/routes/music.js` - увеличен лимит multer до `105MB` + +**Изменения:** +```javascript +// server.js +app.use(express.json({ limit: '105mb' })); +app.use(express.urlencoded({ extended: true, limit: '105mb' })); + +// routes/music.js +limits: { + fileSize: 105 * 1024 * 1024 // 105MB для ZIP альбомов +} +``` + +### 3. Музыка не воспроизводится +**Исправлено в:** `frontend/src/contexts/MusicPlayerContext.jsx` +- Добавлено формирование полного URL для треков +- Относительные пути преобразуются в абсолютные с использованием `VITE_API_URL` +- Добавлен `audioRef.current.load()` для перезагрузки источника + +**Изменения:** +```javascript +let audioUrl = track.fileUrl +if (audioUrl && audioUrl.startsWith('/')) { + const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3000/api' + audioUrl = apiUrl.replace('/api', '') + audioUrl +} +audioRef.current.src = audioUrl +audioRef.current.load() +``` + +### 4. Обложка у трека не грузится +**Исправлено в:** +- `frontend/src/components/MusicAttachment.jsx` +- `frontend/src/components/MiniPlayer.jsx` +- `frontend/src/components/FullPlayer.jsx` +- `frontend/src/pages/MediaMusic.jsx` +- `frontend/src/components/MusicPickerModal.jsx` + +**Изменения:** +- Добавлено формирование полного URL для обложек (как для треков) +- Добавлена обработка ошибок загрузки изображений (`onError`) +- При ошибке загрузки показывается fallback иконка + +### 5. Иконка furry не лиса +**Исправлено в:** `frontend/src/pages/Media.jsx` +- Заменена SVG иконка на правильную иконку лисы в стиле аниме +- Иконка теперь отображает мордочку лисы с ушами, глазами, носом и хвостом + +### 6. Не получается создать пост с треком +**Проверено:** +- `frontend/src/components/CreatePostModal.jsx` - правильно передает `attachedTrackId` +- `backend/routes/posts.js` - правильно сохраняет `attachedTrack` в пост +- `backend/routes/posts.js` - правильно populate'ит `attachedTrack` с `artist` и `album` при получении постов + +**Статус:** Работает корректно, `attachedTrackId` передается через FormData и сохраняется в MongoDB. + +## 📝 Дополнительные изменения + +### Формирование URL для файлов +Все компоненты, которые отображают обложки треков, теперь: +1. Проверяют, является ли URL абсолютным (начинается с `http`) +2. Если относительный, добавляют базовый URL из `VITE_API_URL` +3. Обрабатывают ошибки загрузки изображений + +### Обработка ошибок +Добавлена обработка ошибок при загрузке изображений: +- При ошибке изображение скрывается +- Показывается fallback иконка `Music` + +## 🔍 Что проверить + +1. **Воспроизведение музыки:** + - Убедитесь, что треки воспроизводятся при клике + - Проверьте работу мини-плеера и полного плеера + +2. **Загрузка альбомов:** + - Попробуйте загрузить ZIP альбом размером до 105MB + - Проверьте извлечение метаданных и обложек + +3. **Обложки треков:** + - Проверьте отображение обложек в списке треков + - Проверьте обложки в постах с треками + - Проверьте обложки в мини-плеере и полном плеере + +4. **Создание постов с треками:** + - Создайте пост с прикрепленным треком + - Проверьте отображение трека в посте + - Проверьте воспроизведение трека из поста + +5. **Иконка лисы:** + - Проверьте отображение иконки лисы на странице Media + - Иконка должна быть видна как лиса в стиле аниме + +--- + +**Все исправления готовы к тестированию! ✅** + diff --git a/CHANGES_SUMMARY.md b/CHANGES_SUMMARY.md index 70e854c..4e4d11c 100644 --- a/CHANGES_SUMMARY.md +++ b/CHANGES_SUMMARY.md @@ -1,174 +1,174 @@ -# 📝 Сводка изменений - -## ✅ Исправлено - -### 1. **Кнопки Media - 2 в ряд** -- Изменено: `grid-template-columns: repeat(2, 1fr)` в `Media.css` -- Удалены адаптивные медиа-запросы -- Теперь всегда отображается ровно 2 кнопки в ряд - -### 2. **Заголовки выровнены слева** -- `Media.css` - заголовок "Media" слева -- `MediaSearch.css` - заголовки "Furry", "Anime" слева -- `MediaMusic.css` - заголовок "Music" слева -- Все заголовки черного цвета (`color: var(--text-primary)`) - -### 3. **Иконки обновлены** -- **Furry**: 🦊 Лиса (кастомный SVG FoxIcon) -- **Anime**: 👤 Человек (User из lucide-react) -- **Music**: 🎵 Музыка (Music из lucide-react) - -### 4. **Загрузка треков и альбомов добавлена** -Создан модал `UploadTrackModal` с функциями: -- ✅ Выбор аудио файла (MP3, WAV, OGG, M4A, FLAC) - макс 50MB -- ✅ Выбор ZIP архива (альбом) - макс 100MB -- ✅ **Автоматическое извлечение метаданных из ID3 тегов:** - - Название трека - - Исполнитель - - Альбом - - Год - - Жанр - - Номер трека - - Обложка (из встроенных изображений) - - Длительность -- ✅ Редактирование всех полей при загрузке -- ✅ Поддержка вложенных папок в ZIP -- ✅ Кнопка Upload в табах Music (справа) - -### 5. **Музыка - вкладки переорганизованы** -Было: `Поиск | Все треки | Избранное` -Стало: `Избранное | Обзор | [Upload]` - -- **Избранное** - на первом месте -- **Обзор** - объединяет поиск и все треки -- **Upload** - кнопка загрузки (иконка) - -### 6. **Прикрепление музыки к посту** -- ✅ Кнопка Music (🎵) в CreatePostModal -- ✅ Модал выбора трека (MusicPickerModal) -- ✅ Поиск треков или выбор из избранного -- ✅ Отображение прикрепленного трека в посте (MusicAttachment) -- ✅ Воспроизведение через общий плеер - -### 7. **Темная тема** -Добавлена полная поддержка темной темы для: -- ✅ Media карточки -- ✅ MediaSearch страницы -- ✅ MediaMusic страницы -- ✅ UploadTrackModal -- ✅ MusicPickerModal -- ✅ MusicAttachment -- ✅ MiniPlayer -- ✅ FullPlayer -- ✅ PostCard с музыкой - -## 📁 Новые файлы - -### Components -- `frontend/src/components/UploadTrackModal.jsx` - модал загрузки трека -- `frontend/src/components/UploadTrackModal.css` - стили -- `frontend/src/components/MusicPickerModal.jsx` - выбор трека для поста -- `frontend/src/components/MusicPickerModal.css` - стили -- `frontend/src/components/MusicAttachment.jsx` - отображение трека -- `frontend/src/components/MusicAttachment.css` - стили -- `frontend/src/components/MiniPlayer.jsx` - мини-плеер -- `frontend/src/components/MiniPlayer.css` - стили -- `frontend/src/components/FullPlayer.jsx` - полный плеер -- `frontend/src/components/FullPlayer.css` - стили - -### Contexts -- `frontend/src/contexts/MusicPlayerContext.jsx` - управление плеером - -### Pages -- `frontend/src/pages/Media.jsx` - главная Media страница -- `frontend/src/pages/Media.css` - стили -- `frontend/src/pages/MediaFurry.jsx` - Furry поиск -- `frontend/src/pages/MediaAnime.jsx` - Anime поиск -- `frontend/src/pages/MediaMusic.jsx` - Music сервис -- `frontend/src/pages/MediaMusic.css` - стили -- `frontend/src/pages/MediaSearch.css` - общие стили для Furry/Anime - -### Utils -- `frontend/src/utils/musicApi.js` - API функции для музыки - -### Backend -- `backend/models/Artist.js` - модель исполнителя -- `backend/models/Album.js` - модель альбома -- `backend/models/Track.js` - модель трека -- `backend/models/FavoriteTrack.js` - избранные треки -- `backend/routes/music.js` - routes для музыки -- `backend/middleware/devAuth.js` - dev авторизация - -### Documentation -- `WIND.md` - инструкция для Windows -- `DEV_SETUP.md` - настройка dev режима -- `QUICK_DEV_START.md` - быстрый старт -- `MUSIC_SETUP.md` - настройка музыки -- `CHANGES_SUMMARY.md` - этот файл - -## 🎨 Дизайн - -### Цвета -- Furry: `#FF8A33` (оранжевый) -- Anime: `#4A90E2` (синий) -- Music: `#9b59b6` (фиолетовый) - -### Темная тема -Все компоненты используют CSS переменные: -- `var(--bg-primary)` - основной фон -- `var(--bg-secondary)` - вторичный фон -- `var(--text-primary)` - основной текст -- `var(--text-secondary)` - вторичный текст -- `var(--divider-color)` - разделители -- `var(--border-color)` - границы - -## 🔧 Как использовать - -### Загрузка трека -1. Откройте Media → Music -2. Нажмите иконку Upload (справа в табах) -3. Выберите аудио файл -4. Отредактируйте метаданные (автоматически извлекаются из имени) -5. Нажмите "Загрузить" - -### Прикрепление музыки к посту -1. Создайте пост (кнопка +) -2. Нажмите иконку Music (🎵) -3. Выберите трек из избранного или найдите через поиск -4. Трек прикрепится к посту -5. Опубликуйте пост - -### Воспроизведение -- Нажмите Play на любом треке -- Мини-плеер появится внизу экрана -- Нажмите на мини-плеер для открытия полного плеера -- Управление: play/pause, next/prev, громкость, избранное, скачивание - -## 🚀 Dev режим - -Для разработки без Telegram: - -1. Создайте `.env`: -```env -DISABLE_TELEGRAM_AUTH=true -NODE_ENV=development -MONGODB_URI=mongodb://localhost:27017/nakama-dev -``` - -2. Создайте `frontend\.env`: -```env -VITE_API_URL=http://localhost:3000/api -VITE_MOCK_TELEGRAM=true -``` - -3. Запустите: -```cmd -npm run dev -``` - -Подробнее в `QUICK_DEV_START.md` - ---- - -**Все изменения готовы! 🎉** - +# 📝 Сводка изменений + +## ✅ Исправлено + +### 1. **Кнопки Media - 2 в ряд** +- Изменено: `grid-template-columns: repeat(2, 1fr)` в `Media.css` +- Удалены адаптивные медиа-запросы +- Теперь всегда отображается ровно 2 кнопки в ряд + +### 2. **Заголовки выровнены слева** +- `Media.css` - заголовок "Media" слева +- `MediaSearch.css` - заголовки "Furry", "Anime" слева +- `MediaMusic.css` - заголовок "Music" слева +- Все заголовки черного цвета (`color: var(--text-primary)`) + +### 3. **Иконки обновлены** +- **Furry**: 🦊 Лиса (кастомный SVG FoxIcon) +- **Anime**: 👤 Человек (User из lucide-react) +- **Music**: 🎵 Музыка (Music из lucide-react) + +### 4. **Загрузка треков и альбомов добавлена** +Создан модал `UploadTrackModal` с функциями: +- ✅ Выбор аудио файла (MP3, WAV, OGG, M4A, FLAC) - макс 50MB +- ✅ Выбор ZIP архива (альбом) - макс 100MB +- ✅ **Автоматическое извлечение метаданных из ID3 тегов:** + - Название трека + - Исполнитель + - Альбом + - Год + - Жанр + - Номер трека + - Обложка (из встроенных изображений) + - Длительность +- ✅ Редактирование всех полей при загрузке +- ✅ Поддержка вложенных папок в ZIP +- ✅ Кнопка Upload в табах Music (справа) + +### 5. **Музыка - вкладки переорганизованы** +Было: `Поиск | Все треки | Избранное` +Стало: `Избранное | Обзор | [Upload]` + +- **Избранное** - на первом месте +- **Обзор** - объединяет поиск и все треки +- **Upload** - кнопка загрузки (иконка) + +### 6. **Прикрепление музыки к посту** +- ✅ Кнопка Music (🎵) в CreatePostModal +- ✅ Модал выбора трека (MusicPickerModal) +- ✅ Поиск треков или выбор из избранного +- ✅ Отображение прикрепленного трека в посте (MusicAttachment) +- ✅ Воспроизведение через общий плеер + +### 7. **Темная тема** +Добавлена полная поддержка темной темы для: +- ✅ Media карточки +- ✅ MediaSearch страницы +- ✅ MediaMusic страницы +- ✅ UploadTrackModal +- ✅ MusicPickerModal +- ✅ MusicAttachment +- ✅ MiniPlayer +- ✅ FullPlayer +- ✅ PostCard с музыкой + +## 📁 Новые файлы + +### Components +- `frontend/src/components/UploadTrackModal.jsx` - модал загрузки трека +- `frontend/src/components/UploadTrackModal.css` - стили +- `frontend/src/components/MusicPickerModal.jsx` - выбор трека для поста +- `frontend/src/components/MusicPickerModal.css` - стили +- `frontend/src/components/MusicAttachment.jsx` - отображение трека +- `frontend/src/components/MusicAttachment.css` - стили +- `frontend/src/components/MiniPlayer.jsx` - мини-плеер +- `frontend/src/components/MiniPlayer.css` - стили +- `frontend/src/components/FullPlayer.jsx` - полный плеер +- `frontend/src/components/FullPlayer.css` - стили + +### Contexts +- `frontend/src/contexts/MusicPlayerContext.jsx` - управление плеером + +### Pages +- `frontend/src/pages/Media.jsx` - главная Media страница +- `frontend/src/pages/Media.css` - стили +- `frontend/src/pages/MediaFurry.jsx` - Furry поиск +- `frontend/src/pages/MediaAnime.jsx` - Anime поиск +- `frontend/src/pages/MediaMusic.jsx` - Music сервис +- `frontend/src/pages/MediaMusic.css` - стили +- `frontend/src/pages/MediaSearch.css` - общие стили для Furry/Anime + +### Utils +- `frontend/src/utils/musicApi.js` - API функции для музыки + +### Backend +- `backend/models/Artist.js` - модель исполнителя +- `backend/models/Album.js` - модель альбома +- `backend/models/Track.js` - модель трека +- `backend/models/FavoriteTrack.js` - избранные треки +- `backend/routes/music.js` - routes для музыки +- `backend/middleware/devAuth.js` - dev авторизация + +### Documentation +- `WIND.md` - инструкция для Windows +- `DEV_SETUP.md` - настройка dev режима +- `QUICK_DEV_START.md` - быстрый старт +- `MUSIC_SETUP.md` - настройка музыки +- `CHANGES_SUMMARY.md` - этот файл + +## 🎨 Дизайн + +### Цвета +- Furry: `#FF8A33` (оранжевый) +- Anime: `#4A90E2` (синий) +- Music: `#9b59b6` (фиолетовый) + +### Темная тема +Все компоненты используют CSS переменные: +- `var(--bg-primary)` - основной фон +- `var(--bg-secondary)` - вторичный фон +- `var(--text-primary)` - основной текст +- `var(--text-secondary)` - вторичный текст +- `var(--divider-color)` - разделители +- `var(--border-color)` - границы + +## 🔧 Как использовать + +### Загрузка трека +1. Откройте Media → Music +2. Нажмите иконку Upload (справа в табах) +3. Выберите аудио файл +4. Отредактируйте метаданные (автоматически извлекаются из имени) +5. Нажмите "Загрузить" + +### Прикрепление музыки к посту +1. Создайте пост (кнопка +) +2. Нажмите иконку Music (🎵) +3. Выберите трек из избранного или найдите через поиск +4. Трек прикрепится к посту +5. Опубликуйте пост + +### Воспроизведение +- Нажмите Play на любом треке +- Мини-плеер появится внизу экрана +- Нажмите на мини-плеер для открытия полного плеера +- Управление: play/pause, next/prev, громкость, избранное, скачивание + +## 🚀 Dev режим + +Для разработки без Telegram: + +1. Создайте `.env`: +```env +DISABLE_TELEGRAM_AUTH=true +NODE_ENV=development +MONGODB_URI=mongodb://localhost:27017/nakama-dev +``` + +2. Создайте `frontend\.env`: +```env +VITE_API_URL=http://localhost:3000/api +VITE_MOCK_TELEGRAM=true +``` + +3. Запустите: +```cmd +npm run dev +``` + +Подробнее в `QUICK_DEV_START.md` + +--- + +**Все изменения готовы! 🎉** + diff --git a/DEV_SETUP.md b/DEV_SETUP.md index e02a6a3..e23527d 100644 --- a/DEV_SETUP.md +++ b/DEV_SETUP.md @@ -283,3 +283,4 @@ npm run dev **Удачной разработки! 🚀** + diff --git a/MUSIC_SETUP.md b/MUSIC_SETUP.md index 6f0c9a5..8020911 100644 --- a/MUSIC_SETUP.md +++ b/MUSIC_SETUP.md @@ -1,162 +1,162 @@ -# Настройка музыкального модуля Nakama - -## Установка зависимостей - -Для работы музыкального модуля требуется установить дополнительные пакеты: - -```bash -npm install adm-zip music-metadata -``` - -- **adm-zip** - для распаковки ZIP-архивов при загрузке альбомов -- **music-metadata** - для извлечения метаданных (название, исполнитель, обложка) из аудио файлов - -## Структура файлов - -### 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 - -**Загрузка:** -- Загрузка отдельных треков (MP3, WAV, OGG, M4A, FLAC) -- Загрузка альбомов из ZIP архива -- Автоматическое извлечение метаданных из аудио файлов: - - Название трека - - Исполнитель - - Альбом - - Год - - Жанр - - Номер трека - - Обложка (из ID3 тегов) -- Возможность редактирования метаданных при загрузке -- Автоматическое создание исполнителей и альбомов - -**Поиск:** -- Поиск по трекам, исполнителям, альбомам -- Фильтрация результатов - -**Плеер:** -- Мини-плеер (закреплен внизу) -- Полный плеер (открывается по клику) -- Управление воспроизведением (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. Треки в постах воспроизводятся через общий плеер - +# Настройка музыкального модуля Nakama + +## Установка зависимостей + +Для работы музыкального модуля требуется установить дополнительные пакеты: + +```bash +npm install adm-zip music-metadata +``` + +- **adm-zip** - для распаковки ZIP-архивов при загрузке альбомов +- **music-metadata** - для извлечения метаданных (название, исполнитель, обложка) из аудио файлов + +## Структура файлов + +### 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 + +**Загрузка:** +- Загрузка отдельных треков (MP3, WAV, OGG, M4A, FLAC) +- Загрузка альбомов из ZIP архива +- Автоматическое извлечение метаданных из аудио файлов: + - Название трека + - Исполнитель + - Альбом + - Год + - Жанр + - Номер трека + - Обложка (из ID3 тегов) +- Возможность редактирования метаданных при загрузке +- Автоматическое создание исполнителей и альбомов + +**Поиск:** +- Поиск по трекам, исполнителям, альбомам +- Фильтрация результатов + +**Плеер:** +- Мини-плеер (закреплен внизу) +- Полный плеер (открывается по клику) +- Управление воспроизведением (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/QUICK_DEV_START.md b/QUICK_DEV_START.md index 2e2998b..5430738 100644 --- a/QUICK_DEV_START.md +++ b/QUICK_DEV_START.md @@ -1,221 +1,221 @@ -# ⚡ Быстрый старт DEV версии (без Telegram) - -## 🚀 Запуск за 5 минут - -### Шаг 1: Создайте `.env` файл - -В корне проекта создайте файл `.env` с содержимым: - -```env -DISABLE_TELEGRAM_AUTH=true -NODE_ENV=development -PORT=3000 -MONGODB_URI=mongodb://localhost:27017/nakama-dev - -JWT_SECRET=dev_jwt_secret_32chars_minimum_length -JWT_ACCESS_SECRET=dev_access_secret_32chars_minimum -JWT_REFRESH_SECRET=dev_refresh_secret_32chars_minimum - -TELEGRAM_BOT_TOKEN= -FRONTEND_URL=http://localhost:5173 -VITE_API_URL=http://localhost:3000/api -CORS_ORIGIN=* - -MINIO_ENABLED=false -RATE_LIMIT_GENERAL=1000 -RATE_LIMIT_POSTS=100 -``` - -### Шаг 2: Создайте `frontend\.env` - -В папке `frontend` создайте файл `.env`: - -```env -VITE_API_URL=http://localhost:3000/api -VITE_MOCK_TELEGRAM=true -``` - -### Шаг 3: Обновите `frontend\src\utils\telegram.js` - -Найдите функцию `initTelegramApp` и в самом начале добавьте: - -```javascript -export const initTelegramApp = () => { - // 🔥 DEV MODE: Mock Telegram WebApp - if (import.meta.env.DEV && import.meta.env.VITE_MOCK_TELEGRAM === 'true') { - if (!window.Telegram?.WebApp) { - 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=1640000000&hash=mockhash'; - - window.Telegram = { - WebApp: { - initData: mockInitData, - initDataUnsafe: { - user: { - id: 123456789, - first_name: 'Dev', - last_name: 'User', - username: 'devuser' - } - }, - version: '7.0', - platform: 'web', - colorScheme: 'light', - themeParams: {}, - isExpanded: true, - viewportHeight: 600, - viewportStableHeight: 600, - ready: () => console.log('Mock ready'), - expand: () => {}, - close: () => {}, - disableVerticalSwipes: () => {}, - BackButton: { isVisible: false }, - MainButton: { isVisible: false }, - HapticFeedback: { - impactOccurred: () => {}, - notificationOccurred: () => {}, - selectionChanged: () => {} - } - } - }; - console.log('✅ Mock Telegram WebApp (DEV MODE)'); - } - } - - const tg = window.Telegram?.WebApp - // ... остальной код без изменений -``` - -### Шаг 4: Запустите MongoDB - -```cmd -net start MongoDB -``` - -### Шаг 5: Установите зависимости - -```cmd -npm install -npm install adm-zip music-metadata -cd frontend -npm install -cd .. -``` - -### Шаг 6: Запустите приложение - -```cmd -npm run dev -``` - -### Шаг 7: Откройте браузер - -``` -http://localhost:5173 -``` - -✅ **Готово!** Приложение работает без Telegram! - ---- - -## 🎯 Что происходит? - -1. `DISABLE_TELEGRAM_AUTH=true` - отключает проверку initData на backend -2. `backend/middleware/devAuth.js` - создает тестового пользователя (ID: 123456789) -3. `VITE_MOCK_TELEGRAM=true` - включает mock Telegram WebApp на frontend -4. Mock создает фиктивный `window.Telegram.WebApp` объект - ---- - -## 🧪 Тестирование - -### Проверьте что dev режим работает: - -**Backend консоль должна показать:** -``` -⚠️ DEV MODE ENABLED - Telegram auth disabled! -⚠️ DEV MODE: Telegram auth disabled -✅ Created dev user: DevUser (123456789) -``` - -**Browser консоль должна показать:** -``` -✅ Mock Telegram WebApp (DEV MODE) -``` - -### Тест API: - -Откройте: `http://localhost:3000/api/posts` - -Должен вернуться список постов (может быть пустой). - ---- - -## 📁 Структура - -``` -nakama/ -├── .env ← Создайте (dev конфиг) -├── backend/ -│ └── middleware/ -│ └── devAuth.js ← Уже создан -├── frontend/ -│ ├── .env ← Создайте (dev конфиг) -│ └── src/utils/ -│ └── telegram.js ← Обновите -├── DEV_SETUP.md ← Подробная инструкция -└── QUICK_DEV_START.md ← Эта инструкция -``` - ---- - -## ⚠️ Важно - -- **НЕ коммитьте** `.env` файлы в git -- **НЕ используйте** dev конфигурацию в production -- Для production используйте настоящий `TELEGRAM_BOT_TOKEN` - ---- - -## 🔄 Вернуться к production - -1. Удалите или измените `.env`: -```env -DISABLE_TELEGRAM_AUTH=false -TELEGRAM_BOT_TOKEN=your_real_token -``` - -2. Перезапустите: -```cmd -npm run server -``` - ---- - -## 🆘 Проблемы? - -### MongoDB не запускается -```cmd -cd "C:\Program Files\MongoDB\Server\7.0\bin" -if not exist C:\data\db mkdir C:\data\db -mongod --dbpath C:\data\db -``` - -### Port 3000 занят -```cmd -netstat -ano | findstr :3000 -taskkill /PID /F -``` - -### Dev user не создается - -Проверьте `.env`: -```cmd -type .env | findstr DISABLE_TELEGRAM_AUTH -``` - -Должно быть: `DISABLE_TELEGRAM_AUTH=true` - ---- - -**Быстрого старта! 🚀** - +# ⚡ Быстрый старт DEV версии (без Telegram) + +## 🚀 Запуск за 5 минут + +### Шаг 1: Создайте `.env` файл + +В корне проекта создайте файл `.env` с содержимым: + +```env +DISABLE_TELEGRAM_AUTH=true +NODE_ENV=development +PORT=3000 +MONGODB_URI=mongodb://localhost:27017/nakama-dev + +JWT_SECRET=dev_jwt_secret_32chars_minimum_length +JWT_ACCESS_SECRET=dev_access_secret_32chars_minimum +JWT_REFRESH_SECRET=dev_refresh_secret_32chars_minimum + +TELEGRAM_BOT_TOKEN= +FRONTEND_URL=http://localhost:5173 +VITE_API_URL=http://localhost:3000/api +CORS_ORIGIN=* + +MINIO_ENABLED=false +RATE_LIMIT_GENERAL=1000 +RATE_LIMIT_POSTS=100 +``` + +### Шаг 2: Создайте `frontend\.env` + +В папке `frontend` создайте файл `.env`: + +```env +VITE_API_URL=http://localhost:3000/api +VITE_MOCK_TELEGRAM=true +``` + +### Шаг 3: Обновите `frontend\src\utils\telegram.js` + +Найдите функцию `initTelegramApp` и в самом начале добавьте: + +```javascript +export const initTelegramApp = () => { + // 🔥 DEV MODE: Mock Telegram WebApp + if (import.meta.env.DEV && import.meta.env.VITE_MOCK_TELEGRAM === 'true') { + if (!window.Telegram?.WebApp) { + 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=1640000000&hash=mockhash'; + + window.Telegram = { + WebApp: { + initData: mockInitData, + initDataUnsafe: { + user: { + id: 123456789, + first_name: 'Dev', + last_name: 'User', + username: 'devuser' + } + }, + version: '7.0', + platform: 'web', + colorScheme: 'light', + themeParams: {}, + isExpanded: true, + viewportHeight: 600, + viewportStableHeight: 600, + ready: () => console.log('Mock ready'), + expand: () => {}, + close: () => {}, + disableVerticalSwipes: () => {}, + BackButton: { isVisible: false }, + MainButton: { isVisible: false }, + HapticFeedback: { + impactOccurred: () => {}, + notificationOccurred: () => {}, + selectionChanged: () => {} + } + } + }; + console.log('✅ Mock Telegram WebApp (DEV MODE)'); + } + } + + const tg = window.Telegram?.WebApp + // ... остальной код без изменений +``` + +### Шаг 4: Запустите MongoDB + +```cmd +net start MongoDB +``` + +### Шаг 5: Установите зависимости + +```cmd +npm install +npm install adm-zip music-metadata +cd frontend +npm install +cd .. +``` + +### Шаг 6: Запустите приложение + +```cmd +npm run dev +``` + +### Шаг 7: Откройте браузер + +``` +http://localhost:5173 +``` + +✅ **Готово!** Приложение работает без Telegram! + +--- + +## 🎯 Что происходит? + +1. `DISABLE_TELEGRAM_AUTH=true` - отключает проверку initData на backend +2. `backend/middleware/devAuth.js` - создает тестового пользователя (ID: 123456789) +3. `VITE_MOCK_TELEGRAM=true` - включает mock Telegram WebApp на frontend +4. Mock создает фиктивный `window.Telegram.WebApp` объект + +--- + +## 🧪 Тестирование + +### Проверьте что dev режим работает: + +**Backend консоль должна показать:** +``` +⚠️ DEV MODE ENABLED - Telegram auth disabled! +⚠️ DEV MODE: Telegram auth disabled +✅ Created dev user: DevUser (123456789) +``` + +**Browser консоль должна показать:** +``` +✅ Mock Telegram WebApp (DEV MODE) +``` + +### Тест API: + +Откройте: `http://localhost:3000/api/posts` + +Должен вернуться список постов (может быть пустой). + +--- + +## 📁 Структура + +``` +nakama/ +├── .env ← Создайте (dev конфиг) +├── backend/ +│ └── middleware/ +│ └── devAuth.js ← Уже создан +├── frontend/ +│ ├── .env ← Создайте (dev конфиг) +│ └── src/utils/ +│ └── telegram.js ← Обновите +├── DEV_SETUP.md ← Подробная инструкция +└── QUICK_DEV_START.md ← Эта инструкция +``` + +--- + +## ⚠️ Важно + +- **НЕ коммитьте** `.env` файлы в git +- **НЕ используйте** dev конфигурацию в production +- Для production используйте настоящий `TELEGRAM_BOT_TOKEN` + +--- + +## 🔄 Вернуться к production + +1. Удалите или измените `.env`: +```env +DISABLE_TELEGRAM_AUTH=false +TELEGRAM_BOT_TOKEN=your_real_token +``` + +2. Перезапустите: +```cmd +npm run server +``` + +--- + +## 🆘 Проблемы? + +### MongoDB не запускается +```cmd +cd "C:\Program Files\MongoDB\Server\7.0\bin" +if not exist C:\data\db mkdir C:\data\db +mongod --dbpath C:\data\db +``` + +### Port 3000 занят +```cmd +netstat -ano | findstr :3000 +taskkill /PID /F +``` + +### Dev user не создается + +Проверьте `.env`: +```cmd +type .env | findstr DISABLE_TELEGRAM_AUTH +``` + +Должно быть: `DISABLE_TELEGRAM_AUTH=true` + +--- + +**Быстрого старта! 🚀** + diff --git a/UPDATE_SUMMARY.md b/UPDATE_SUMMARY.md index 97e875d..b8968ac 100644 --- a/UPDATE_SUMMARY.md +++ b/UPDATE_SUMMARY.md @@ -81,3 +81,4 @@ npm list adm-zip music-metadata **Готово к production! ✅** + diff --git a/WIND.md b/WIND.md index c384424..99acf12 100644 --- a/WIND.md +++ b/WIND.md @@ -1,679 +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 music-metadata -``` - -### 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'" или "Cannot find module 'music-metadata'" - -**Решение:** -```cmd -npm install adm-zip music-metadata -``` - -### 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 music-metadata` выполнен -- [ ] `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 в репозитории - ---- - -**Удачной разработки! 🚀** - +# 🪟 Инструкция по развертыванию 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 music-metadata +``` + +### 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'" или "Cannot find module 'music-metadata'" + +**Решение:** +```cmd +npm install adm-zip music-metadata +``` + +### 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 music-metadata` выполнен +- [ ] `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/models/Album.js b/backend/models/Album.js index c336583..54e7c6f 100644 --- a/backend/models/Album.js +++ b/backend/models/Album.js @@ -52,3 +52,4 @@ albumSchema.index({ createdAt: -1 }); module.exports = mongoose.model('Album', albumSchema); + diff --git a/backend/models/Artist.js b/backend/models/Artist.js index 3e9aca5..de5b98c 100644 --- a/backend/models/Artist.js +++ b/backend/models/Artist.js @@ -52,3 +52,4 @@ artistSchema.pre('save', function(next) { module.exports = mongoose.model('Artist', artistSchema); + diff --git a/backend/models/FavoriteTrack.js b/backend/models/FavoriteTrack.js index 1fcd69e..42fba12 100644 --- a/backend/models/FavoriteTrack.js +++ b/backend/models/FavoriteTrack.js @@ -23,3 +23,4 @@ favoriteTrackSchema.index({ createdAt: -1 }); module.exports = mongoose.model('FavoriteTrack', favoriteTrackSchema); + diff --git a/backend/models/Track.js b/backend/models/Track.js index f12952f..65fb075 100644 --- a/backend/models/Track.js +++ b/backend/models/Track.js @@ -74,3 +74,4 @@ trackSchema.index({ 'stats.plays': -1 }); module.exports = mongoose.model('Track', trackSchema); + diff --git a/backend/routes/music.js b/backend/routes/music.js index 4953c3c..7e745cc 100644 --- a/backend/routes/music.js +++ b/backend/routes/music.js @@ -1,612 +1,612 @@ -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 mm = require('music-metadata'); -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: 100 * 1024 * 1024 // 100MB для ZIP альбомов - }, - fileFilter: (req, file, cb) => { - const allowedMimeTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/mp4', 'audio/m4a', 'application/zip', 'application/x-zip-compressed']; - const ext = path.extname(file.originalname).toLowerCase(); - const allowedExts = ['.mp3', '.wav', '.ogg', '.m4a', '.flac', '.zip']; - - if (allowedMimeTypes.includes(file.mimetype) || allowedExts.includes(ext)) { - cb(null, true); - } else { - cb(new Error('Неподдерживаемый формат файла')); - } - } -}); - -// Извлечь метаданные из аудио файла -async function extractAudioMetadata(filePath) { - try { - const metadata = await mm.parseFile(filePath); - - // Извлечь обложку если есть - let coverImagePath = null; - if (metadata.common.picture && metadata.common.picture.length > 0) { - const picture = metadata.common.picture[0]; - const coverFileName = `cover-${Date.now()}.${picture.format.split('/')[1] || 'jpg'}`; - coverImagePath = path.join(__dirname, '../uploads/music', coverFileName); - await fs.writeFile(coverImagePath, picture.data); - coverImagePath = `/uploads/music/${coverFileName}`; - } - - return { - title: metadata.common.title || null, - artist: metadata.common.artist || metadata.common.artists?.[0] || null, - album: metadata.common.album || null, - year: metadata.common.year || null, - genre: metadata.common.genre?.[0] || null, - trackNumber: metadata.common.track?.no || null, - duration: metadata.format.duration || 0, - coverImage: coverImagePath - }; - } catch (error) { - console.error('Ошибка извлечения метаданных:', error); - return { - title: null, - artist: null, - album: null, - year: null, - genre: null, - trackNumber: null, - duration: 0, - coverImage: null - }; - } -} - -// Вспомогательная функция для поиска или создания исполнителя -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: 'Файл не загружен' }); - } - - let { title, artistName, albumTitle, trackNumber, year, genre } = req.body; - - // Извлечь метаданные из файла - const metadata = await extractAudioMetadata(req.file.path); - - // Использовать метаданные если поля не заполнены - title = title || metadata.title || path.basename(req.file.originalname, path.extname(req.file.originalname)); - artistName = artistName || metadata.artist || 'Unknown Artist'; - albumTitle = albumTitle || metadata.album; - trackNumber = trackNumber || metadata.trackNumber; - year = year || metadata.year; - genre = genre || metadata.genre; - - // Найти или создать исполнителя - 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, - coverImage: metadata.coverImage, - 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, - coverImage: metadata.coverImage || (album ? album.coverImage : null), - file: { - size: req.file.size, - mimeType: req.file.mimetype, - duration: Math.round(metadata.duration) - }, - 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; - album.stats.duration += Math.round(metadata.duration); - 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 файл не загружен' }); - } - - const ext = path.extname(req.file.originalname).toLowerCase(); - if (ext !== '.zip' && req.file.mimetype !== 'application/zip' && req.file.mimetype !== 'application/x-zip-compressed') { - await fs.unlink(req.file.path).catch(() => {}); - return res.status(400).json({ error: 'Требуется ZIP файл' }); - } - - let { artistName, albumTitle, year, genre } = req.body; - - // Распаковать 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 uploadDir = path.join(__dirname, '../uploads/music'); - - // Извлечь первый трек для получения метаданных альбома - const firstEntry = audioFiles[0]; - const firstFileName = path.basename(firstEntry.entryName); - const firstTempName = `temp-${Date.now()}${path.extname(firstFileName)}`; - const firstTempPath = path.join(uploadDir, firstTempName); - - zip.extractEntryTo(firstEntry, uploadDir, false, true, false, firstTempName); - - // Извлечь метаданные из первого трека - const firstMetadata = await extractAudioMetadata(firstTempPath); - - // Использовать метаданные для альбома если не указаны - artistName = artistName || firstMetadata.artist || 'Unknown Artist'; - albumTitle = albumTitle || firstMetadata.album || path.basename(req.file.originalname, '.zip'); - year = year || firstMetadata.year; - genre = genre || firstMetadata.genre; - - const albumCoverImage = firstMetadata.coverImage; - - // Удалить временный файл - await fs.unlink(firstTempPath).catch(() => {}); - - // Найти или создать исполнителя - const artist = await findOrCreateArtist(artistName, req.user._id); - - // Создать альбом - const album = await Album.create({ - title: albumTitle, - artist: artist._id, - coverImage: albumCoverImage, - year: year ? parseInt(year) : null, - genre: genre || '', - addedBy: req.user._id - }); - - // Извлечь и сохранить треки - const tracks = []; - let totalDuration = 0; - - 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 trackMetadata = await extractAudioMetadata(filePath); - - // Получить размер файла - const stats = await fs.stat(filePath); - - // Определить название трека - const trackTitle = trackMetadata.title || fileName.replace(ext, ''); - const trackArtist = trackMetadata.artist || artistName; - - // Если исполнитель трека отличается от исполнителя альбома, найти или создать - let trackArtistId = artist._id; - if (trackArtist !== artistName) { - const trackArtistObj = await findOrCreateArtist(trackArtist, req.user._id); - trackArtistId = trackArtistObj._id; - } - - // Создать трек - const track = await Track.create({ - title: trackTitle, - artist: trackArtistId, - album: album._id, - fileUrl: `/uploads/music/${newFileName}`, - coverImage: trackMetadata.coverImage || albumCoverImage, - file: { - size: stats.size, - mimeType: req.file.mimetype, - duration: Math.round(trackMetadata.duration) - }, - trackNumber: trackMetadata.trackNumber || (i + 1), - year: trackMetadata.year || year ? parseInt(year) : null, - genre: trackMetadata.genre || genre || '', - addedBy: req.user._id - }); - - tracks.push(track); - totalDuration += Math.round(trackMetadata.duration); - } - - // Удалить 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; - album.stats.duration = totalDuration; - await album.save(); - - // Populate для ответа - await album.populate('artist'); - - res.json({ - success: true, - album, - tracks, - message: `Загружено ${tracks.length} треков из альбома "${albumTitle}"` - }); - } catch (error) { - console.error('Ошибка загрузки альбома:', error); - - if (req.file) { - await fs.unlink(req.file.path).catch(() => {}); - } - - res.status(500).json({ error: 'Ошибка загрузки альбома: ' + error.message }); - } -}); - -// Поиск треков, исполнителей, альбомов -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; - +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 mm = require('music-metadata'); +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: 105 * 1024 * 1024 // 105MB для ZIP альбомов (соответствует express.json limit) + }, + fileFilter: (req, file, cb) => { + const allowedMimeTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/mp4', 'audio/m4a', 'application/zip', 'application/x-zip-compressed']; + const ext = path.extname(file.originalname).toLowerCase(); + const allowedExts = ['.mp3', '.wav', '.ogg', '.m4a', '.flac', '.zip']; + + if (allowedMimeTypes.includes(file.mimetype) || allowedExts.includes(ext)) { + cb(null, true); + } else { + cb(new Error('Неподдерживаемый формат файла')); + } + } +}); + +// Извлечь метаданные из аудио файла +async function extractAudioMetadata(filePath) { + try { + const metadata = await mm.parseFile(filePath); + + // Извлечь обложку если есть + let coverImagePath = null; + if (metadata.common.picture && metadata.common.picture.length > 0) { + const picture = metadata.common.picture[0]; + const coverFileName = `cover-${Date.now()}.${picture.format.split('/')[1] || 'jpg'}`; + coverImagePath = path.join(__dirname, '../uploads/music', coverFileName); + await fs.writeFile(coverImagePath, picture.data); + coverImagePath = `/uploads/music/${coverFileName}`; + } + + return { + title: metadata.common.title || null, + artist: metadata.common.artist || metadata.common.artists?.[0] || null, + album: metadata.common.album || null, + year: metadata.common.year || null, + genre: metadata.common.genre?.[0] || null, + trackNumber: metadata.common.track?.no || null, + duration: metadata.format.duration || 0, + coverImage: coverImagePath + }; + } catch (error) { + console.error('Ошибка извлечения метаданных:', error); + return { + title: null, + artist: null, + album: null, + year: null, + genre: null, + trackNumber: null, + duration: 0, + coverImage: null + }; + } +} + +// Вспомогательная функция для поиска или создания исполнителя +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: 'Файл не загружен' }); + } + + let { title, artistName, albumTitle, trackNumber, year, genre } = req.body; + + // Извлечь метаданные из файла + const metadata = await extractAudioMetadata(req.file.path); + + // Использовать метаданные если поля не заполнены + title = title || metadata.title || path.basename(req.file.originalname, path.extname(req.file.originalname)); + artistName = artistName || metadata.artist || 'Unknown Artist'; + albumTitle = albumTitle || metadata.album; + trackNumber = trackNumber || metadata.trackNumber; + year = year || metadata.year; + genre = genre || metadata.genre; + + // Найти или создать исполнителя + 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, + coverImage: metadata.coverImage, + 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, + coverImage: metadata.coverImage || (album ? album.coverImage : null), + file: { + size: req.file.size, + mimeType: req.file.mimetype, + duration: Math.round(metadata.duration) + }, + 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; + album.stats.duration += Math.round(metadata.duration); + 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 файл не загружен' }); + } + + const ext = path.extname(req.file.originalname).toLowerCase(); + if (ext !== '.zip' && req.file.mimetype !== 'application/zip' && req.file.mimetype !== 'application/x-zip-compressed') { + await fs.unlink(req.file.path).catch(() => {}); + return res.status(400).json({ error: 'Требуется ZIP файл' }); + } + + let { artistName, albumTitle, year, genre } = req.body; + + // Распаковать 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 uploadDir = path.join(__dirname, '../uploads/music'); + + // Извлечь первый трек для получения метаданных альбома + const firstEntry = audioFiles[0]; + const firstFileName = path.basename(firstEntry.entryName); + const firstTempName = `temp-${Date.now()}${path.extname(firstFileName)}`; + const firstTempPath = path.join(uploadDir, firstTempName); + + zip.extractEntryTo(firstEntry, uploadDir, false, true, false, firstTempName); + + // Извлечь метаданные из первого трека + const firstMetadata = await extractAudioMetadata(firstTempPath); + + // Использовать метаданные для альбома если не указаны + artistName = artistName || firstMetadata.artist || 'Unknown Artist'; + albumTitle = albumTitle || firstMetadata.album || path.basename(req.file.originalname, '.zip'); + year = year || firstMetadata.year; + genre = genre || firstMetadata.genre; + + const albumCoverImage = firstMetadata.coverImage; + + // Удалить временный файл + await fs.unlink(firstTempPath).catch(() => {}); + + // Найти или создать исполнителя + const artist = await findOrCreateArtist(artistName, req.user._id); + + // Создать альбом + const album = await Album.create({ + title: albumTitle, + artist: artist._id, + coverImage: albumCoverImage, + year: year ? parseInt(year) : null, + genre: genre || '', + addedBy: req.user._id + }); + + // Извлечь и сохранить треки + const tracks = []; + let totalDuration = 0; + + 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 trackMetadata = await extractAudioMetadata(filePath); + + // Получить размер файла + const stats = await fs.stat(filePath); + + // Определить название трека + const trackTitle = trackMetadata.title || fileName.replace(ext, ''); + const trackArtist = trackMetadata.artist || artistName; + + // Если исполнитель трека отличается от исполнителя альбома, найти или создать + let trackArtistId = artist._id; + if (trackArtist !== artistName) { + const trackArtistObj = await findOrCreateArtist(trackArtist, req.user._id); + trackArtistId = trackArtistObj._id; + } + + // Создать трек + const track = await Track.create({ + title: trackTitle, + artist: trackArtistId, + album: album._id, + fileUrl: `/uploads/music/${newFileName}`, + coverImage: trackMetadata.coverImage || albumCoverImage, + file: { + size: stats.size, + mimeType: req.file.mimetype, + duration: Math.round(trackMetadata.duration) + }, + trackNumber: trackMetadata.trackNumber || (i + 1), + year: trackMetadata.year || year ? parseInt(year) : null, + genre: trackMetadata.genre || genre || '', + addedBy: req.user._id + }); + + tracks.push(track); + totalDuration += Math.round(trackMetadata.duration); + } + + // Удалить 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; + album.stats.duration = totalDuration; + await album.save(); + + // Populate для ответа + await album.populate('artist'); + + res.json({ + success: true, + album, + tracks, + message: `Загружено ${tracks.length} треков из альбома "${albumTitle}"` + }); + } catch (error) { + console.error('Ошибка загрузки альбома:', error); + + if (req.file) { + await fs.unlink(req.file.path).catch(() => {}); + } + + res.status(500).json({ error: 'Ошибка загрузки альбома: ' + error.message }); + } +}); + +// Поиск треков, исполнителей, альбомов +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/server.js b/backend/server.js index df457e8..b88172c 100644 --- a/backend/server.js +++ b/backend/server.js @@ -61,8 +61,8 @@ app.use(cors(corsOptions)); app.use(cookieParser()); // Body parsing с ограничениями -app.use(express.json({ limit: '10mb' })); -app.use(express.urlencoded({ extended: true, limit: '10mb' })); +app.use(express.json({ limit: '105mb' })); +app.use(express.urlencoded({ extended: true, limit: '105mb' })); // Security middleware app.use(sanitizeMongo); // Защита от NoSQL injection diff --git a/frontend/src/components/FullPlayer.css b/frontend/src/components/FullPlayer.css index 7ab38dd..e8dc3cb 100644 --- a/frontend/src/components/FullPlayer.css +++ b/frontend/src/components/FullPlayer.css @@ -1,341 +1,341 @@ -.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; -} - -/* Адаптив */ -/* Темная тема */ -[data-theme="dark"] .full-player { - background: var(--bg-primary); -} - -[data-theme="dark"] .full-player-header { - background: var(--bg-secondary); - border-bottom-color: var(--divider-color); -} - -[data-theme="dark"] .full-player-cover { - background: var(--bg-secondary); - border: 1px solid var(--border-color); -} - -[data-theme="dark"] .full-player-control-btn:hover { - background: var(--bg-secondary); -} - -[data-theme="dark"] .full-player-action-btn:hover { - background: var(--bg-secondary); -} - -[data-theme="dark"] .full-player-volume { - background: var(--bg-secondary); - border: 1px solid var(--border-color); -} - -[data-theme="dark"] .full-player-progress-bar { - background: var(--border-color); -} - -/* Адаптив */ -@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; - } -} - +.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; +} + +/* Адаптив */ +/* Темная тема */ +[data-theme="dark"] .full-player { + background: var(--bg-primary); +} + +[data-theme="dark"] .full-player-header { + background: var(--bg-secondary); + border-bottom-color: var(--divider-color); +} + +[data-theme="dark"] .full-player-cover { + background: var(--bg-secondary); + border: 1px solid var(--border-color); +} + +[data-theme="dark"] .full-player-control-btn:hover { + background: var(--bg-secondary); +} + +[data-theme="dark"] .full-player-action-btn:hover { + background: var(--bg-secondary); +} + +[data-theme="dark"] .full-player-volume { + background: var(--bg-secondary); + border: 1px solid var(--border-color); +} + +[data-theme="dark"] .full-player-progress-bar { + background: var(--border-color); +} + +/* Адаптив */ +@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 index 08380be..e1f6935 100644 --- a/frontend/src/components/FullPlayer.jsx +++ b/frontend/src/components/FullPlayer.jsx @@ -135,10 +135,21 @@ export default function FullPlayer() {
{currentTrack.coverImage ? ( - {currentTrack.title} - ) : ( + {currentTrack.title} { + e.target.style.display = 'none' + e.target.nextElementSibling.style.display = 'flex' + }} + /> + ) : null} +
- )} +
@@ -239,3 +250,4 @@ export default function FullPlayer() { ) } + diff --git a/frontend/src/components/MiniPlayer.css b/frontend/src/components/MiniPlayer.css index fbfb253..9aecbc5 100644 --- a/frontend/src/components/MiniPlayer.css +++ b/frontend/src/components/MiniPlayer.css @@ -1,119 +1,120 @@ -.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); -} - -/* Темная тема */ -[data-theme="dark"] .mini-player { - background: var(--bg-secondary); - border-top-color: var(--divider-color); -} - -[data-theme="dark"] .mini-player-cover { - background: var(--bg-primary); - border: 1px solid var(--border-color); -} - -[data-theme="dark"] .mini-player-btn:hover { - background: var(--bg-primary); -} - +.mini-player { + position: fixed; + bottom: 70px; + left: 0; + right: 0; + background: var(--bg-secondary); + border-top: 1px solid var(--divider-color); + z-index: 60; + cursor: pointer; + transition: transform 0.3s; + box-shadow: 0 -2px 12px var(--shadow-md); + padding-bottom: env(safe-area-inset-bottom); +} + +.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); +} + +/* Темная тема */ +[data-theme="dark"] .mini-player { + background: var(--bg-secondary); + border-top-color: var(--divider-color); +} + +[data-theme="dark"] .mini-player-cover { + background: var(--bg-primary); + border: 1px solid var(--border-color); +} + +[data-theme="dark"] .mini-player-btn:hover { + background: var(--bg-primary); +} + diff --git a/frontend/src/components/MiniPlayer.jsx b/frontend/src/components/MiniPlayer.jsx index 2f7d963..74c2f32 100644 --- a/frontend/src/components/MiniPlayer.jsx +++ b/frontend/src/components/MiniPlayer.jsx @@ -42,10 +42,21 @@ export default function MiniPlayer() {
{currentTrack.coverImage ? ( - {currentTrack.title} - ) : ( + {currentTrack.title} { + e.target.style.display = 'none' + e.target.nextElementSibling.style.display = 'flex' + }} + /> + ) : null} +
- )} +
@@ -66,3 +77,4 @@ export default function MiniPlayer() { ) } + diff --git a/frontend/src/components/MusicAttachment.css b/frontend/src/components/MusicAttachment.css index 21e3770..11add9f 100644 --- a/frontend/src/components/MusicAttachment.css +++ b/frontend/src/components/MusicAttachment.css @@ -1,113 +1,113 @@ -.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; -} - -/* Темная тема */ -[data-theme="dark"] .music-attachment { - background: var(--bg-secondary); - border-color: var(--border-color); -} - -[data-theme="dark"] .music-attachment:hover { - background: var(--bg-primary); -} - -[data-theme="dark"] .music-attachment-cover { - background: var(--bg-primary); - border: 1px solid var(--border-color); -} - -[data-theme="dark"] .music-attachment-play:hover, -[data-theme="dark"] .music-attachment-remove:hover { - background: rgba(155, 89, 182, 0.1); -} - +.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; +} + +/* Темная тема */ +[data-theme="dark"] .music-attachment { + background: var(--bg-secondary); + border-color: var(--border-color); +} + +[data-theme="dark"] .music-attachment:hover { + background: var(--bg-primary); +} + +[data-theme="dark"] .music-attachment-cover { + background: var(--bg-primary); + border: 1px solid var(--border-color); +} + +[data-theme="dark"] .music-attachment-play:hover, +[data-theme="dark"] .music-attachment-remove:hover { + background: rgba(155, 89, 182, 0.1); +} + diff --git a/frontend/src/components/MusicAttachment.jsx b/frontend/src/components/MusicAttachment.jsx index d7edbb9..9b70377 100644 --- a/frontend/src/components/MusicAttachment.jsx +++ b/frontend/src/components/MusicAttachment.jsx @@ -24,10 +24,21 @@ export default function MusicAttachment({ track, onRemove, showRemove = false })
{track.coverImage ? ( - {track.title} - ) : ( + {track.title} { + e.target.style.display = 'none' + e.target.nextElementSibling.style.display = 'flex' + }} + /> + ) : null} +
- )} +
@@ -48,3 +59,4 @@ export default function MusicAttachment({ track, onRemove, showRemove = false }) ) } + diff --git a/frontend/src/components/MusicPickerModal.css b/frontend/src/components/MusicPickerModal.css index 9a172d8..085f82c 100644 --- a/frontend/src/components/MusicPickerModal.css +++ b/frontend/src/components/MusicPickerModal.css @@ -1,236 +1,236 @@ -.music-picker-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.8); - display: flex; - align-items: center; - justify-content: center; - z-index: 1001; - padding: 20px; - animation: fadeIn 0.2s ease-out; -} - -/* Темная тема */ -[data-theme="dark"] .music-picker-overlay { - background: rgba(0, 0, 0, 0.9); -} - -.music-picker-modal { - background: var(--bg-secondary); - border-radius: 16px; - width: 100%; - max-width: 500px; - max-height: 80vh; - display: flex; - flex-direction: column; - box-shadow: 0 8px 32px var(--shadow-lg); - animation: slideUp 0.3s ease-out; -} - -.music-picker-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 20px; - border-bottom: 1px solid var(--divider-color); -} - -.music-picker-header h2 { - font-size: 18px; - font-weight: 600; - color: var(--text-primary); - margin: 0; -} - -.music-picker-tabs { - display: flex; - border-bottom: 1px solid var(--divider-color); - padding: 0 20px; -} - -.picker-tab { - flex: 1; - background: none; - border: none; - padding: 12px; - font-size: 14px; - font-weight: 500; - color: var(--text-secondary); - cursor: pointer; - border-bottom: 2px solid transparent; - transition: all 0.2s; - display: flex; - align-items: center; - justify-content: center; - gap: 6px; -} - -.picker-tab.active { - color: #9b59b6; - border-bottom-color: #9b59b6; -} - -.music-picker-content { - flex: 1; - overflow-y: auto; - padding: 16px; -} - -.search-section { - display: flex; - flex-direction: column; - gap: 16px; -} - -.search-input-wrapper { - display: flex; - align-items: center; - gap: 8px; - background: var(--bg-primary); - border-radius: 8px; - padding: 10px 12px; -} - -.search-input-wrapper input { - flex: 1; - background: none; - border: none; - outline: none; - font-size: 14px; - color: var(--text-primary); -} - -.search-input-wrapper input::placeholder { - color: var(--text-secondary); -} - -.search-input-wrapper button { - background: #9b59b6; - border: none; - color: white; - padding: 6px 16px; - border-radius: 6px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; -} - -.search-input-wrapper button:hover:not(:disabled) { - background: #8e44ad; -} - -.search-input-wrapper button:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.tracks-list-picker { - display: flex; - flex-direction: column; - gap: 8px; -} - -.music-picker-track { - display: flex; - align-items: center; - gap: 12px; - padding: 12px; - background: var(--bg-primary); - border-radius: 8px; - cursor: pointer; - transition: all 0.2s; -} - -.music-picker-track:hover { - background: var(--bg-secondary); - box-shadow: 0 2px 8px var(--shadow-sm); -} - -.music-picker-track:active { - transform: scale(0.98); -} - -.track-cover-small { - width: 40px; - height: 40px; - border-radius: 6px; - background: var(--bg-secondary); - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; - flex-shrink: 0; -} - -.track-cover-small img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.track-cover-small svg { - color: var(--text-secondary); -} - -.track-info-small { - flex: 1; - min-width: 0; -} - -.track-title-small { - font-size: 14px; - font-weight: 500; - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.track-artist-small { - font-size: 12px; - color: var(--text-secondary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.picker-loading, -.picker-empty { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 40px 20px; - gap: 12px; - text-align: center; -} - -.picker-loading p, -.picker-empty p { - font-size: 14px; - color: var(--text-secondary); - margin: 0; -} - -/* Темная тема */ -[data-theme="dark"] .music-picker-track { - background: var(--bg-secondary); -} - -[data-theme="dark"] .music-picker-track:hover { - background: var(--bg-primary); -} - -[data-theme="dark"] .search-input-wrapper { - background: var(--bg-secondary); - border: 1px solid var(--border-color); -} - -[data-theme="dark"] .track-cover-small { - background: var(--bg-primary); -} - +.music-picker-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 1001; + padding: 20px; + animation: fadeIn 0.2s ease-out; +} + +/* Темная тема */ +[data-theme="dark"] .music-picker-overlay { + background: rgba(0, 0, 0, 0.9); +} + +.music-picker-modal { + background: var(--bg-secondary); + border-radius: 16px; + width: 100%; + max-width: 500px; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 8px 32px var(--shadow-lg); + animation: slideUp 0.3s ease-out; +} + +.music-picker-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px; + border-bottom: 1px solid var(--divider-color); +} + +.music-picker-header h2 { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.music-picker-tabs { + display: flex; + border-bottom: 1px solid var(--divider-color); + padding: 0 20px; +} + +.picker-tab { + flex: 1; + background: none; + border: none; + padding: 12px; + font-size: 14px; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +.picker-tab.active { + color: #9b59b6; + border-bottom-color: #9b59b6; +} + +.music-picker-content { + flex: 1; + overflow-y: auto; + padding: 16px; +} + +.search-section { + display: flex; + flex-direction: column; + gap: 16px; +} + +.search-input-wrapper { + display: flex; + align-items: center; + gap: 8px; + background: var(--bg-primary); + border-radius: 8px; + padding: 10px 12px; +} + +.search-input-wrapper input { + flex: 1; + background: none; + border: none; + outline: none; + font-size: 14px; + color: var(--text-primary); +} + +.search-input-wrapper input::placeholder { + color: var(--text-secondary); +} + +.search-input-wrapper button { + background: #9b59b6; + border: none; + color: white; + padding: 6px 16px; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.search-input-wrapper button:hover:not(:disabled) { + background: #8e44ad; +} + +.search-input-wrapper button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.tracks-list-picker { + display: flex; + flex-direction: column; + gap: 8px; +} + +.music-picker-track { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: var(--bg-primary); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; +} + +.music-picker-track:hover { + background: var(--bg-secondary); + box-shadow: 0 2px 8px var(--shadow-sm); +} + +.music-picker-track:active { + transform: scale(0.98); +} + +.track-cover-small { + width: 40px; + height: 40px; + border-radius: 6px; + background: var(--bg-secondary); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; +} + +.track-cover-small img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.track-cover-small svg { + color: var(--text-secondary); +} + +.track-info-small { + flex: 1; + min-width: 0; +} + +.track-title-small { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.track-artist-small { + font-size: 12px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.picker-loading, +.picker-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + gap: 12px; + text-align: center; +} + +.picker-loading p, +.picker-empty p { + font-size: 14px; + color: var(--text-secondary); + margin: 0; +} + +/* Темная тема */ +[data-theme="dark"] .music-picker-track { + background: var(--bg-secondary); +} + +[data-theme="dark"] .music-picker-track:hover { + background: var(--bg-primary); +} + +[data-theme="dark"] .search-input-wrapper { + background: var(--bg-secondary); + border: 1px solid var(--border-color); +} + +[data-theme="dark"] .track-cover-small { + background: var(--bg-primary); +} + diff --git a/frontend/src/components/MusicPickerModal.jsx b/frontend/src/components/MusicPickerModal.jsx index cc5190c..3078b63 100644 --- a/frontend/src/components/MusicPickerModal.jsx +++ b/frontend/src/components/MusicPickerModal.jsx @@ -61,10 +61,21 @@ export default function MusicPickerModal({ onClose, onSelect }) { >
{track.coverImage ? ( - {track.title} - ) : ( + {track.title} { + e.target.style.display = 'none' + e.target.nextElementSibling.style.display = 'flex' + }} + /> + ) : null} +
- )} +
{track.title}
@@ -163,3 +174,4 @@ export default function MusicPickerModal({ onClose, onSelect }) { ) } + diff --git a/frontend/src/components/UploadTrackModal.css b/frontend/src/components/UploadTrackModal.css index 9b09cb8..9f36be1 100644 --- a/frontend/src/components/UploadTrackModal.css +++ b/frontend/src/components/UploadTrackModal.css @@ -1,346 +1,346 @@ -.upload-modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.8); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; - padding: 20px; - animation: fadeIn 0.2s ease-out; -} - -/* Темная тема */ -[data-theme="dark"] .upload-modal-overlay { - background: rgba(0, 0, 0, 0.9); -} - -.upload-modal { - background: var(--bg-secondary); - border-radius: 16px; - width: 100%; - max-width: 500px; - max-height: 90vh; - display: flex; - flex-direction: column; - box-shadow: 0 8px 32px var(--shadow-lg); - animation: slideUp 0.3s ease-out; -} - -.upload-modal-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 20px; - border-bottom: 1px solid var(--divider-color); -} - -.upload-modal-header h2 { - font-size: 20px; - font-weight: 600; - color: var(--text-primary); - margin: 0; -} - -.close-btn { - 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; -} - -.close-btn:hover { - background: var(--bg-primary); - color: var(--text-primary); -} - -.close-btn:active { - transform: scale(0.9); -} - -.upload-modal-content { - flex: 1; - overflow-y: auto; - padding: 20px; -} - -.file-select { - margin-bottom: 20px; -} - -.file-select-btn { - width: 100%; - padding: 40px 20px; - border: 2px dashed var(--divider-color); - border-radius: 12px; - background: var(--bg-primary); - cursor: pointer; - display: flex; - flex-direction: column; - align-items: center; - gap: 12px; - transition: all 0.2s; - color: var(--text-secondary); -} - -.file-select-btn:hover { - border-color: #9b59b6; - background: var(--bg-secondary); -} - -.file-select-btn:active { - transform: scale(0.98); -} - -.upload-icons { - display: flex; - gap: 16px; - align-items: center; -} - -.file-select-btn span { - font-size: 16px; - font-weight: 500; - color: var(--text-primary); -} - -.file-select-btn small { - font-size: 12px; - color: var(--text-secondary); - line-height: 1.4; -} - -.file-type-badge { - display: inline-block; - font-size: 11px; - font-weight: 600; - color: #9b59b6; - background: rgba(155, 89, 182, 0.1); - padding: 4px 8px; - border-radius: 4px; - margin-bottom: 4px; - text-transform: uppercase; -} - -.info-notice { - display: flex; - align-items: flex-start; - gap: 8px; - padding: 12px; - background: rgba(155, 89, 182, 0.1); - border-radius: 8px; - margin-bottom: 16px; - font-size: 13px; - color: var(--text-secondary); - line-height: 1.5; -} - -.info-notice svg { - color: #9b59b6; - flex-shrink: 0; - margin-top: 2px; -} - -.file-selected { - display: flex; - align-items: center; - gap: 12px; - padding: 16px; - background: var(--bg-primary); - border-radius: 12px; - border: 1px solid var(--divider-color); -} - -.file-selected svg { - color: #9b59b6; - flex-shrink: 0; -} - -.file-info { - flex: 1; - min-width: 0; -} - -.file-name { - font-size: 14px; - font-weight: 500; - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.file-size { - font-size: 12px; - color: var(--text-secondary); - margin-top: 4px; -} - -.change-file-btn { - background: none; - border: 1px solid var(--divider-color); - color: var(--text-primary); - padding: 8px 16px; - border-radius: 8px; - font-size: 14px; - cursor: pointer; - transition: all 0.2s; - flex-shrink: 0; -} - -.change-file-btn:hover { - background: var(--bg-secondary); - border-color: #9b59b6; -} - -.change-file-btn:active { - transform: scale(0.95); -} - -.extracting-notice { - display: flex; - align-items: center; - gap: 8px; - padding: 12px; - background: var(--bg-primary); - border-radius: 8px; - margin-bottom: 16px; - font-size: 14px; - color: var(--text-secondary); -} - -.spinning { - animation: spin 1s linear infinite; -} - -@keyframes spin { - to { transform: rotate(360deg); } -} - -.metadata-form { - display: flex; - flex-direction: column; - gap: 16px; -} - -.form-group { - display: flex; - flex-direction: column; - gap: 8px; -} - -.form-group label { - font-size: 14px; - font-weight: 500; - color: var(--text-primary); -} - -.form-group input { - padding: 12px; - border: 1px solid var(--divider-color); - border-radius: 8px; - background: var(--bg-primary); - color: var(--text-primary); - font-size: 14px; - transition: border-color 0.2s; -} - -.form-group input:focus { - outline: none; - border-color: #9b59b6; -} - -.form-group input::placeholder { - color: var(--text-secondary); -} - -.form-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; -} - -.upload-modal-footer { - display: flex; - gap: 12px; - padding: 20px; - border-top: 1px solid var(--divider-color); -} - -.cancel-btn, -.upload-btn { - flex: 1; - padding: 12px 24px; - border-radius: 8px; - font-size: 16px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; -} - -.cancel-btn { - background: var(--bg-primary); - border: 1px solid var(--divider-color); - color: var(--text-primary); -} - -.cancel-btn:hover { - background: var(--bg-secondary); -} - -.cancel-btn:active { - transform: scale(0.98); -} - -.upload-btn { - background: #9b59b6; - border: none; - color: white; -} - -.upload-btn:hover:not(:disabled) { - background: #8e44ad; -} - -.upload-btn:active:not(:disabled) { - transform: scale(0.98); -} - -.upload-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -/* Темная тема - улучшение контраста */ -[data-theme="dark"] .file-select-btn { - border-color: var(--border-color); - background: var(--bg-secondary); -} - -[data-theme="dark"] .file-select-btn:hover { - border-color: #9b59b6; - background: var(--bg-primary); -} - -[data-theme="dark"] .form-group input { - background: var(--bg-secondary); - border-color: var(--border-color); -} - -[data-theme="dark"] .form-group input:focus { - border-color: #9b59b6; -} - +.upload-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 20px; + animation: fadeIn 0.2s ease-out; +} + +/* Темная тема */ +[data-theme="dark"] .upload-modal-overlay { + background: rgba(0, 0, 0, 0.9); +} + +.upload-modal { + background: var(--bg-secondary); + border-radius: 16px; + width: 100%; + max-width: 500px; + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 8px 32px var(--shadow-lg); + animation: slideUp 0.3s ease-out; +} + +.upload-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px; + border-bottom: 1px solid var(--divider-color); +} + +.upload-modal-header h2 { + font-size: 20px; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.close-btn { + 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; +} + +.close-btn:hover { + background: var(--bg-primary); + color: var(--text-primary); +} + +.close-btn:active { + transform: scale(0.9); +} + +.upload-modal-content { + flex: 1; + overflow-y: auto; + padding: 20px; +} + +.file-select { + margin-bottom: 20px; +} + +.file-select-btn { + width: 100%; + padding: 40px 20px; + border: 2px dashed var(--divider-color); + border-radius: 12px; + background: var(--bg-primary); + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + transition: all 0.2s; + color: var(--text-secondary); +} + +.file-select-btn:hover { + border-color: #9b59b6; + background: var(--bg-secondary); +} + +.file-select-btn:active { + transform: scale(0.98); +} + +.upload-icons { + display: flex; + gap: 16px; + align-items: center; +} + +.file-select-btn span { + font-size: 16px; + font-weight: 500; + color: var(--text-primary); +} + +.file-select-btn small { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.4; +} + +.file-type-badge { + display: inline-block; + font-size: 11px; + font-weight: 600; + color: #9b59b6; + background: rgba(155, 89, 182, 0.1); + padding: 4px 8px; + border-radius: 4px; + margin-bottom: 4px; + text-transform: uppercase; +} + +.info-notice { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 12px; + background: rgba(155, 89, 182, 0.1); + border-radius: 8px; + margin-bottom: 16px; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; +} + +.info-notice svg { + color: #9b59b6; + flex-shrink: 0; + margin-top: 2px; +} + +.file-selected { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + background: var(--bg-primary); + border-radius: 12px; + border: 1px solid var(--divider-color); +} + +.file-selected svg { + color: #9b59b6; + flex-shrink: 0; +} + +.file-info { + flex: 1; + min-width: 0; +} + +.file-name { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.file-size { + font-size: 12px; + color: var(--text-secondary); + margin-top: 4px; +} + +.change-file-btn { + background: none; + border: 1px solid var(--divider-color); + color: var(--text-primary); + padding: 8px 16px; + border-radius: 8px; + font-size: 14px; + cursor: pointer; + transition: all 0.2s; + flex-shrink: 0; +} + +.change-file-btn:hover { + background: var(--bg-secondary); + border-color: #9b59b6; +} + +.change-file-btn:active { + transform: scale(0.95); +} + +.extracting-notice { + display: flex; + align-items: center; + gap: 8px; + padding: 12px; + background: var(--bg-primary); + border-radius: 8px; + margin-bottom: 16px; + font-size: 14px; + color: var(--text-secondary); +} + +.spinning { + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.metadata-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.form-group label { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); +} + +.form-group input { + padding: 12px; + border: 1px solid var(--divider-color); + border-radius: 8px; + background: var(--bg-primary); + color: var(--text-primary); + font-size: 14px; + transition: border-color 0.2s; +} + +.form-group input:focus { + outline: none; + border-color: #9b59b6; +} + +.form-group input::placeholder { + color: var(--text-secondary); +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.upload-modal-footer { + display: flex; + gap: 12px; + padding: 20px; + border-top: 1px solid var(--divider-color); +} + +.cancel-btn, +.upload-btn { + flex: 1; + padding: 12px 24px; + border-radius: 8px; + font-size: 16px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.cancel-btn { + background: var(--bg-primary); + border: 1px solid var(--divider-color); + color: var(--text-primary); +} + +.cancel-btn:hover { + background: var(--bg-secondary); +} + +.cancel-btn:active { + transform: scale(0.98); +} + +.upload-btn { + background: #9b59b6; + border: none; + color: white; +} + +.upload-btn:hover:not(:disabled) { + background: #8e44ad; +} + +.upload-btn:active:not(:disabled) { + transform: scale(0.98); +} + +.upload-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Темная тема - улучшение контраста */ +[data-theme="dark"] .file-select-btn { + border-color: var(--border-color); + background: var(--bg-secondary); +} + +[data-theme="dark"] .file-select-btn:hover { + border-color: #9b59b6; + background: var(--bg-primary); +} + +[data-theme="dark"] .form-group input { + background: var(--bg-secondary); + border-color: var(--border-color); +} + +[data-theme="dark"] .form-group input:focus { + border-color: #9b59b6; +} + diff --git a/frontend/src/components/UploadTrackModal.jsx b/frontend/src/components/UploadTrackModal.jsx index e4272fd..f9456c2 100644 --- a/frontend/src/components/UploadTrackModal.jsx +++ b/frontend/src/components/UploadTrackModal.jsx @@ -1,351 +1,351 @@ -import { useState, useRef } from 'react' -import { createPortal } from 'react-dom' -import { X, Upload, Music, Loader, Package } from 'lucide-react' -import { uploadTrack, uploadAlbum } from '../utils/musicApi' -import { hapticFeedback } from '../utils/telegram' -import './UploadTrackModal.css' - -export default function UploadTrackModal({ user, onClose, onUploaded }) { - const [uploadType, setUploadType] = useState('track') // track или album - const [file, setFile] = useState(null) - const [metadata, setMetadata] = useState({ - title: '', - artist: '', - album: '', - year: '', - genre: '', - trackNumber: '' - }) - const [loading, setLoading] = useState(false) - const [extracting, setExtracting] = useState(false) - const fileInputRef = useRef(null) - - const handleFileSelect = async (e) => { - const selectedFile = e.target.files[0] - if (!selectedFile) return - - const isZip = selectedFile.name.toLowerCase().endsWith('.zip') || - selectedFile.type === 'application/zip' || - selectedFile.type === 'application/x-zip-compressed' - - if (isZip) { - // ZIP файл - это альбом - setUploadType('album') - - // Проверка размера (100MB для альбомов) - if (selectedFile.size > 100 * 1024 * 1024) { - alert('ZIP файл слишком большой. Максимум 100MB') - return - } - - setFile(selectedFile) - - // Попытка извлечь название альбома из имени ZIP - const albumName = selectedFile.name.replace('.zip', '').replace(/\.(ZIP)$/i, '') - setMetadata(prev => ({ - ...prev, - album: albumName - })) - } else { - // Обычный аудио файл - setUploadType('track') - - // Проверка типа файла - const allowedTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/m4a', 'audio/mp4'] - const allowedExts = /\.(mp3|wav|ogg|m4a|flac)$/i - - if (!allowedTypes.includes(selectedFile.type) && !selectedFile.name.match(allowedExts)) { - alert('Поддерживаются только аудио файлы: MP3, WAV, OGG, M4A, FLAC') - return - } - - // Проверка размера (50MB) - if (selectedFile.size > 50 * 1024 * 1024) { - alert('Файл слишком большой. Максимум 50MB') - return - } - - setFile(selectedFile) - - // Попытка извлечь метаданные из имени файла - extractMetadataFromFilename(selectedFile.name) - } - } - - const extractMetadataFromFilename = (filename) => { - setExtracting(true) - - // Убрать расширение - const nameWithoutExt = filename.replace(/\.[^/.]+$/, '') - - // Попытка разобрать формат "Artist - Title" или "Artist - Album - Title" - const parts = nameWithoutExt.split(' - ').map(p => p.trim()) - - if (parts.length >= 2) { - setMetadata(prev => ({ - ...prev, - artist: parts[0] || prev.artist, - title: parts[parts.length - 1] || prev.title, - album: parts.length === 3 ? parts[1] : prev.album - })) - } else { - // Если не удалось разобрать, использовать имя файла как название - setMetadata(prev => ({ - ...prev, - title: nameWithoutExt || prev.title - })) - } - - setTimeout(() => setExtracting(false), 500) - } - - const handleUpload = async () => { - if (!file) { - alert('Выберите файл') - return - } - - if (uploadType === 'track' && (!metadata.title || !metadata.artist)) { - alert('Укажите название трека и исполнителя') - return - } - - if (uploadType === 'album' && (!metadata.artist || !metadata.album)) { - alert('Укажите исполнителя и название альбома') - return - } - - try { - setLoading(true) - hapticFeedback('light') - - const formData = new FormData() - - if (uploadType === 'track') { - formData.append('track', file) - formData.append('title', metadata.title) - formData.append('artistName', metadata.artist) - - if (metadata.album) formData.append('albumTitle', metadata.album) - if (metadata.year) formData.append('year', metadata.year) - if (metadata.genre) formData.append('genre', metadata.genre) - if (metadata.trackNumber) formData.append('trackNumber', metadata.trackNumber) - - const response = await uploadTrack(formData) - - hapticFeedback('success') - alert('✅ Трек успешно загружен!') - } else { - // Загрузка альбома - formData.append('album', file) - formData.append('artistName', metadata.artist) - formData.append('albumTitle', metadata.album) - - if (metadata.year) formData.append('year', metadata.year) - if (metadata.genre) formData.append('genre', metadata.genre) - - const response = await uploadAlbum(formData) - - hapticFeedback('success') - alert(`✅ Альбом загружен! ${response.tracks?.length || 0} треков`) - } - - if (onUploaded) onUploaded() - } catch (error) { - console.error('Ошибка загрузки:', error) - hapticFeedback('error') - alert('Ошибка загрузки: ' + (error.response?.data?.error || error.message)) - } finally { - setLoading(false) - } - } - - return createPortal( -
-
e.stopPropagation()}> -
-

{uploadType === 'album' ? 'Загрузить альбом' : 'Загрузить трек'}

- -
- -
- {/* Выбор файла */} -
- - - {!file ? ( - - ) : ( -
- {uploadType === 'album' ? : } -
-
{uploadType === 'album' ? 'Альбом (ZIP)' : 'Трек'}
-
{file.name}
-
{(file.size / 1024 / 1024).toFixed(2)} MB
-
- -
- )} -
- - {/* Метаданные */} - {file && ( -
- {extracting && ( -
- - Извлечение метаданных... -
- )} - - {uploadType === 'album' && ( -
- - Метаданные (название, исполнитель, обложка) будут автоматически извлечены из аудио файлов в архиве -
- )} - - {uploadType === 'track' && ( -
- - setMetadata({ ...metadata, title: e.target.value })} - placeholder="My Awesome Track" - /> -
- )} - -
- - setMetadata({ ...metadata, artist: e.target.value })} - placeholder="Artist Name" - /> -
- - {uploadType === 'album' && ( -
- - setMetadata({ ...metadata, album: e.target.value })} - placeholder="Album Name" - /> -
- )} - - {uploadType === 'track' && ( -
- - setMetadata({ ...metadata, album: e.target.value })} - placeholder="Album Name (необязательно)" - /> -
- )} - -
-
- - setMetadata({ ...metadata, year: e.target.value })} - placeholder="2024" - min="1900" - max="2099" - /> -
- - {uploadType === 'track' && ( -
- - setMetadata({ ...metadata, trackNumber: e.target.value })} - placeholder="1" - min="1" - /> -
- )} -
- -
- - setMetadata({ ...metadata, genre: e.target.value })} - placeholder="Electronic, Rock, Pop..." - /> -
-
- )} -
- -
- - -
-
-
, - document.body - ) -} - +import { useState, useRef } from 'react' +import { createPortal } from 'react-dom' +import { X, Upload, Music, Loader, Package } from 'lucide-react' +import { uploadTrack, uploadAlbum } from '../utils/musicApi' +import { hapticFeedback } from '../utils/telegram' +import './UploadTrackModal.css' + +export default function UploadTrackModal({ user, onClose, onUploaded }) { + const [uploadType, setUploadType] = useState('track') // track или album + const [file, setFile] = useState(null) + const [metadata, setMetadata] = useState({ + title: '', + artist: '', + album: '', + year: '', + genre: '', + trackNumber: '' + }) + const [loading, setLoading] = useState(false) + const [extracting, setExtracting] = useState(false) + const fileInputRef = useRef(null) + + const handleFileSelect = async (e) => { + const selectedFile = e.target.files[0] + if (!selectedFile) return + + const isZip = selectedFile.name.toLowerCase().endsWith('.zip') || + selectedFile.type === 'application/zip' || + selectedFile.type === 'application/x-zip-compressed' + + if (isZip) { + // ZIP файл - это альбом + setUploadType('album') + + // Проверка размера (100MB для альбомов) + if (selectedFile.size > 100 * 1024 * 1024) { + alert('ZIP файл слишком большой. Максимум 100MB') + return + } + + setFile(selectedFile) + + // Попытка извлечь название альбома из имени ZIP + const albumName = selectedFile.name.replace('.zip', '').replace(/\.(ZIP)$/i, '') + setMetadata(prev => ({ + ...prev, + album: albumName + })) + } else { + // Обычный аудио файл + setUploadType('track') + + // Проверка типа файла + const allowedTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/m4a', 'audio/mp4'] + const allowedExts = /\.(mp3|wav|ogg|m4a|flac)$/i + + if (!allowedTypes.includes(selectedFile.type) && !selectedFile.name.match(allowedExts)) { + alert('Поддерживаются только аудио файлы: MP3, WAV, OGG, M4A, FLAC') + return + } + + // Проверка размера (50MB) + if (selectedFile.size > 50 * 1024 * 1024) { + alert('Файл слишком большой. Максимум 50MB') + return + } + + setFile(selectedFile) + + // Попытка извлечь метаданные из имени файла + extractMetadataFromFilename(selectedFile.name) + } + } + + const extractMetadataFromFilename = (filename) => { + setExtracting(true) + + // Убрать расширение + const nameWithoutExt = filename.replace(/\.[^/.]+$/, '') + + // Попытка разобрать формат "Artist - Title" или "Artist - Album - Title" + const parts = nameWithoutExt.split(' - ').map(p => p.trim()) + + if (parts.length >= 2) { + setMetadata(prev => ({ + ...prev, + artist: parts[0] || prev.artist, + title: parts[parts.length - 1] || prev.title, + album: parts.length === 3 ? parts[1] : prev.album + })) + } else { + // Если не удалось разобрать, использовать имя файла как название + setMetadata(prev => ({ + ...prev, + title: nameWithoutExt || prev.title + })) + } + + setTimeout(() => setExtracting(false), 500) + } + + const handleUpload = async () => { + if (!file) { + alert('Выберите файл') + return + } + + if (uploadType === 'track' && (!metadata.title || !metadata.artist)) { + alert('Укажите название трека и исполнителя') + return + } + + if (uploadType === 'album' && (!metadata.artist || !metadata.album)) { + alert('Укажите исполнителя и название альбома') + return + } + + try { + setLoading(true) + hapticFeedback('light') + + const formData = new FormData() + + if (uploadType === 'track') { + formData.append('track', file) + formData.append('title', metadata.title) + formData.append('artistName', metadata.artist) + + if (metadata.album) formData.append('albumTitle', metadata.album) + if (metadata.year) formData.append('year', metadata.year) + if (metadata.genre) formData.append('genre', metadata.genre) + if (metadata.trackNumber) formData.append('trackNumber', metadata.trackNumber) + + const response = await uploadTrack(formData) + + hapticFeedback('success') + alert('✅ Трек успешно загружен!') + } else { + // Загрузка альбома + formData.append('album', file) + formData.append('artistName', metadata.artist) + formData.append('albumTitle', metadata.album) + + if (metadata.year) formData.append('year', metadata.year) + if (metadata.genre) formData.append('genre', metadata.genre) + + const response = await uploadAlbum(formData) + + hapticFeedback('success') + alert(`✅ Альбом загружен! ${response.tracks?.length || 0} треков`) + } + + if (onUploaded) onUploaded() + } catch (error) { + console.error('Ошибка загрузки:', error) + hapticFeedback('error') + alert('Ошибка загрузки: ' + (error.response?.data?.error || error.message)) + } finally { + setLoading(false) + } + } + + return createPortal( +
+
e.stopPropagation()}> +
+

{uploadType === 'album' ? 'Загрузить альбом' : 'Загрузить трек'}

+ +
+ +
+ {/* Выбор файла */} +
+ + + {!file ? ( + + ) : ( +
+ {uploadType === 'album' ? : } +
+
{uploadType === 'album' ? 'Альбом (ZIP)' : 'Трек'}
+
{file.name}
+
{(file.size / 1024 / 1024).toFixed(2)} MB
+
+ +
+ )} +
+ + {/* Метаданные */} + {file && ( +
+ {extracting && ( +
+ + Извлечение метаданных... +
+ )} + + {uploadType === 'album' && ( +
+ + Метаданные (название, исполнитель, обложка) будут автоматически извлечены из аудио файлов в архиве +
+ )} + + {uploadType === 'track' && ( +
+ + setMetadata({ ...metadata, title: e.target.value })} + placeholder="My Awesome Track" + /> +
+ )} + +
+ + setMetadata({ ...metadata, artist: e.target.value })} + placeholder="Artist Name" + /> +
+ + {uploadType === 'album' && ( +
+ + setMetadata({ ...metadata, album: e.target.value })} + placeholder="Album Name" + /> +
+ )} + + {uploadType === 'track' && ( +
+ + setMetadata({ ...metadata, album: e.target.value })} + placeholder="Album Name (необязательно)" + /> +
+ )} + +
+
+ + setMetadata({ ...metadata, year: e.target.value })} + placeholder="2024" + min="1900" + max="2099" + /> +
+ + {uploadType === 'track' && ( +
+ + setMetadata({ ...metadata, trackNumber: e.target.value })} + placeholder="1" + min="1" + /> +
+ )} +
+ +
+ + setMetadata({ ...metadata, genre: e.target.value })} + placeholder="Electronic, Rock, Pop..." + /> +
+
+ )} +
+ +
+ + +
+
+
, + document.body + ) +} + diff --git a/frontend/src/contexts/MusicPlayerContext.jsx b/frontend/src/contexts/MusicPlayerContext.jsx index 7ddbb6f..e9ba1a5 100644 --- a/frontend/src/contexts/MusicPlayerContext.jsx +++ b/frontend/src/contexts/MusicPlayerContext.jsx @@ -104,8 +104,18 @@ export const MusicPlayerProvider = ({ children }) => { // Загрузить трек if (audioRef.current) { - audioRef.current.src = track.fileUrl + // Сформировать полный URL для трека + let audioUrl = track.fileUrl + + // Если относительный URL, добавить базовый URL API + if (audioUrl && audioUrl.startsWith('/')) { + const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3000/api' + audioUrl = apiUrl.replace('/api', '') + audioUrl + } + + audioRef.current.src = audioUrl audioRef.current.volume = volume + audioRef.current.load() // Перезагрузить источник await audioRef.current.play() setIsPlaying(true) } @@ -233,3 +243,4 @@ export const MusicPlayerProvider = ({ children }) => { ) } + diff --git a/frontend/src/pages/Media.css b/frontend/src/pages/Media.css index a378c47..e0a921a 100644 --- a/frontend/src/pages/Media.css +++ b/frontend/src/pages/Media.css @@ -1,133 +1,133 @@ -.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: flex-start; - box-shadow: 0 2px 8px var(--shadow-sm); -} - -.media-header h1 { - font-size: 20px; - font-weight: 600; - color: var(--text-primary); - margin: 0; - text-align: left; - width: 100%; -} - -.media-grid { - display: grid; - grid-template-columns: repeat(2, 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; -} - -/* Всегда 2 в ряд */ - -/* Темная тема */ -[data-theme="dark"] .media-card { - background: var(--bg-secondary); - border: 1px solid var(--border-color); -} - -[data-theme="dark"] .media-card:hover { - border-color: var(--category-color); -} - +.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: flex-start; + box-shadow: 0 2px 8px var(--shadow-sm); +} + +.media-header h1 { + font-size: 20px; + font-weight: 600; + color: var(--text-primary); + margin: 0; + text-align: left; + width: 100%; +} + +.media-grid { + display: grid; + grid-template-columns: repeat(2, 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; +} + +/* Всегда 2 в ряд */ + +/* Темная тема */ +[data-theme="dark"] .media-card { + background: var(--bg-secondary); + border: 1px solid var(--border-color); +} + +[data-theme="dark"] .media-card:hover { + border-color: var(--category-color); +} + diff --git a/frontend/src/pages/Media.jsx b/frontend/src/pages/Media.jsx index 4b17142..efb3251 100644 --- a/frontend/src/pages/Media.jsx +++ b/frontend/src/pages/Media.jsx @@ -1,75 +1,97 @@ -import { useNavigate } from 'react-router-dom' -import { Music, User } from 'lucide-react' -import { hapticFeedback } from '../utils/telegram' -import './Media.css' - -// Иконка лисы (SVG) -const FoxIcon = ({ size = 48 }) => ( - - - - - - -) - -export default function Media({ user }) { - const navigate = useNavigate() - - const categories = [ - { - id: 'furry', - name: 'Furry', - icon: FoxIcon, - color: 'var(--tag-furry)', - path: '/media/furry' - }, - { - id: 'anime', - name: 'Anime', - icon: User, - 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 ( - - ) - })} -
-
- ) -} - +import { useNavigate } from 'react-router-dom' +import { Music, User } from 'lucide-react' +import { hapticFeedback } from '../utils/telegram' +import './Media.css' + +// Иконка лисы (SVG в стиле аниме) +const FoxIcon = ({ size = 48 }) => ( + + {/* Голова */} + + + + {/* Уши */} + + + + {/* Мордочка */} + + + {/* Глаза */} + + + + {/* Нос */} + + + {/* Рот */} + + + {/* Туловище */} + + + {/* Хвост */} + + +) + +export default function Media({ user }) { + const navigate = useNavigate() + + const categories = [ + { + id: 'furry', + name: 'Furry', + icon: FoxIcon, + color: 'var(--tag-furry)', + path: '/media/furry' + }, + { + id: 'anime', + name: 'Anime', + icon: User, + 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 index 0abc69f..e52bc58 100644 --- a/frontend/src/pages/MediaAnime.jsx +++ b/frontend/src/pages/MediaAnime.jsx @@ -1,526 +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} - /> - )} -
- ) -} - +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 index 3be3dac..61f2ba3 100644 --- a/frontend/src/pages/MediaFurry.jsx +++ b/frontend/src/pages/MediaFurry.jsx @@ -1,526 +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} - /> - )} -
- ) -} - +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 index 6e51522..64bf1dc 100644 --- a/frontend/src/pages/MediaMusic.css +++ b/frontend/src/pages/MediaMusic.css @@ -1,425 +1,425 @@ -.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: left; - color: var(--text-primary); -} - -.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-tab.upload-tab { - flex: 0; - padding: 12px 20px; - color: #9b59b6; - border-bottom: none; -} - -.music-tab.upload-tab:hover { - background: var(--bg-primary); - border-radius: 8px; -} - -/* Поиск */ -.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; -} - -/* Темная тема */ -[data-theme="dark"] .music-tabs { - background: var(--bg-secondary); - border-bottom-color: var(--divider-color); -} - -[data-theme="dark"] .search-input-wrapper { - background: var(--bg-secondary); - border: 1px solid var(--border-color); -} - -[data-theme="dark"] .track-item { - background: var(--bg-secondary); -} - -[data-theme="dark"] .track-item:active { - background: var(--bg-primary); -} - -[data-theme="dark"] .track-btn:hover { - background: var(--bg-primary); -} - -[data-theme="dark"] .artist-item, -[data-theme="dark"] .album-item { - background: var(--bg-secondary); - border: 1px solid var(--border-color); -} - -[data-theme="dark"] .artist-item:active, -[data-theme="dark"] .album-item:active { - background: var(--bg-primary); -} - -[data-theme="dark"] .album-cover { - background: var(--bg-primary); - border: 1px solid var(--border-color); -} - +.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: left; + color: var(--text-primary); +} + +.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-tab.upload-tab { + flex: 0; + padding: 12px 20px; + color: #9b59b6; + border-bottom: none; +} + +.music-tab.upload-tab:hover { + background: var(--bg-primary); + border-radius: 8px; +} + +/* Поиск */ +.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; +} + +/* Темная тема */ +[data-theme="dark"] .music-tabs { + background: var(--bg-secondary); + border-bottom-color: var(--divider-color); +} + +[data-theme="dark"] .search-input-wrapper { + background: var(--bg-secondary); + border: 1px solid var(--border-color); +} + +[data-theme="dark"] .track-item { + background: var(--bg-secondary); +} + +[data-theme="dark"] .track-item:active { + background: var(--bg-primary); +} + +[data-theme="dark"] .track-btn:hover { + background: var(--bg-primary); +} + +[data-theme="dark"] .artist-item, +[data-theme="dark"] .album-item { + background: var(--bg-secondary); + border: 1px solid var(--border-color); +} + +[data-theme="dark"] .artist-item:active, +[data-theme="dark"] .album-item:active { + background: var(--bg-primary); +} + +[data-theme="dark"] .album-cover { + background: var(--bg-primary); + border: 1px solid var(--border-color); +} + diff --git a/frontend/src/pages/MediaMusic.jsx b/frontend/src/pages/MediaMusic.jsx index ebccf51..dc488cd 100644 --- a/frontend/src/pages/MediaMusic.jsx +++ b/frontend/src/pages/MediaMusic.jsx @@ -1,338 +1,349 @@ -import { useState, useEffect } from 'react' -import { useNavigate } from 'react-router-dom' -import { ArrowLeft, Search as SearchIcon, Music, Play, Heart, Download, X, Upload } from 'lucide-react' -import { searchMusic, getTracks, addToFavorites, removeFromFavorites, getFavorites } from '../utils/musicApi' -import { hapticFeedback, getTelegramUser } from '../utils/telegram' -import { useMusicPlayer } from '../contexts/MusicPlayerContext' -import UploadTrackModal from '../components/UploadTrackModal' -import api from '../utils/api' -import './MediaMusic.css' - -export default function MediaMusic({ user }) { - const navigate = useNavigate() - const { play } = useMusicPlayer() - const [activeTab, setActiveTab] = useState('favorites') // favorites, browse - const [query, setQuery] = useState('') - const [searchResults, setSearchResults] = useState({ tracks: [], artists: [], albums: [] }) - const [tracks, setTracks] = useState([]) - const [favorites, setFavorites] = useState([]) - const [loading, setLoading] = useState(false) - const [showUpload, setShowUpload] = useState(false) - - useEffect(() => { - if (activeTab === 'browse') { - 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 === 'favorites' && ( -
- {loading ? ( -
-
-

Загрузка...

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

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

- Добавьте треки в избранное -
- ) : ( -
- {favorites.map(track => renderTrackItem(track, favorites))} -
- )} -
- )} - - {/* Обзор (поиск + все треки) */} - {activeTab === 'browse' && ( -
-
- - 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 && ( -
-

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

- Попробуйте другой запрос -
- )} -
- )} -
- )} - - {/* Модал загрузки */} - {showUpload && ( - setShowUpload(false)} - onUploaded={() => { - setShowUpload(false) - loadTracks() - }} - /> - )} -
- ) -} - +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { ArrowLeft, Search as SearchIcon, Music, Play, Heart, Download, X, Upload } from 'lucide-react' +import { searchMusic, getTracks, addToFavorites, removeFromFavorites, getFavorites } from '../utils/musicApi' +import { hapticFeedback, getTelegramUser } from '../utils/telegram' +import { useMusicPlayer } from '../contexts/MusicPlayerContext' +import UploadTrackModal from '../components/UploadTrackModal' +import api from '../utils/api' +import './MediaMusic.css' + +export default function MediaMusic({ user }) { + const navigate = useNavigate() + const { play } = useMusicPlayer() + const [activeTab, setActiveTab] = useState('favorites') // favorites, browse + const [query, setQuery] = useState('') + const [searchResults, setSearchResults] = useState({ tracks: [], artists: [], albums: [] }) + const [tracks, setTracks] = useState([]) + const [favorites, setFavorites] = useState([]) + const [loading, setLoading] = useState(false) + const [showUpload, setShowUpload] = useState(false) + + useEffect(() => { + if (activeTab === 'browse') { + 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} { + e.target.style.display = 'none' + e.target.nextElementSibling.style.display = 'flex' + }} + /> + ) : null} +
+ +
+
+ +
+
{track.title}
+
{track.artist?.name || 'Unknown'}
+
+ +
+ + + +
+
+ ) + } + + return ( +
+
+ +

Music

+
+
+ + {/* Табы */} +
+ + + +
+ + {/* Избранное */} + {activeTab === 'favorites' && ( +
+ {loading ? ( +
+
+

Загрузка...

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

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

+ Добавьте треки в избранное +
+ ) : ( +
+ {favorites.map(track => renderTrackItem(track, favorites))} +
+ )} +
+ )} + + {/* Обзор (поиск + все треки) */} + {activeTab === 'browse' && ( +
+
+ + 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 && ( +
+

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

+ Попробуйте другой запрос +
+ )} +
+ )} +
+ )} + + {/* Модал загрузки */} + {showUpload && ( + setShowUpload(false)} + onUploaded={() => { + setShowUpload(false) + loadTracks() + }} + /> + )} +
+ ) +} + diff --git a/frontend/src/pages/MediaSearch.css b/frontend/src/pages/MediaSearch.css index e02a21e..9fc2956 100644 --- a/frontend/src/pages/MediaSearch.css +++ b/frontend/src/pages/MediaSearch.css @@ -1,93 +1,93 @@ -/* Общие стили для 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: left; - color: var(--text-primary); -} - -.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; -} - -/* Темная тема */ -[data-theme="dark"] .media-search-page { - background: var(--bg-primary); -} - -[data-theme="dark"] .media-search-header { - background: var(--bg-secondary); - border-bottom-color: var(--divider-color); -} - -[data-theme="dark"] .media-search-header .back-btn, -[data-theme="dark"] .media-search-header .selection-toggle { - color: var(--text-primary); -} - -[data-theme="dark"] .media-search-header .selection-toggle:hover { - background: var(--bg-primary); -} - -[data-theme="dark"] .media-search-header .selection-toggle.active { - background: var(--button-accent); - color: white; -} - +/* Общие стили для 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: left; + color: var(--text-primary); +} + +.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; +} + +/* Темная тема */ +[data-theme="dark"] .media-search-page { + background: var(--bg-primary); +} + +[data-theme="dark"] .media-search-header { + background: var(--bg-secondary); + border-bottom-color: var(--divider-color); +} + +[data-theme="dark"] .media-search-header .back-btn, +[data-theme="dark"] .media-search-header .selection-toggle { + color: var(--text-primary); +} + +[data-theme="dark"] .media-search-header .selection-toggle:hover { + background: var(--bg-primary); +} + +[data-theme="dark"] .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 index 55e5af3..f079377 100644 --- a/frontend/src/utils/musicApi.js +++ b/frontend/src/utils/musicApi.js @@ -76,3 +76,4 @@ export const sendTrackToTelegram = async (trackId) => { return response.data } +