Update files
This commit is contained in:
parent
e160cf06d5
commit
046066a079
|
|
@ -213,3 +213,4 @@ Album Disc 2.zip
|
|||
|
||||
**Удачной загрузки! 🚀**
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
- Иконка должна быть видна как лиса в стиле аниме
|
||||
|
||||
---
|
||||
|
||||
**Все исправления готовы к тестированию! ✅**
|
||||
|
||||
|
|
@ -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`
|
||||
|
||||
---
|
||||
|
||||
**Все изменения готовы! 🎉**
|
||||
|
||||
|
|
|
|||
|
|
@ -283,3 +283,4 @@ npm run dev
|
|||
|
||||
**Удачной разработки! 🚀**
|
||||
|
||||
|
||||
|
|
|
|||
324
MUSIC_SETUP.md
324
MUSIC_SETUP.md
|
|
@ -1,162 +1,162 @@
|
|||
# Настройка музыкального модуля Nakama
|
||||
|
||||
## Установка зависимостей
|
||||
|
||||
Для работы музыкального модуля требуется установить дополнительные пакеты:
|
||||
|
||||
```bash
|
||||
npm install adm-zip music-metadata
|
||||
```
|
||||
|
||||
- **adm-zip** - для распаковки ZIP-архивов при загрузке альбомов
|
||||
- **music-metadata** - для извлечения метаданных (название, исполнитель, обложка) из аудио файлов
|
||||
|
||||
## Структура файлов
|
||||
|
||||
### Backend
|
||||
|
||||
**Модели:**
|
||||
- `backend/models/Artist.js` - Исполнители
|
||||
- `backend/models/Album.js` - Альбомы
|
||||
- `backend/models/Track.js` - Треки
|
||||
- `backend/models/FavoriteTrack.js` - Избранные треки пользователей
|
||||
|
||||
**Routes:**
|
||||
- `backend/routes/music.js` - API для музыки (загрузка, поиск, избранное)
|
||||
- `backend/routes/bot.js` - Обновлен для отправки треков в Telegram
|
||||
- `backend/routes/posts.js` - Обновлен для поддержки музыкальных вложений
|
||||
|
||||
**Бот:**
|
||||
- `backend/bot.js` - Добавлена функция `sendAudioToUser()` для отправки треков
|
||||
|
||||
### Frontend
|
||||
|
||||
**Страницы:**
|
||||
- `frontend/src/pages/Media.jsx` - Главная страница Media с категориями
|
||||
- `frontend/src/pages/MediaFurry.jsx` - Поиск Furry контента
|
||||
- `frontend/src/pages/MediaAnime.jsx` - Поиск Anime контента
|
||||
- `frontend/src/pages/MediaMusic.jsx` - Музыкальный сервис
|
||||
|
||||
**Компоненты:**
|
||||
- `frontend/src/components/MiniPlayer.jsx` - Мини-плеер (внизу экрана)
|
||||
- `frontend/src/components/FullPlayer.jsx` - Полный плеер
|
||||
- `frontend/src/components/MusicAttachment.jsx` - Отображение музыки в постах
|
||||
|
||||
**Контекст:**
|
||||
- `frontend/src/contexts/MusicPlayerContext.jsx` - Управление состоянием плеера
|
||||
|
||||
**API:**
|
||||
- `frontend/src/utils/musicApi.js` - API функции для работы с музыкой
|
||||
|
||||
## Функционал
|
||||
|
||||
### 1. Media Hub
|
||||
- Главная страница с тремя категориями: Furry, Anime, Music
|
||||
- Квадратные кнопки-карточки с цветовой кодировкой
|
||||
- Анимации и состояния как в остальном приложении
|
||||
|
||||
### 2. Furry / Anime
|
||||
- Отдельные страницы для каждой категории
|
||||
- Поиск по тегам (e621 / gelbooru)
|
||||
- Автокомплит тегов
|
||||
- Просмотр изображений
|
||||
- Добавление в посты
|
||||
- Отправка в Telegram
|
||||
- Режим выбора нескольких изображений
|
||||
|
||||
### 3. Music Service
|
||||
|
||||
**Загрузка:**
|
||||
- Загрузка отдельных треков (MP3, WAV, OGG, M4A, FLAC)
|
||||
- Загрузка альбомов из ZIP архива
|
||||
- Автоматическое извлечение метаданных из аудио файлов:
|
||||
- Название трека
|
||||
- Исполнитель
|
||||
- Альбом
|
||||
- Год
|
||||
- Жанр
|
||||
- Номер трека
|
||||
- Обложка (из ID3 тегов)
|
||||
- Возможность редактирования метаданных при загрузке
|
||||
- Автоматическое создание исполнителей и альбомов
|
||||
|
||||
**Поиск:**
|
||||
- Поиск по трекам, исполнителям, альбомам
|
||||
- Фильтрация результатов
|
||||
|
||||
**Плеер:**
|
||||
- Мини-плеер (закреплен внизу)
|
||||
- Полный плеер (открывается по клику)
|
||||
- Управление воспроизведением (play/pause/next/prev)
|
||||
- Прогресс-бар с перемоткой
|
||||
- Регулировка громкости
|
||||
- Очередь воспроизведения
|
||||
|
||||
**Избранное:**
|
||||
- Добавление треков в избранное
|
||||
- Просмотр избранных треков
|
||||
|
||||
**Telegram интеграция:**
|
||||
- Отправка треков в личные сообщения
|
||||
- Треки отправляются как аудио файлы с метаданными
|
||||
|
||||
### 4. Музыка в постах
|
||||
- Прикрепление треков к постам
|
||||
- Воспроизведение через общий плеер
|
||||
- Отображение в ленте
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Music
|
||||
|
||||
**GET** `/api/music/search?q=query&type=all` - Поиск музыки
|
||||
|
||||
**GET** `/api/music/tracks?limit=50&page=1` - Список треков
|
||||
|
||||
**GET** `/api/music/tracks/:trackId` - Получить трек
|
||||
|
||||
**GET** `/api/music/albums/:albumId` - Получить альбом с треками
|
||||
|
||||
**POST** `/api/music/upload-track` - Загрузить трек
|
||||
- FormData: track (file), title, artistName, albumTitle, trackNumber, year, genre
|
||||
|
||||
**POST** `/api/music/upload-album` - Загрузить альбом (ZIP)
|
||||
- FormData: album (zip), artistName, albumTitle, year, genre
|
||||
|
||||
**POST** `/api/music/favorites/:trackId` - Добавить в избранное
|
||||
|
||||
**DELETE** `/api/music/favorites/:trackId` - Удалить из избранного
|
||||
|
||||
**GET** `/api/music/favorites` - Получить избранные треки
|
||||
|
||||
**POST** `/api/music/tracks/:trackId/play` - Отметить прослушивание
|
||||
|
||||
### Bot
|
||||
|
||||
**POST** `/api/bot/send-track` - Отправить трек в Telegram
|
||||
```json
|
||||
{
|
||||
"userId": 123456789,
|
||||
"trackId": "track_id"
|
||||
}
|
||||
```
|
||||
|
||||
## Навигация
|
||||
|
||||
Вкладка "Search" переименована в "Media" с иконкой Layers.
|
||||
|
||||
## Цветовая схема
|
||||
|
||||
- **Furry:** `var(--tag-furry)` - оранжевый (#FF8A33)
|
||||
- **Anime:** `var(--tag-anime)` - синий (#4A90E2)
|
||||
- **Music:** `#9b59b6` - фиолетовый
|
||||
|
||||
## Примечания
|
||||
|
||||
1. Музыкальные файлы хранятся в `backend/uploads/music/`
|
||||
2. Поддерживаемые форматы: MP3, WAV, OGG, M4A
|
||||
3. Максимальный размер файла: 50MB
|
||||
4. ZIP архивы автоматически распаковываются и создают альбом
|
||||
5. Плеер работает глобально - музыка продолжает играть при навигации
|
||||
6. Треки в постах воспроизводятся через общий плеер
|
||||
|
||||
# Настройка музыкального модуля Nakama
|
||||
|
||||
## Установка зависимостей
|
||||
|
||||
Для работы музыкального модуля требуется установить дополнительные пакеты:
|
||||
|
||||
```bash
|
||||
npm install adm-zip music-metadata
|
||||
```
|
||||
|
||||
- **adm-zip** - для распаковки ZIP-архивов при загрузке альбомов
|
||||
- **music-metadata** - для извлечения метаданных (название, исполнитель, обложка) из аудио файлов
|
||||
|
||||
## Структура файлов
|
||||
|
||||
### Backend
|
||||
|
||||
**Модели:**
|
||||
- `backend/models/Artist.js` - Исполнители
|
||||
- `backend/models/Album.js` - Альбомы
|
||||
- `backend/models/Track.js` - Треки
|
||||
- `backend/models/FavoriteTrack.js` - Избранные треки пользователей
|
||||
|
||||
**Routes:**
|
||||
- `backend/routes/music.js` - API для музыки (загрузка, поиск, избранное)
|
||||
- `backend/routes/bot.js` - Обновлен для отправки треков в Telegram
|
||||
- `backend/routes/posts.js` - Обновлен для поддержки музыкальных вложений
|
||||
|
||||
**Бот:**
|
||||
- `backend/bot.js` - Добавлена функция `sendAudioToUser()` для отправки треков
|
||||
|
||||
### Frontend
|
||||
|
||||
**Страницы:**
|
||||
- `frontend/src/pages/Media.jsx` - Главная страница Media с категориями
|
||||
- `frontend/src/pages/MediaFurry.jsx` - Поиск Furry контента
|
||||
- `frontend/src/pages/MediaAnime.jsx` - Поиск Anime контента
|
||||
- `frontend/src/pages/MediaMusic.jsx` - Музыкальный сервис
|
||||
|
||||
**Компоненты:**
|
||||
- `frontend/src/components/MiniPlayer.jsx` - Мини-плеер (внизу экрана)
|
||||
- `frontend/src/components/FullPlayer.jsx` - Полный плеер
|
||||
- `frontend/src/components/MusicAttachment.jsx` - Отображение музыки в постах
|
||||
|
||||
**Контекст:**
|
||||
- `frontend/src/contexts/MusicPlayerContext.jsx` - Управление состоянием плеера
|
||||
|
||||
**API:**
|
||||
- `frontend/src/utils/musicApi.js` - API функции для работы с музыкой
|
||||
|
||||
## Функционал
|
||||
|
||||
### 1. Media Hub
|
||||
- Главная страница с тремя категориями: Furry, Anime, Music
|
||||
- Квадратные кнопки-карточки с цветовой кодировкой
|
||||
- Анимации и состояния как в остальном приложении
|
||||
|
||||
### 2. Furry / Anime
|
||||
- Отдельные страницы для каждой категории
|
||||
- Поиск по тегам (e621 / gelbooru)
|
||||
- Автокомплит тегов
|
||||
- Просмотр изображений
|
||||
- Добавление в посты
|
||||
- Отправка в Telegram
|
||||
- Режим выбора нескольких изображений
|
||||
|
||||
### 3. Music Service
|
||||
|
||||
**Загрузка:**
|
||||
- Загрузка отдельных треков (MP3, WAV, OGG, M4A, FLAC)
|
||||
- Загрузка альбомов из ZIP архива
|
||||
- Автоматическое извлечение метаданных из аудио файлов:
|
||||
- Название трека
|
||||
- Исполнитель
|
||||
- Альбом
|
||||
- Год
|
||||
- Жанр
|
||||
- Номер трека
|
||||
- Обложка (из ID3 тегов)
|
||||
- Возможность редактирования метаданных при загрузке
|
||||
- Автоматическое создание исполнителей и альбомов
|
||||
|
||||
**Поиск:**
|
||||
- Поиск по трекам, исполнителям, альбомам
|
||||
- Фильтрация результатов
|
||||
|
||||
**Плеер:**
|
||||
- Мини-плеер (закреплен внизу)
|
||||
- Полный плеер (открывается по клику)
|
||||
- Управление воспроизведением (play/pause/next/prev)
|
||||
- Прогресс-бар с перемоткой
|
||||
- Регулировка громкости
|
||||
- Очередь воспроизведения
|
||||
|
||||
**Избранное:**
|
||||
- Добавление треков в избранное
|
||||
- Просмотр избранных треков
|
||||
|
||||
**Telegram интеграция:**
|
||||
- Отправка треков в личные сообщения
|
||||
- Треки отправляются как аудио файлы с метаданными
|
||||
|
||||
### 4. Музыка в постах
|
||||
- Прикрепление треков к постам
|
||||
- Воспроизведение через общий плеер
|
||||
- Отображение в ленте
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Music
|
||||
|
||||
**GET** `/api/music/search?q=query&type=all` - Поиск музыки
|
||||
|
||||
**GET** `/api/music/tracks?limit=50&page=1` - Список треков
|
||||
|
||||
**GET** `/api/music/tracks/:trackId` - Получить трек
|
||||
|
||||
**GET** `/api/music/albums/:albumId` - Получить альбом с треками
|
||||
|
||||
**POST** `/api/music/upload-track` - Загрузить трек
|
||||
- FormData: track (file), title, artistName, albumTitle, trackNumber, year, genre
|
||||
|
||||
**POST** `/api/music/upload-album` - Загрузить альбом (ZIP)
|
||||
- FormData: album (zip), artistName, albumTitle, year, genre
|
||||
|
||||
**POST** `/api/music/favorites/:trackId` - Добавить в избранное
|
||||
|
||||
**DELETE** `/api/music/favorites/:trackId` - Удалить из избранного
|
||||
|
||||
**GET** `/api/music/favorites` - Получить избранные треки
|
||||
|
||||
**POST** `/api/music/tracks/:trackId/play` - Отметить прослушивание
|
||||
|
||||
### Bot
|
||||
|
||||
**POST** `/api/bot/send-track` - Отправить трек в Telegram
|
||||
```json
|
||||
{
|
||||
"userId": 123456789,
|
||||
"trackId": "track_id"
|
||||
}
|
||||
```
|
||||
|
||||
## Навигация
|
||||
|
||||
Вкладка "Search" переименована в "Media" с иконкой Layers.
|
||||
|
||||
## Цветовая схема
|
||||
|
||||
- **Furry:** `var(--tag-furry)` - оранжевый (#FF8A33)
|
||||
- **Anime:** `var(--tag-anime)` - синий (#4A90E2)
|
||||
- **Music:** `#9b59b6` - фиолетовый
|
||||
|
||||
## Примечания
|
||||
|
||||
1. Музыкальные файлы хранятся в `backend/uploads/music/`
|
||||
2. Поддерживаемые форматы: MP3, WAV, OGG, M4A
|
||||
3. Максимальный размер файла: 50MB
|
||||
4. ZIP архивы автоматически распаковываются и создают альбом
|
||||
5. Плеер работает глобально - музыка продолжает играть при навигации
|
||||
6. Треки в постах воспроизводятся через общий плеер
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
---
|
||||
|
||||
**Быстрого старта! 🚀**
|
||||
|
||||
|
|
|
|||
|
|
@ -81,3 +81,4 @@ npm list adm-zip music-metadata
|
|||
|
||||
**Готово к production! ✅**
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -52,3 +52,4 @@ albumSchema.index({ createdAt: -1 });
|
|||
|
||||
module.exports = mongoose.model('Album', albumSchema);
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -52,3 +52,4 @@ artistSchema.pre('save', function(next) {
|
|||
|
||||
module.exports = mongoose.model('Artist', artistSchema);
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -23,3 +23,4 @@ favoriteTrackSchema.index({ createdAt: -1 });
|
|||
|
||||
module.exports = mongoose.model('FavoriteTrack', favoriteTrackSchema);
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })
|
|||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
|||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -76,3 +76,4 @@ export const sendTrackToTelegram = async (trackId) => {
|
|||
return response.data
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue