Update files

This commit is contained in:
glpshchn 2025-12-15 22:51:01 +03:00
parent e160cf06d5
commit 046066a079
33 changed files with 5697 additions and 5485 deletions

View File

@ -213,3 +213,4 @@ Album Disc 2.zip
**Удачной загрузки! 🚀**

111
BUGFIXES_SUMMARY.md Normal file
View File

@ -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
- Иконка должна быть видна как лиса в стиле аниме
---
**Все исправления готовы к тестированию! ✅**

View File

@ -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`
---
**Все изменения готовы! 🎉**

View File

@ -283,3 +283,4 @@ npm run dev
**Удачной разработки! 🚀**

View File

@ -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. Треки в постах воспроизводятся через общий плеер

View File

@ -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 <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 <PID> /F
```
### Dev user не создается
Проверьте `.env`:
```cmd
type .env | findstr DISABLE_TELEGRAM_AUTH
```
Должно быть: `DISABLE_TELEGRAM_AUTH=true`
---
**Быстрого старта! 🚀**

View File

@ -81,3 +81,4 @@ npm list adm-zip music-metadata
**Готово к production! ✅**

1358
WIND.md

File diff suppressed because it is too large Load Diff

View File

@ -52,3 +52,4 @@ albumSchema.index({ createdAt: -1 });
module.exports = mongoose.model('Album', albumSchema);

View File

@ -52,3 +52,4 @@ artistSchema.pre('save', function(next) {
module.exports = mongoose.model('Artist', artistSchema);

View File

@ -23,3 +23,4 @@ favoriteTrackSchema.index({ createdAt: -1 });
module.exports = mongoose.model('FavoriteTrack', favoriteTrackSchema);

View File

@ -74,3 +74,4 @@ trackSchema.index({ 'stats.plays': -1 });
module.exports = mongoose.model('Track', trackSchema);

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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;
}
}

View File

@ -135,10 +135,21 @@ export default function FullPlayer() {
<div className="full-player-content">
<div className="full-player-cover">
{currentTrack.coverImage ? (
<img src={currentTrack.coverImage} alt={currentTrack.title} />
) : (
<img
src={currentTrack.coverImage.startsWith('http')
? currentTrack.coverImage
: (import.meta.env.VITE_API_URL || 'http://localhost:3000/api').replace('/api', '') + currentTrack.coverImage
}
alt={currentTrack.title}
onError={(e) => {
e.target.style.display = 'none'
e.target.nextElementSibling.style.display = 'flex'
}}
/>
) : null}
<div style={{ display: currentTrack.coverImage ? 'none' : 'flex' }}>
<Music size={80} />
)}
</div>
</div>
<div className="full-player-info">
@ -239,3 +250,4 @@ export default function FullPlayer() {
)
}

View File

@ -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);
}

View File

@ -42,10 +42,21 @@ export default function MiniPlayer() {
<div className="mini-player-content">
<div className="mini-player-cover">
{currentTrack.coverImage ? (
<img src={currentTrack.coverImage} alt={currentTrack.title} />
) : (
<img
src={currentTrack.coverImage.startsWith('http')
? currentTrack.coverImage
: (import.meta.env.VITE_API_URL || 'http://localhost:3000/api').replace('/api', '') + currentTrack.coverImage
}
alt={currentTrack.title}
onError={(e) => {
e.target.style.display = 'none'
e.target.nextElementSibling.style.display = 'flex'
}}
/>
) : null}
<div style={{ display: currentTrack.coverImage ? 'none' : 'flex' }}>
<Music size={20} />
)}
</div>
</div>
<div className="mini-player-info">
@ -66,3 +77,4 @@ export default function MiniPlayer() {
)
}

View File

@ -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);
}

View File

@ -24,10 +24,21 @@ export default function MusicAttachment({ track, onRemove, showRemove = false })
<div className="music-attachment">
<div className="music-attachment-cover">
{track.coverImage ? (
<img src={track.coverImage} alt={track.title} />
) : (
<img
src={track.coverImage.startsWith('http')
? track.coverImage
: (import.meta.env.VITE_API_URL || 'http://localhost:3000/api').replace('/api', '') + track.coverImage
}
alt={track.title}
onError={(e) => {
e.target.style.display = 'none'
e.target.nextElementSibling.style.display = 'flex'
}}
/>
) : null}
<div style={{ display: track.coverImage ? 'none' : 'flex' }}>
<Music size={20} />
)}
</div>
</div>
<div className="music-attachment-info">
@ -48,3 +59,4 @@ export default function MusicAttachment({ track, onRemove, showRemove = false })
)
}

View File

@ -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);
}

View File

@ -61,10 +61,21 @@ export default function MusicPickerModal({ onClose, onSelect }) {
>
<div className="track-cover-small">
{track.coverImage ? (
<img src={track.coverImage} alt={track.title} />
) : (
<img
src={track.coverImage.startsWith('http')
? track.coverImage
: (import.meta.env.VITE_API_URL || 'http://localhost:3000/api').replace('/api', '') + track.coverImage
}
alt={track.title}
onError={(e) => {
e.target.style.display = 'none'
e.target.nextElementSibling.style.display = 'flex'
}}
/>
) : null}
<div style={{ display: track.coverImage ? 'none' : 'flex' }}>
<Music size={20} />
)}
</div>
</div>
<div className="track-info-small">
<div className="track-title-small">{track.title}</div>
@ -163,3 +174,4 @@ export default function MusicPickerModal({ onClose, onSelect }) {
)
}

View File

@ -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;
}

View File

@ -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(
<div className="upload-modal-overlay" onClick={onClose}>
<div className="upload-modal" onClick={(e) => e.stopPropagation()}>
<div className="upload-modal-header">
<h2>{uploadType === 'album' ? 'Загрузить альбом' : 'Загрузить трек'}</h2>
<button className="close-btn" onClick={onClose}>
<X size={24} />
</button>
</div>
<div className="upload-modal-content">
{/* Выбор файла */}
<div className="file-select">
<input
ref={fileInputRef}
type="file"
accept="audio/*,.mp3,.wav,.ogg,.m4a,.flac,.zip"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
{!file ? (
<button
className="file-select-btn"
onClick={() => fileInputRef.current?.click()}
>
<div className="upload-icons">
<Music size={28} />
<Package size={28} />
</div>
<span>Выберите файл</span>
<small>Трек: MP3, WAV, OGG, M4A, FLAC (макс. 50MB)</small>
<small>Альбом: ZIP архив (макс. 100MB)</small>
</button>
) : (
<div className="file-selected">
{uploadType === 'album' ? <Package size={24} /> : <Music size={24} />}
<div className="file-info">
<div className="file-type-badge">{uploadType === 'album' ? 'Альбом (ZIP)' : 'Трек'}</div>
<div className="file-name">{file.name}</div>
<div className="file-size">{(file.size / 1024 / 1024).toFixed(2)} MB</div>
</div>
<button
className="change-file-btn"
onClick={() => fileInputRef.current?.click()}
>
Изменить
</button>
</div>
)}
</div>
{/* Метаданные */}
{file && (
<div className="metadata-form">
{extracting && (
<div className="extracting-notice">
<Loader size={16} className="spinning" />
<span>Извлечение метаданных...</span>
</div>
)}
{uploadType === 'album' && (
<div className="info-notice">
<Music size={16} />
<span>Метаданные (название, исполнитель, обложка) будут автоматически извлечены из аудио файлов в архиве</span>
</div>
)}
{uploadType === 'track' && (
<div className="form-group">
<label>Название трека *</label>
<input
type="text"
value={metadata.title}
onChange={(e) => setMetadata({ ...metadata, title: e.target.value })}
placeholder="My Awesome Track"
/>
</div>
)}
<div className="form-group">
<label>Исполнитель *</label>
<input
type="text"
value={metadata.artist}
onChange={(e) => setMetadata({ ...metadata, artist: e.target.value })}
placeholder="Artist Name"
/>
</div>
{uploadType === 'album' && (
<div className="form-group">
<label>Название альбома *</label>
<input
type="text"
value={metadata.album}
onChange={(e) => setMetadata({ ...metadata, album: e.target.value })}
placeholder="Album Name"
/>
</div>
)}
{uploadType === 'track' && (
<div className="form-group">
<label>Альбом</label>
<input
type="text"
value={metadata.album}
onChange={(e) => setMetadata({ ...metadata, album: e.target.value })}
placeholder="Album Name (необязательно)"
/>
</div>
)}
<div className="form-row">
<div className="form-group">
<label>Год</label>
<input
type="number"
value={metadata.year}
onChange={(e) => setMetadata({ ...metadata, year: e.target.value })}
placeholder="2024"
min="1900"
max="2099"
/>
</div>
{uploadType === 'track' && (
<div className="form-group">
<label> трека</label>
<input
type="number"
value={metadata.trackNumber}
onChange={(e) => setMetadata({ ...metadata, trackNumber: e.target.value })}
placeholder="1"
min="1"
/>
</div>
)}
</div>
<div className="form-group">
<label>Жанр</label>
<input
type="text"
value={metadata.genre}
onChange={(e) => setMetadata({ ...metadata, genre: e.target.value })}
placeholder="Electronic, Rock, Pop..."
/>
</div>
</div>
)}
</div>
<div className="upload-modal-footer">
<button className="cancel-btn" onClick={onClose} disabled={loading}>
Отмена
</button>
<button
className="upload-btn"
onClick={handleUpload}
disabled={
!file ||
loading ||
!metadata.artist ||
(uploadType === 'track' && !metadata.title) ||
(uploadType === 'album' && !metadata.album)
}
>
{loading ? (
<>
<Loader size={20} className="spinning" />
{uploadType === 'album' ? 'Загрузка альбома...' : 'Загрузка...'}
</>
) : (
<>
<Upload size={20} />
{uploadType === 'album' ? 'Загрузить альбом' : 'Загрузить трек'}
</>
)}
</button>
</div>
</div>
</div>,
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(
<div className="upload-modal-overlay" onClick={onClose}>
<div className="upload-modal" onClick={(e) => e.stopPropagation()}>
<div className="upload-modal-header">
<h2>{uploadType === 'album' ? 'Загрузить альбом' : 'Загрузить трек'}</h2>
<button className="close-btn" onClick={onClose}>
<X size={24} />
</button>
</div>
<div className="upload-modal-content">
{/* Выбор файла */}
<div className="file-select">
<input
ref={fileInputRef}
type="file"
accept="audio/*,.mp3,.wav,.ogg,.m4a,.flac,.zip"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
{!file ? (
<button
className="file-select-btn"
onClick={() => fileInputRef.current?.click()}
>
<div className="upload-icons">
<Music size={28} />
<Package size={28} />
</div>
<span>Выберите файл</span>
<small>Трек: MP3, WAV, OGG, M4A, FLAC (макс. 50MB)</small>
<small>Альбом: ZIP архив (макс. 100MB)</small>
</button>
) : (
<div className="file-selected">
{uploadType === 'album' ? <Package size={24} /> : <Music size={24} />}
<div className="file-info">
<div className="file-type-badge">{uploadType === 'album' ? 'Альбом (ZIP)' : 'Трек'}</div>
<div className="file-name">{file.name}</div>
<div className="file-size">{(file.size / 1024 / 1024).toFixed(2)} MB</div>
</div>
<button
className="change-file-btn"
onClick={() => fileInputRef.current?.click()}
>
Изменить
</button>
</div>
)}
</div>
{/* Метаданные */}
{file && (
<div className="metadata-form">
{extracting && (
<div className="extracting-notice">
<Loader size={16} className="spinning" />
<span>Извлечение метаданных...</span>
</div>
)}
{uploadType === 'album' && (
<div className="info-notice">
<Music size={16} />
<span>Метаданные (название, исполнитель, обложка) будут автоматически извлечены из аудио файлов в архиве</span>
</div>
)}
{uploadType === 'track' && (
<div className="form-group">
<label>Название трека *</label>
<input
type="text"
value={metadata.title}
onChange={(e) => setMetadata({ ...metadata, title: e.target.value })}
placeholder="My Awesome Track"
/>
</div>
)}
<div className="form-group">
<label>Исполнитель *</label>
<input
type="text"
value={metadata.artist}
onChange={(e) => setMetadata({ ...metadata, artist: e.target.value })}
placeholder="Artist Name"
/>
</div>
{uploadType === 'album' && (
<div className="form-group">
<label>Название альбома *</label>
<input
type="text"
value={metadata.album}
onChange={(e) => setMetadata({ ...metadata, album: e.target.value })}
placeholder="Album Name"
/>
</div>
)}
{uploadType === 'track' && (
<div className="form-group">
<label>Альбом</label>
<input
type="text"
value={metadata.album}
onChange={(e) => setMetadata({ ...metadata, album: e.target.value })}
placeholder="Album Name (необязательно)"
/>
</div>
)}
<div className="form-row">
<div className="form-group">
<label>Год</label>
<input
type="number"
value={metadata.year}
onChange={(e) => setMetadata({ ...metadata, year: e.target.value })}
placeholder="2024"
min="1900"
max="2099"
/>
</div>
{uploadType === 'track' && (
<div className="form-group">
<label> трека</label>
<input
type="number"
value={metadata.trackNumber}
onChange={(e) => setMetadata({ ...metadata, trackNumber: e.target.value })}
placeholder="1"
min="1"
/>
</div>
)}
</div>
<div className="form-group">
<label>Жанр</label>
<input
type="text"
value={metadata.genre}
onChange={(e) => setMetadata({ ...metadata, genre: e.target.value })}
placeholder="Electronic, Rock, Pop..."
/>
</div>
</div>
)}
</div>
<div className="upload-modal-footer">
<button className="cancel-btn" onClick={onClose} disabled={loading}>
Отмена
</button>
<button
className="upload-btn"
onClick={handleUpload}
disabled={
!file ||
loading ||
!metadata.artist ||
(uploadType === 'track' && !metadata.title) ||
(uploadType === 'album' && !metadata.album)
}
>
{loading ? (
<>
<Loader size={20} className="spinning" />
{uploadType === 'album' ? 'Загрузка альбома...' : 'Загрузка...'}
</>
) : (
<>
<Upload size={20} />
{uploadType === 'album' ? 'Загрузить альбом' : 'Загрузить трек'}
</>
)}
</button>
</div>
</div>
</div>,
document.body
)
}

View File

@ -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 }) => {
)
}

View File

@ -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);
}

View File

@ -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 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3 L8 8 L4 6 L6 11 L2 13 L5 15 L3 19 C3 19 7 21 12 21 C17 21 21 19 21 19 L19 15 L22 13 L18 11 L20 6 L16 8 Z" />
<circle cx="9" cy="13" r="1" fill="currentColor" />
<circle cx="15" cy="13" r="1" fill="currentColor" />
<path d="M9 16 C10 17 11 17.5 12 17.5 C13 17.5 14 17 15 16" />
</svg>
)
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 (
<div className="media-page">
<div className="media-header">
<h1>Media</h1>
</div>
<div className="media-grid">
{categories.map((category) => {
const Icon = category.icon
return (
<button
key={category.id}
className="media-card"
onClick={() => handleCategoryClick(category)}
style={{ '--category-color': category.color }}
>
<div className="media-card-icon">
<Icon size={48} strokeWidth={2} />
</div>
<h2 className="media-card-title">{category.name}</h2>
</button>
)
})}
</div>
</div>
)
}
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 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{/* Голова */}
<path d="M12 8C15 8 17 10 17 12.5C17 13.5 16.5 14.5 16 15" />
<path d="M12 8C9 8 7 10 7 12.5C7 13.5 7.5 14.5 8 15" />
{/* Уши */}
<path d="M10 6L9 3L8 6" />
<path d="M14 6L15 3L16 6" />
{/* Мордочка */}
<ellipse cx="12" cy="13" rx="4.5" ry="3.5" />
{/* Глаза */}
<circle cx="10" cy="12.5" r="1.2" fill="currentColor" />
<circle cx="14" cy="12.5" r="1.2" fill="currentColor" />
{/* Нос */}
<path d="M12 14.5L11.5 15.5L12 16.5L12.5 15.5Z" fill="currentColor" />
{/* Рот */}
<path d="M10.5 15C11 16 11.5 16.5 12 16.5C12.5 16.5 13 16 13.5 15" />
{/* Туловище */}
<ellipse cx="12" cy="18.5" rx="3.5" ry="2.5" />
{/* Хвост */}
<path d="M15.5 17C17.5 16.5 19 18 19.5 20C20 22 19 23.5 17.5 24" />
</svg>
)
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 (
<div className="media-page">
<div className="media-header">
<h1>Media</h1>
</div>
<div className="media-grid">
{categories.map((category) => {
const Icon = category.icon
return (
<button
key={category.id}
className="media-card"
onClick={() => handleCategoryClick(category)}
style={{ '--category-color': category.color }}
>
<div className="media-card-icon">
<Icon size={48} strokeWidth={2} />
</div>
<h2 className="media-card-title">{category.name}</h2>
</button>
)
})}
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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);
}

View File

@ -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 (
<div key={track._id} className="track-item">
<div className="track-cover">
{track.coverImage ? (
<img src={track.coverImage} alt={track.title} />
) : (
<Music size={24} />
)}
</div>
<div className="track-info">
<div className="track-title">{track.title}</div>
<div className="track-artist">{track.artist?.name || 'Unknown'}</div>
</div>
<div className="track-actions">
<button className="track-btn" onClick={() => handlePlayTrack(track, trackList)}>
<Play size={20} />
</button>
<button
className={`track-btn ${isFavorite ? 'active' : ''}`}
onClick={() => handleToggleFavorite(track)}
>
<Heart size={20} fill={isFavorite ? 'currentColor' : 'none'} />
</button>
<button className="track-btn" onClick={() => handleDownloadTrack(track)}>
<Download size={20} />
</button>
</div>
</div>
)
}
return (
<div className="media-music-page">
<div className="media-music-header">
<button className="back-btn" onClick={() => navigate('/media')}>
<ArrowLeft size={24} />
</button>
<h1>Music</h1>
<div style={{ width: '40px' }} />
</div>
{/* Табы */}
<div className="music-tabs">
<button
className={`music-tab ${activeTab === 'favorites' ? 'active' : ''}`}
onClick={() => setActiveTab('favorites')}
>
Избранное
</button>
<button
className={`music-tab ${activeTab === 'browse' ? 'active' : ''}`}
onClick={() => setActiveTab('browse')}
>
Обзор
</button>
<button
className="music-tab upload-tab"
onClick={() => setShowUpload(true)}
>
<Upload size={16} />
</button>
</div>
{/* Избранное */}
{activeTab === 'favorites' && (
<div className="music-favorites">
{loading ? (
<div className="loading-state">
<div className="spinner" />
<p>Загрузка...</p>
</div>
) : favorites.length === 0 ? (
<div className="empty-state">
<Heart size={48} color="var(--text-secondary)" />
<p>Нет избранных треков</p>
<span>Добавьте треки в избранное</span>
</div>
) : (
<div className="tracks-list">
{favorites.map(track => renderTrackItem(track, favorites))}
</div>
)}
</div>
)}
{/* Обзор (поиск + все треки) */}
{activeTab === 'browse' && (
<div className="music-search">
<div className="search-input-wrapper">
<SearchIcon size={20} className="search-icon" />
<input
type="text"
placeholder="Поиск треков, исполнителей..."
value={query}
onChange={e => setQuery(e.target.value)}
onKeyPress={e => {
if (e.key === 'Enter') {
handleSearch()
}
}}
/>
{query && (
<button className="clear-btn" onClick={() => setQuery('')}>
<X size={18} />
</button>
)}
<button
className="search-submit-btn"
onClick={handleSearch}
disabled={!query.trim() || loading}
>
<SearchIcon size={20} />
</button>
</div>
{loading ? (
<div className="loading-state">
<div className="spinner" />
<p>Поиск...</p>
</div>
) : searchResults.tracks.length === 0 && !query ? (
<div className="empty-state">
<Music size={48} color="var(--text-secondary)" />
<p>Введите запрос для поиска</p>
<span>Ищите треки, исполнителей и альбомы</span>
</div>
) : (
<div className="search-results">
{searchResults.tracks.length > 0 && (
<div className="results-section">
<h3>Треки</h3>
<div className="tracks-list">
{searchResults.tracks.map(track => renderTrackItem(track, searchResults.tracks))}
</div>
</div>
)}
{searchResults.artists.length > 0 && (
<div className="results-section">
<h3>Исполнители</h3>
<div className="artists-list">
{searchResults.artists.map(artist => (
<div key={artist._id} className="artist-item">
<div className="artist-name">{artist.name}</div>
<div className="artist-stats">
{artist.stats.tracks} треков
</div>
</div>
))}
</div>
</div>
)}
{searchResults.albums.length > 0 && (
<div className="results-section">
<h3>Альбомы</h3>
<div className="albums-list">
{searchResults.albums.map(album => (
<div key={album._id} className="album-item">
<div className="album-cover">
{album.coverImage ? (
<img src={album.coverImage} alt={album.title} />
) : (
<Music size={32} />
)}
</div>
<div className="album-info">
<div className="album-title">{album.title}</div>
<div className="album-artist">{album.artist?.name}</div>
</div>
</div>
))}
</div>
</div>
)}
{searchResults.tracks.length === 0 &&
searchResults.artists.length === 0 &&
searchResults.albums.length === 0 && (
<div className="empty-state">
<p>Ничего не найдено</p>
<span>Попробуйте другой запрос</span>
</div>
)}
</div>
)}
</div>
)}
{/* Модал загрузки */}
{showUpload && (
<UploadTrackModal
user={user}
onClose={() => setShowUpload(false)}
onUploaded={() => {
setShowUpload(false)
loadTracks()
}}
/>
)}
</div>
)
}
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 (
<div key={track._id} className="track-item">
<div className="track-cover">
{track.coverImage ? (
<img
src={track.coverImage.startsWith('http')
? track.coverImage
: (import.meta.env.VITE_API_URL || 'http://localhost:3000/api').replace('/api', '') + track.coverImage
}
alt={track.title}
onError={(e) => {
e.target.style.display = 'none'
e.target.nextElementSibling.style.display = 'flex'
}}
/>
) : null}
<div style={{ display: track.coverImage ? 'none' : 'flex' }}>
<Music size={24} />
</div>
</div>
<div className="track-info">
<div className="track-title">{track.title}</div>
<div className="track-artist">{track.artist?.name || 'Unknown'}</div>
</div>
<div className="track-actions">
<button className="track-btn" onClick={() => handlePlayTrack(track, trackList)}>
<Play size={20} />
</button>
<button
className={`track-btn ${isFavorite ? 'active' : ''}`}
onClick={() => handleToggleFavorite(track)}
>
<Heart size={20} fill={isFavorite ? 'currentColor' : 'none'} />
</button>
<button className="track-btn" onClick={() => handleDownloadTrack(track)}>
<Download size={20} />
</button>
</div>
</div>
)
}
return (
<div className="media-music-page">
<div className="media-music-header">
<button className="back-btn" onClick={() => navigate('/media')}>
<ArrowLeft size={24} />
</button>
<h1>Music</h1>
<div style={{ width: '40px' }} />
</div>
{/* Табы */}
<div className="music-tabs">
<button
className={`music-tab ${activeTab === 'favorites' ? 'active' : ''}`}
onClick={() => setActiveTab('favorites')}
>
Избранное
</button>
<button
className={`music-tab ${activeTab === 'browse' ? 'active' : ''}`}
onClick={() => setActiveTab('browse')}
>
Обзор
</button>
<button
className="music-tab upload-tab"
onClick={() => setShowUpload(true)}
>
<Upload size={16} />
</button>
</div>
{/* Избранное */}
{activeTab === 'favorites' && (
<div className="music-favorites">
{loading ? (
<div className="loading-state">
<div className="spinner" />
<p>Загрузка...</p>
</div>
) : favorites.length === 0 ? (
<div className="empty-state">
<Heart size={48} color="var(--text-secondary)" />
<p>Нет избранных треков</p>
<span>Добавьте треки в избранное</span>
</div>
) : (
<div className="tracks-list">
{favorites.map(track => renderTrackItem(track, favorites))}
</div>
)}
</div>
)}
{/* Обзор (поиск + все треки) */}
{activeTab === 'browse' && (
<div className="music-search">
<div className="search-input-wrapper">
<SearchIcon size={20} className="search-icon" />
<input
type="text"
placeholder="Поиск треков, исполнителей..."
value={query}
onChange={e => setQuery(e.target.value)}
onKeyPress={e => {
if (e.key === 'Enter') {
handleSearch()
}
}}
/>
{query && (
<button className="clear-btn" onClick={() => setQuery('')}>
<X size={18} />
</button>
)}
<button
className="search-submit-btn"
onClick={handleSearch}
disabled={!query.trim() || loading}
>
<SearchIcon size={20} />
</button>
</div>
{loading ? (
<div className="loading-state">
<div className="spinner" />
<p>Поиск...</p>
</div>
) : searchResults.tracks.length === 0 && !query ? (
<div className="empty-state">
<Music size={48} color="var(--text-secondary)" />
<p>Введите запрос для поиска</p>
<span>Ищите треки, исполнителей и альбомы</span>
</div>
) : (
<div className="search-results">
{searchResults.tracks.length > 0 && (
<div className="results-section">
<h3>Треки</h3>
<div className="tracks-list">
{searchResults.tracks.map(track => renderTrackItem(track, searchResults.tracks))}
</div>
</div>
)}
{searchResults.artists.length > 0 && (
<div className="results-section">
<h3>Исполнители</h3>
<div className="artists-list">
{searchResults.artists.map(artist => (
<div key={artist._id} className="artist-item">
<div className="artist-name">{artist.name}</div>
<div className="artist-stats">
{artist.stats.tracks} треков
</div>
</div>
))}
</div>
</div>
)}
{searchResults.albums.length > 0 && (
<div className="results-section">
<h3>Альбомы</h3>
<div className="albums-list">
{searchResults.albums.map(album => (
<div key={album._id} className="album-item">
<div className="album-cover">
{album.coverImage ? (
<img src={album.coverImage} alt={album.title} />
) : (
<Music size={32} />
)}
</div>
<div className="album-info">
<div className="album-title">{album.title}</div>
<div className="album-artist">{album.artist?.name}</div>
</div>
</div>
))}
</div>
</div>
)}
{searchResults.tracks.length === 0 &&
searchResults.artists.length === 0 &&
searchResults.albums.length === 0 && (
<div className="empty-state">
<p>Ничего не найдено</p>
<span>Попробуйте другой запрос</span>
</div>
)}
</div>
)}
</div>
)}
{/* Модал загрузки */}
{showUpload && (
<UploadTrackModal
user={user}
onClose={() => setShowUpload(false)}
onUploaded={() => {
setShowUpload(false)
loadTracks()
}}
/>
)}
</div>
)
}

View File

@ -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;
}

View File

@ -76,3 +76,4 @@ export const sendTrackToTelegram = async (trackId) => {
return response.data
}