Update files
This commit is contained in:
parent
fe45fbb159
commit
e160cf06d5
|
|
@ -0,0 +1,215 @@
|
||||||
|
# 📀 Руководство по загрузке альбомов
|
||||||
|
|
||||||
|
## 🎵 Загрузка ZIP альбома
|
||||||
|
|
||||||
|
### Подготовка ZIP архива
|
||||||
|
|
||||||
|
1. **Соберите треки в одну папку:**
|
||||||
|
```
|
||||||
|
My Album/
|
||||||
|
├── 01 - First Track.mp3
|
||||||
|
├── 02 - Second Track.mp3
|
||||||
|
├── 03 - Third Track.mp3
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Создайте ZIP архив:**
|
||||||
|
- Правый клик на папке → Отправить → Сжатая ZIP-папка
|
||||||
|
- Или используйте любой архиватор (7-Zip, WinRAR)
|
||||||
|
|
||||||
|
3. **Требования:**
|
||||||
|
- Максимальный размер: **100MB**
|
||||||
|
- Поддерживаемые форматы: MP3, WAV, OGG, M4A, FLAC
|
||||||
|
- Файлы могут быть в подпапках - будут найдены автоматически
|
||||||
|
|
||||||
|
### 📋 Метаданные (ID3 теги)
|
||||||
|
|
||||||
|
Система автоматически извлекает из аудио файлов:
|
||||||
|
- ✅ **Название трека** (TITLE)
|
||||||
|
- ✅ **Исполнитель** (ARTIST)
|
||||||
|
- ✅ **Альбом** (ALBUM)
|
||||||
|
- ✅ **Год** (YEAR)
|
||||||
|
- ✅ **Жанр** (GENRE)
|
||||||
|
- ✅ **Номер трека** (TRACK NUMBER)
|
||||||
|
- ✅ **Обложка** (PICTURE/APIC)
|
||||||
|
- ✅ **Длительность** (автоматически)
|
||||||
|
|
||||||
|
### 🎨 Обложка альбома
|
||||||
|
|
||||||
|
Обложка извлекается из ID3 тегов первого трека:
|
||||||
|
- Если в треках есть встроенная обложка - она будет использована
|
||||||
|
- Поддерживаемые форматы: JPEG, PNG
|
||||||
|
- Размер: рекомендуется 500x500 - 1000x1000 пикселей
|
||||||
|
|
||||||
|
### 📝 Как загрузить
|
||||||
|
|
||||||
|
1. **Откройте Music раздел:**
|
||||||
|
Media → Music
|
||||||
|
|
||||||
|
2. **Нажмите кнопку Upload** (иконка справа в табах)
|
||||||
|
|
||||||
|
3. **Выберите ZIP файл:**
|
||||||
|
- Выберите подготовленный ZIP архив
|
||||||
|
|
||||||
|
4. **Заполните базовые данные:**
|
||||||
|
- **Исполнитель*** - обязательно (можно оставить из метаданных)
|
||||||
|
- **Название альбома*** - обязательно (можно оставить из метаданных)
|
||||||
|
- **Год** - необязательно
|
||||||
|
- **Жанр** - необязательно
|
||||||
|
|
||||||
|
5. **Нажмите "Загрузить альбом":**
|
||||||
|
- Система распакует архив
|
||||||
|
- Извлечет метаданные из каждого трека
|
||||||
|
- Создаст исполнителя (если не существует)
|
||||||
|
- Создаст альбом
|
||||||
|
- Создаст все треки с правильными данными
|
||||||
|
|
||||||
|
### ⚡ Что происходит автоматически
|
||||||
|
|
||||||
|
1. **Распаковка ZIP:**
|
||||||
|
- Поиск всех аудио файлов в архиве
|
||||||
|
- Извлечение в папку `backend/uploads/music/`
|
||||||
|
|
||||||
|
2. **Извлечение метаданных:**
|
||||||
|
- Чтение ID3 тегов из каждого файла
|
||||||
|
- Извлечение обложки из первого трека
|
||||||
|
- Сохранение обложки как отдельный файл
|
||||||
|
|
||||||
|
3. **Создание записей:**
|
||||||
|
- Исполнитель (если не существует)
|
||||||
|
- Альбом с обложкой
|
||||||
|
- Треки с индивидуальными метаданными
|
||||||
|
|
||||||
|
4. **Обновление статистики:**
|
||||||
|
- Счетчик треков исполнителя
|
||||||
|
- Счетчик альбомов исполнителя
|
||||||
|
- Общая длительность альбома
|
||||||
|
|
||||||
|
### 📊 Пример обработки
|
||||||
|
|
||||||
|
**Входные данные:**
|
||||||
|
```
|
||||||
|
Album.zip (содержит):
|
||||||
|
├── 01 - Track One.mp3 (ID3: title="Track One", artist="Artist", album="My Album", year=2024)
|
||||||
|
├── 02 - Track Two.mp3 (ID3: title="Track Two", artist="Artist", album="My Album", year=2024)
|
||||||
|
└── cover.jpg (встроена в треки)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Результат:**
|
||||||
|
- Создан исполнитель: "Artist"
|
||||||
|
- Создан альбом: "My Album" (2024) с обложкой
|
||||||
|
- Созданы треки:
|
||||||
|
1. "Track One" (№1)
|
||||||
|
2. "Track Two" (№2)
|
||||||
|
- Все треки имеют обложку альбома
|
||||||
|
|
||||||
|
### 🔧 Редактирование метаданных
|
||||||
|
|
||||||
|
Если метаданные в файлах неполные или неверные:
|
||||||
|
|
||||||
|
1. **При загрузке можно изменить:**
|
||||||
|
- Исполнителя (применится ко всем трекам)
|
||||||
|
- Название альбома
|
||||||
|
- Год
|
||||||
|
- Жанр
|
||||||
|
|
||||||
|
2. **Что берется из файлов:**
|
||||||
|
- Название каждого трека
|
||||||
|
- Номер трека
|
||||||
|
- Индивидуальная обложка (если есть)
|
||||||
|
- Длительность
|
||||||
|
|
||||||
|
### 🎯 Рекомендации
|
||||||
|
|
||||||
|
**Для лучшего качества метаданных:**
|
||||||
|
|
||||||
|
1. Используйте программу для редактирования ID3 тегов:
|
||||||
|
- **Mp3tag** (Windows) - бесплатно
|
||||||
|
- **MusicBrainz Picard** - кросс-платформенный
|
||||||
|
- **Kid3** - кросс-платформенный
|
||||||
|
|
||||||
|
2. Добавьте обложку во все треки:
|
||||||
|
- Формат: JPEG или PNG
|
||||||
|
- Размер: 500x500 или больше
|
||||||
|
- Встроена в файл (не отдельным файлом)
|
||||||
|
|
||||||
|
3. Заполните базовые теги:
|
||||||
|
```
|
||||||
|
TITLE: Track Name
|
||||||
|
ARTIST: Artist Name
|
||||||
|
ALBUM: Album Name
|
||||||
|
YEAR: 2024
|
||||||
|
GENRE: Electronic
|
||||||
|
TRACK: 1/10
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Используйте последовательную нумерацию:
|
||||||
|
```
|
||||||
|
01 - Track Name.mp3
|
||||||
|
02 - Track Name.mp3
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### ⚠️ Ограничения
|
||||||
|
|
||||||
|
- Максимальный размер ZIP: **100MB**
|
||||||
|
- Максимальное количество треков: **неограниченно**
|
||||||
|
- Поддерживаемые форматы: MP3, WAV, OGG, M4A, FLAC
|
||||||
|
- Вложенные папки: **поддерживаются** (треки будут найдены на любом уровне)
|
||||||
|
|
||||||
|
### 🐛 Проблемы
|
||||||
|
|
||||||
|
**"В архиве нет аудио файлов"**
|
||||||
|
- Убедитесь что файлы имеют расширения: .mp3, .wav, .ogg, .m4a, .flac
|
||||||
|
- Проверьте что файлы не повреждены
|
||||||
|
|
||||||
|
**"ZIP файл слишком большой"**
|
||||||
|
- Уменьшите битрейт треков
|
||||||
|
- Разбейте альбом на несколько ZIP (по дискам)
|
||||||
|
- Используйте формат с меньшим размером (MP3 320kbps вместо FLAC)
|
||||||
|
|
||||||
|
**"Обложка не загрузилась"**
|
||||||
|
- Убедитесь что обложка встроена в ID3 теги
|
||||||
|
- Размер обложки не должен превышать 5MB
|
||||||
|
- Формат: JPEG или PNG
|
||||||
|
|
||||||
|
**"Неверные метаданные"**
|
||||||
|
- Отредактируйте ID3 теги перед загрузкой с помощью Mp3tag
|
||||||
|
- Или заполните поля вручную при загрузке
|
||||||
|
|
||||||
|
### 📚 Примеры структуры ZIP
|
||||||
|
|
||||||
|
**Вариант 1 - Простой:**
|
||||||
|
```
|
||||||
|
Album.zip
|
||||||
|
├── Track 01.mp3
|
||||||
|
├── Track 02.mp3
|
||||||
|
└── Track 03.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
**Вариант 2 - С подпапками:**
|
||||||
|
```
|
||||||
|
Album.zip
|
||||||
|
└── Album Name/
|
||||||
|
├── 01 - Track One.mp3
|
||||||
|
├── 02 - Track Two.mp3
|
||||||
|
└── cover.jpg (не используется, нужна встроенная)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Вариант 3 - Несколько дисков:**
|
||||||
|
```
|
||||||
|
Album Disc 1.zip
|
||||||
|
├── CD1/
|
||||||
|
│ ├── 01 - Track.mp3
|
||||||
|
│ └── 02 - Track.mp3
|
||||||
|
|
||||||
|
Album Disc 2.zip
|
||||||
|
├── CD2/
|
||||||
|
│ ├── 01 - Track.mp3
|
||||||
|
│ └── 02 - Track.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Удачной загрузки! 🚀**
|
||||||
|
|
||||||
|
|
@ -0,0 +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`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Все изменения готовы! 🎉**
|
||||||
|
|
||||||
|
|
@ -0,0 +1,285 @@
|
||||||
|
# 🔥 Dev Setup - Разработка без Telegram
|
||||||
|
|
||||||
|
Эта инструкция для быстрого запуска приложения в режиме разработки БЕЗ проверки Telegram initData.
|
||||||
|
|
||||||
|
## 🚀 Быстрый старт
|
||||||
|
|
||||||
|
### 1. Скопируйте dev конфигурацию
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
REM Backend
|
||||||
|
copy .env.development .env
|
||||||
|
|
||||||
|
REM Frontend
|
||||||
|
cd frontend
|
||||||
|
copy .env.development .env
|
||||||
|
cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Запустите MongoDB
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
net start 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
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Установите зависимости (если еще не установлены)
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
npm install
|
||||||
|
npm install adm-zip
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Запустите приложение
|
||||||
|
|
||||||
|
**Вариант A - Оба сразу:**
|
||||||
|
```cmd
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Вариант B - Раздельно:**
|
||||||
|
|
||||||
|
Окно 1 - Backend:
|
||||||
|
```cmd
|
||||||
|
npm run server
|
||||||
|
```
|
||||||
|
|
||||||
|
Окно 2 - Frontend:
|
||||||
|
```cmd
|
||||||
|
cd frontend
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Откройте браузер
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ Готово! Приложение работает БЕЗ Telegram!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Как это работает
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- `DISABLE_TELEGRAM_AUTH=true` в `.env` отключает проверку initData
|
||||||
|
- `backend/middleware/devAuth.js` создает тестового пользователя
|
||||||
|
- Автоматически создается пользователь с ID `123456789`
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- `VITE_MOCK_TELEGRAM=true` включает mock Telegram WebApp
|
||||||
|
- `frontend/src/utils/telegram.js` создает фиктивный `window.Telegram.WebApp`
|
||||||
|
- Все функции Telegram доступны, но не отправляют реальные запросы
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 👤 Тестовые пользователи
|
||||||
|
|
||||||
|
### Обычный пользователь
|
||||||
|
- ID: `123456789`
|
||||||
|
- Username: `DevUser`
|
||||||
|
- Имя: `Dev User`
|
||||||
|
|
||||||
|
### Модератор (для тестирования модерации)
|
||||||
|
- ID: `987654321`
|
||||||
|
- Username: `DevModerator`
|
||||||
|
- Роль: `moderator`
|
||||||
|
|
||||||
|
### Настройка тестового пользователя
|
||||||
|
|
||||||
|
В `.env` можете изменить:
|
||||||
|
```env
|
||||||
|
DEV_USER_ID=123456789
|
||||||
|
DEV_USERNAME=MyDevUser
|
||||||
|
DEV_FIRST_NAME=My
|
||||||
|
DEV_LAST_NAME=DevUser
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Доступные функции в Dev режиме
|
||||||
|
|
||||||
|
✅ Создание постов
|
||||||
|
✅ Комментарии
|
||||||
|
✅ Лайки
|
||||||
|
✅ Подписки
|
||||||
|
✅ Поиск (Furry/Anime/Music)
|
||||||
|
✅ Загрузка изображений
|
||||||
|
✅ Загрузка музыки
|
||||||
|
✅ Музыкальный плеер
|
||||||
|
✅ Уведомления
|
||||||
|
✅ Профиль
|
||||||
|
✅ Настройки
|
||||||
|
|
||||||
|
❌ Отправка в Telegram (требует реального бота)
|
||||||
|
❌ Telegram уведомления
|
||||||
|
❌ Реферальная система
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗄️ База данных
|
||||||
|
|
||||||
|
В dev режиме используется отдельная база:
|
||||||
|
```
|
||||||
|
mongodb://localhost:27017/nakama-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Очистка базы данных
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
mongosh mongodb://localhost:27017/nakama-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Удалить все посты
|
||||||
|
db.posts.deleteMany({})
|
||||||
|
|
||||||
|
// Удалить всех пользователей
|
||||||
|
db.users.deleteMany({})
|
||||||
|
|
||||||
|
// Удалить все уведомления
|
||||||
|
db.notifications.deleteMany({})
|
||||||
|
|
||||||
|
// Полная очистка
|
||||||
|
db.dropDatabase()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Переключение обратно на Production режим
|
||||||
|
|
||||||
|
### 1. Скопируйте production конфиг
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
copy ENV_EXAMPLE.txt .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Настройте `.env`
|
||||||
|
|
||||||
|
```env
|
||||||
|
DISABLE_TELEGRAM_AUTH=false
|
||||||
|
TELEGRAM_BOT_TOKEN=your_real_bot_token
|
||||||
|
MONGODB_URI=your_production_mongodb_uri
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Перезапустите сервер
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
npm run server
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Отладка
|
||||||
|
|
||||||
|
### Логи
|
||||||
|
|
||||||
|
Backend показывает:
|
||||||
|
```
|
||||||
|
⚠️ DEV MODE: Telegram auth disabled
|
||||||
|
✅ Created dev user: DevUser (123456789)
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend показывает:
|
||||||
|
```
|
||||||
|
✅ Mock Telegram WebApp initialized (DEV MODE)
|
||||||
|
⚠️ Running in DEV MODE with mock Telegram WebApp
|
||||||
|
```
|
||||||
|
|
||||||
|
### Проверка режима
|
||||||
|
|
||||||
|
В консоли backend:
|
||||||
|
```javascript
|
||||||
|
console.log('Dev mode:', process.env.DISABLE_TELEGRAM_AUTH === 'true')
|
||||||
|
```
|
||||||
|
|
||||||
|
В консоли browser:
|
||||||
|
```javascript
|
||||||
|
console.log('Telegram:', window.Telegram?.WebApp)
|
||||||
|
console.log('InitData:', window.Telegram?.WebApp?.initData)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Важно
|
||||||
|
|
||||||
|
1. **НЕ ИСПОЛЬЗУЙТЕ** dev конфигурацию в production
|
||||||
|
2. **НЕ КОММИТЬТЕ** `.env` в git
|
||||||
|
3. **НЕ ДЕЛИТЕСЬ** dev токенами если они там есть
|
||||||
|
4. При деплое используйте `ENV_EXAMPLE.txt` как шаблон
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Структура Dev файлов
|
||||||
|
|
||||||
|
```
|
||||||
|
nakama/
|
||||||
|
├── .env.development # Dev конфиг backend
|
||||||
|
├── .env # Текущий конфиг (копия .env.development)
|
||||||
|
├── backend/
|
||||||
|
│ └── middleware/
|
||||||
|
│ └── devAuth.js # Dev middleware
|
||||||
|
└── frontend/
|
||||||
|
├── .env.development # Dev конфиг frontend
|
||||||
|
├── .env # Текущий конфиг
|
||||||
|
└── src/
|
||||||
|
└── utils/
|
||||||
|
└── telegram.js # Содержит mock Telegram WebApp
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🆘 Проблемы
|
||||||
|
|
||||||
|
### "Dev user not created"
|
||||||
|
|
||||||
|
Проверьте:
|
||||||
|
```cmd
|
||||||
|
REM MongoDB запущен?
|
||||||
|
net start MongoDB
|
||||||
|
|
||||||
|
REM .env содержит правильные настройки?
|
||||||
|
type .env | findstr DISABLE_TELEGRAM_AUTH
|
||||||
|
```
|
||||||
|
|
||||||
|
### "initData validation failed" даже в dev режиме
|
||||||
|
|
||||||
|
Убедитесь:
|
||||||
|
```env
|
||||||
|
DISABLE_TELEGRAM_AUTH=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Перезапустите backend:
|
||||||
|
```cmd
|
||||||
|
npm run server
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend не видит mock Telegram
|
||||||
|
|
||||||
|
Проверьте `frontend\.env`:
|
||||||
|
```env
|
||||||
|
VITE_MOCK_TELEGRAM=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Перезапустите frontend:
|
||||||
|
```cmd
|
||||||
|
cd frontend
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Удачной разработки! 🚀**
|
||||||
|
|
||||||
|
|
@ -9,8 +9,8 @@ RUN npm ci --only=production
|
||||||
# Копирование backend кода
|
# Копирование backend кода
|
||||||
COPY backend ./backend
|
COPY backend ./backend
|
||||||
|
|
||||||
# Создание директории для uploads
|
# Создание директорий для uploads
|
||||||
RUN mkdir -p backend/uploads/posts backend/uploads/mod-channel
|
RUN mkdir -p backend/uploads/posts backend/uploads/mod-channel backend/uploads/music
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@
|
||||||
|
|
||||||
## Установка зависимостей
|
## Установка зависимостей
|
||||||
|
|
||||||
Для работы музыкального модуля требуется установить дополнительный пакет:
|
Для работы музыкального модуля требуется установить дополнительные пакеты:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install adm-zip
|
npm install adm-zip music-metadata
|
||||||
```
|
```
|
||||||
|
|
||||||
Этот пакет используется для распаковки ZIP-архивов при загрузке альбомов.
|
- **adm-zip** - для распаковки ZIP-архивов при загрузке альбомов
|
||||||
|
- **music-metadata** - для извлечения метаданных (название, исполнитель, обложка) из аудио файлов
|
||||||
|
|
||||||
## Структура файлов
|
## Структура файлов
|
||||||
|
|
||||||
|
|
@ -66,8 +67,17 @@ npm install adm-zip
|
||||||
### 3. Music Service
|
### 3. Music Service
|
||||||
|
|
||||||
**Загрузка:**
|
**Загрузка:**
|
||||||
- Загрузка отдельных треков
|
- Загрузка отдельных треков (MP3, WAV, OGG, M4A, FLAC)
|
||||||
- Загрузка альбомов из ZIP архива
|
- Загрузка альбомов из ZIP архива
|
||||||
|
- Автоматическое извлечение метаданных из аудио файлов:
|
||||||
|
- Название трека
|
||||||
|
- Исполнитель
|
||||||
|
- Альбом
|
||||||
|
- Год
|
||||||
|
- Жанр
|
||||||
|
- Номер трека
|
||||||
|
- Обложка (из ID3 тегов)
|
||||||
|
- Возможность редактирования метаданных при загрузке
|
||||||
- Автоматическое создание исполнителей и альбомов
|
- Автоматическое создание исполнителей и альбомов
|
||||||
|
|
||||||
**Поиск:**
|
**Поиск:**
|
||||||
|
|
|
||||||
|
|
@ -0,0 +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`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Быстрого старта! 🚀**
|
||||||
|
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
# 📦 Обновление зависимостей и удаление dev режима
|
||||||
|
|
||||||
|
## ✅ Выполнено
|
||||||
|
|
||||||
|
### 1. Добавлены зависимости в package.json
|
||||||
|
```json
|
||||||
|
"adm-zip": "^0.5.10",
|
||||||
|
"music-metadata": "^8.1.4"
|
||||||
|
```
|
||||||
|
|
||||||
|
Обе библиотеки добавлены в `dependencies` (не devDependencies), так как они нужны в production для:
|
||||||
|
- **adm-zip** - распаковка ZIP альбомов
|
||||||
|
- **music-metadata** - извлечение метаданных и обложек из аудио файлов
|
||||||
|
|
||||||
|
### 2. Обновлен Dockerfile.backend
|
||||||
|
|
||||||
|
Добавлена директория для музыки:
|
||||||
|
```dockerfile
|
||||||
|
RUN mkdir -p backend/uploads/posts backend/uploads/mod-channel backend/uploads/music
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Удален dev режим из кода
|
||||||
|
|
||||||
|
#### Удаленные файлы:
|
||||||
|
- ❌ `backend/middleware/devAuth.js` - dev middleware удален
|
||||||
|
|
||||||
|
#### Обновленные файлы:
|
||||||
|
- ✅ `backend/server.js` - убрана проверка DEV_MODE
|
||||||
|
- ✅ Код не содержит упоминаний `DISABLE_TELEGRAM_AUTH`
|
||||||
|
- ✅ Код не содержит mock Telegram WebApp
|
||||||
|
|
||||||
|
### 4. Docker
|
||||||
|
|
||||||
|
Dockerfile.backend использует `npm ci --only=production`, который установит только production зависимости из `dependencies`:
|
||||||
|
- ✅ `adm-zip` - будет установлен
|
||||||
|
- ✅ `music-metadata` - будет установлен
|
||||||
|
|
||||||
|
## 📝 Что нужно сделать после изменений
|
||||||
|
|
||||||
|
### Для локальной разработки:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Это установит все зависимости включая новые.
|
||||||
|
|
||||||
|
### Для Docker:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
docker-compose build backend
|
||||||
|
docker-compose up -d backend
|
||||||
|
```
|
||||||
|
|
||||||
|
Или пересобрать образ:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
docker-compose stop backend
|
||||||
|
docker-compose rm -f backend
|
||||||
|
docker-compose build --no-cache backend
|
||||||
|
docker-compose up -d backend
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 Проверка
|
||||||
|
|
||||||
|
Убедитесь что зависимости установлены:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
npm list adm-zip music-metadata
|
||||||
|
```
|
||||||
|
|
||||||
|
Должно показать установленные версии.
|
||||||
|
|
||||||
|
## ⚠️ Важно
|
||||||
|
|
||||||
|
- Dev режим полностью удален из production кода
|
||||||
|
- Все зависимости находятся в `dependencies` (не devDependencies)
|
||||||
|
- Docker образ будет правильно собираться с новыми зависимостями
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Готово к production! ✅**
|
||||||
|
|
||||||
8
WIND.md
8
WIND.md
|
|
@ -51,7 +51,7 @@ npm install
|
||||||
|
|
||||||
### 3. Установите дополнительные пакеты для музыкального модуля
|
### 3. Установите дополнительные пакеты для музыкального модуля
|
||||||
```cmd
|
```cmd
|
||||||
npm install adm-zip
|
npm install adm-zip music-metadata
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Установите зависимости для frontend
|
### 4. Установите зависимости для frontend
|
||||||
|
|
@ -574,11 +574,11 @@ REM Или измените порт в .env
|
||||||
REM PORT=3001
|
REM PORT=3001
|
||||||
```
|
```
|
||||||
|
|
||||||
### Ошибка: "Cannot find module 'adm-zip'"
|
### Ошибка: "Cannot find module 'adm-zip'" или "Cannot find module 'music-metadata'"
|
||||||
|
|
||||||
**Решение:**
|
**Решение:**
|
||||||
```cmd
|
```cmd
|
||||||
npm install adm-zip
|
npm install adm-zip music-metadata
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend не подключается к Backend
|
### Frontend не подключается к Backend
|
||||||
|
|
@ -634,7 +634,7 @@ npm cache clean --force
|
||||||
- [ ] `.env` создан и настроен
|
- [ ] `.env` создан и настроен
|
||||||
- [ ] `npm install` выполнен в корне проекта
|
- [ ] `npm install` выполнен в корне проекта
|
||||||
- [ ] `npm install` выполнен в `frontend\`
|
- [ ] `npm install` выполнен в `frontend\`
|
||||||
- [ ] `npm install adm-zip` выполнен
|
- [ ] `npm install adm-zip music-metadata` выполнен
|
||||||
- [ ] `DISABLE_TELEGRAM_AUTH=true` в `.env` (для разработки)
|
- [ ] `DISABLE_TELEGRAM_AUTH=true` в `.env` (для разработки)
|
||||||
- [ ] Backend запущен (`npm run server`)
|
- [ ] Backend запущен (`npm run server`)
|
||||||
- [ ] Frontend запущен (`cd frontend && npm run dev`)
|
- [ ] Frontend запущен (`cd frontend && npm run dev`)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ const multer = require('multer');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const AdmZip = require('adm-zip');
|
const AdmZip = require('adm-zip');
|
||||||
|
const mm = require('music-metadata');
|
||||||
const { authenticate } = require('../middleware/auth');
|
const { authenticate } = require('../middleware/auth');
|
||||||
const Artist = require('../models/Artist');
|
const Artist = require('../models/Artist');
|
||||||
const Album = require('../models/Album');
|
const Album = require('../models/Album');
|
||||||
|
|
@ -31,11 +32,14 @@ const storage = multer.diskStorage({
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage,
|
storage,
|
||||||
limits: {
|
limits: {
|
||||||
fileSize: 50 * 1024 * 1024 // 50MB для треков
|
fileSize: 100 * 1024 * 1024 // 100MB для ZIP альбомов
|
||||||
},
|
},
|
||||||
fileFilter: (req, file, cb) => {
|
fileFilter: (req, file, cb) => {
|
||||||
const allowedMimeTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/mp4', 'application/zip'];
|
const allowedMimeTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/mp4', 'audio/m4a', 'application/zip', 'application/x-zip-compressed'];
|
||||||
if (allowedMimeTypes.includes(file.mimetype)) {
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
|
const allowedExts = ['.mp3', '.wav', '.ogg', '.m4a', '.flac', '.zip'];
|
||||||
|
|
||||||
|
if (allowedMimeTypes.includes(file.mimetype) || allowedExts.includes(ext)) {
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
} else {
|
} else {
|
||||||
cb(new Error('Неподдерживаемый формат файла'));
|
cb(new Error('Неподдерживаемый формат файла'));
|
||||||
|
|
@ -43,6 +47,46 @@ const upload = multer({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Извлечь метаданные из аудио файла
|
||||||
|
async function extractAudioMetadata(filePath) {
|
||||||
|
try {
|
||||||
|
const metadata = await mm.parseFile(filePath);
|
||||||
|
|
||||||
|
// Извлечь обложку если есть
|
||||||
|
let coverImagePath = null;
|
||||||
|
if (metadata.common.picture && metadata.common.picture.length > 0) {
|
||||||
|
const picture = metadata.common.picture[0];
|
||||||
|
const coverFileName = `cover-${Date.now()}.${picture.format.split('/')[1] || 'jpg'}`;
|
||||||
|
coverImagePath = path.join(__dirname, '../uploads/music', coverFileName);
|
||||||
|
await fs.writeFile(coverImagePath, picture.data);
|
||||||
|
coverImagePath = `/uploads/music/${coverFileName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: metadata.common.title || null,
|
||||||
|
artist: metadata.common.artist || metadata.common.artists?.[0] || null,
|
||||||
|
album: metadata.common.album || null,
|
||||||
|
year: metadata.common.year || null,
|
||||||
|
genre: metadata.common.genre?.[0] || null,
|
||||||
|
trackNumber: metadata.common.track?.no || null,
|
||||||
|
duration: metadata.format.duration || 0,
|
||||||
|
coverImage: coverImagePath
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка извлечения метаданных:', error);
|
||||||
|
return {
|
||||||
|
title: null,
|
||||||
|
artist: null,
|
||||||
|
album: null,
|
||||||
|
year: null,
|
||||||
|
genre: null,
|
||||||
|
trackNumber: null,
|
||||||
|
duration: 0,
|
||||||
|
coverImage: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Вспомогательная функция для поиска или создания исполнителя
|
// Вспомогательная функция для поиска или создания исполнителя
|
||||||
async function findOrCreateArtist(artistName, userId) {
|
async function findOrCreateArtist(artistName, userId) {
|
||||||
const normalizedName = artistName.toLowerCase().replace(/\s+/g, '');
|
const normalizedName = artistName.toLowerCase().replace(/\s+/g, '');
|
||||||
|
|
@ -67,13 +111,18 @@ router.post('/upload-track', authenticate, upload.single('track'), async (req, r
|
||||||
return res.status(400).json({ error: 'Файл не загружен' });
|
return res.status(400).json({ error: 'Файл не загружен' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, artistName, albumTitle, trackNumber, year, genre } = req.body;
|
let { title, artistName, albumTitle, trackNumber, year, genre } = req.body;
|
||||||
|
|
||||||
if (!title || !artistName) {
|
// Извлечь метаданные из файла
|
||||||
// Удалить загруженный файл
|
const metadata = await extractAudioMetadata(req.file.path);
|
||||||
await fs.unlink(req.file.path).catch(() => {});
|
|
||||||
return res.status(400).json({ error: 'Название трека и исполнитель обязательны' });
|
// Использовать метаданные если поля не заполнены
|
||||||
}
|
title = title || metadata.title || path.basename(req.file.originalname, path.extname(req.file.originalname));
|
||||||
|
artistName = artistName || metadata.artist || 'Unknown Artist';
|
||||||
|
albumTitle = albumTitle || metadata.album;
|
||||||
|
trackNumber = trackNumber || metadata.trackNumber;
|
||||||
|
year = year || metadata.year;
|
||||||
|
genre = genre || metadata.genre;
|
||||||
|
|
||||||
// Найти или создать исполнителя
|
// Найти или создать исполнителя
|
||||||
const artist = await findOrCreateArtist(artistName, req.user._id);
|
const artist = await findOrCreateArtist(artistName, req.user._id);
|
||||||
|
|
@ -87,6 +136,7 @@ router.post('/upload-track', authenticate, upload.single('track'), async (req, r
|
||||||
album = await Album.create({
|
album = await Album.create({
|
||||||
title: albumTitle,
|
title: albumTitle,
|
||||||
artist: artist._id,
|
artist: artist._id,
|
||||||
|
coverImage: metadata.coverImage,
|
||||||
year: year ? parseInt(year) : null,
|
year: year ? parseInt(year) : null,
|
||||||
genre: genre || '',
|
genre: genre || '',
|
||||||
addedBy: req.user._id
|
addedBy: req.user._id
|
||||||
|
|
@ -102,10 +152,11 @@ router.post('/upload-track', authenticate, upload.single('track'), async (req, r
|
||||||
artist: artist._id,
|
artist: artist._id,
|
||||||
album: album ? album._id : null,
|
album: album ? album._id : null,
|
||||||
fileUrl,
|
fileUrl,
|
||||||
|
coverImage: metadata.coverImage || (album ? album.coverImage : null),
|
||||||
file: {
|
file: {
|
||||||
size: req.file.size,
|
size: req.file.size,
|
||||||
mimeType: req.file.mimetype,
|
mimeType: req.file.mimetype,
|
||||||
duration: 0 // TODO: извлечь из метаданных
|
duration: Math.round(metadata.duration)
|
||||||
},
|
},
|
||||||
trackNumber: trackNumber ? parseInt(trackNumber) : 0,
|
trackNumber: trackNumber ? parseInt(trackNumber) : 0,
|
||||||
year: year ? parseInt(year) : null,
|
year: year ? parseInt(year) : null,
|
||||||
|
|
@ -119,6 +170,7 @@ router.post('/upload-track', authenticate, upload.single('track'), async (req, r
|
||||||
|
|
||||||
if (album) {
|
if (album) {
|
||||||
album.stats.tracks += 1;
|
album.stats.tracks += 1;
|
||||||
|
album.stats.duration += Math.round(metadata.duration);
|
||||||
await album.save();
|
await album.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -141,24 +193,20 @@ router.post('/upload-track', authenticate, upload.single('track'), async (req, r
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Загрузка альбома из ZIP
|
// Загрузка альбома из ZIP с извлечением метаданных
|
||||||
router.post('/upload-album', authenticate, upload.single('album'), async (req, res) => {
|
router.post('/upload-album', authenticate, upload.single('album'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
return res.status(400).json({ error: 'ZIP файл не загружен' });
|
return res.status(400).json({ error: 'ZIP файл не загружен' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.file.mimetype !== 'application/zip') {
|
const ext = path.extname(req.file.originalname).toLowerCase();
|
||||||
|
if (ext !== '.zip' && req.file.mimetype !== 'application/zip' && req.file.mimetype !== 'application/x-zip-compressed') {
|
||||||
await fs.unlink(req.file.path).catch(() => {});
|
await fs.unlink(req.file.path).catch(() => {});
|
||||||
return res.status(400).json({ error: 'Требуется ZIP файл' });
|
return res.status(400).json({ error: 'Требуется ZIP файл' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { artistName, albumTitle, year, genre } = req.body;
|
let { artistName, albumTitle, year, genre } = req.body;
|
||||||
|
|
||||||
if (!artistName || !albumTitle) {
|
|
||||||
await fs.unlink(req.file.path).catch(() => {});
|
|
||||||
return res.status(400).json({ error: 'Исполнитель и название альбома обязательны' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Распаковать ZIP
|
// Распаковать ZIP
|
||||||
const zip = new AdmZip(req.file.path);
|
const zip = new AdmZip(req.file.path);
|
||||||
|
|
@ -176,6 +224,30 @@ router.post('/upload-album', authenticate, upload.single('album'), async (req, r
|
||||||
return res.status(400).json({ error: 'В архиве нет аудио файлов' });
|
return res.status(400).json({ error: 'В архиве нет аудио файлов' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const uploadDir = path.join(__dirname, '../uploads/music');
|
||||||
|
|
||||||
|
// Извлечь первый трек для получения метаданных альбома
|
||||||
|
const firstEntry = audioFiles[0];
|
||||||
|
const firstFileName = path.basename(firstEntry.entryName);
|
||||||
|
const firstTempName = `temp-${Date.now()}${path.extname(firstFileName)}`;
|
||||||
|
const firstTempPath = path.join(uploadDir, firstTempName);
|
||||||
|
|
||||||
|
zip.extractEntryTo(firstEntry, uploadDir, false, true, false, firstTempName);
|
||||||
|
|
||||||
|
// Извлечь метаданные из первого трека
|
||||||
|
const firstMetadata = await extractAudioMetadata(firstTempPath);
|
||||||
|
|
||||||
|
// Использовать метаданные для альбома если не указаны
|
||||||
|
artistName = artistName || firstMetadata.artist || 'Unknown Artist';
|
||||||
|
albumTitle = albumTitle || firstMetadata.album || path.basename(req.file.originalname, '.zip');
|
||||||
|
year = year || firstMetadata.year;
|
||||||
|
genre = genre || firstMetadata.genre;
|
||||||
|
|
||||||
|
const albumCoverImage = firstMetadata.coverImage;
|
||||||
|
|
||||||
|
// Удалить временный файл
|
||||||
|
await fs.unlink(firstTempPath).catch(() => {});
|
||||||
|
|
||||||
// Найти или создать исполнителя
|
// Найти или создать исполнителя
|
||||||
const artist = await findOrCreateArtist(artistName, req.user._id);
|
const artist = await findOrCreateArtist(artistName, req.user._id);
|
||||||
|
|
||||||
|
|
@ -183,6 +255,7 @@ router.post('/upload-album', authenticate, upload.single('album'), async (req, r
|
||||||
const album = await Album.create({
|
const album = await Album.create({
|
||||||
title: albumTitle,
|
title: albumTitle,
|
||||||
artist: artist._id,
|
artist: artist._id,
|
||||||
|
coverImage: albumCoverImage,
|
||||||
year: year ? parseInt(year) : null,
|
year: year ? parseInt(year) : null,
|
||||||
genre: genre || '',
|
genre: genre || '',
|
||||||
addedBy: req.user._id
|
addedBy: req.user._id
|
||||||
|
|
@ -190,7 +263,7 @@ router.post('/upload-album', authenticate, upload.single('album'), async (req, r
|
||||||
|
|
||||||
// Извлечь и сохранить треки
|
// Извлечь и сохранить треки
|
||||||
const tracks = [];
|
const tracks = [];
|
||||||
const uploadDir = path.join(__dirname, '../uploads/music');
|
let totalDuration = 0;
|
||||||
|
|
||||||
for (let i = 0; i < audioFiles.length; i++) {
|
for (let i = 0; i < audioFiles.length; i++) {
|
||||||
const entry = audioFiles[i];
|
const entry = audioFiles[i];
|
||||||
|
|
@ -203,27 +276,43 @@ router.post('/upload-album', authenticate, upload.single('album'), async (req, r
|
||||||
// Извлечь файл
|
// Извлечь файл
|
||||||
zip.extractEntryTo(entry, uploadDir, false, true, false, newFileName);
|
zip.extractEntryTo(entry, uploadDir, false, true, false, newFileName);
|
||||||
|
|
||||||
|
// Извлечь метаданные из трека
|
||||||
|
const trackMetadata = await extractAudioMetadata(filePath);
|
||||||
|
|
||||||
// Получить размер файла
|
// Получить размер файла
|
||||||
const stats = await fs.stat(filePath);
|
const stats = await fs.stat(filePath);
|
||||||
|
|
||||||
|
// Определить название трека
|
||||||
|
const trackTitle = trackMetadata.title || fileName.replace(ext, '');
|
||||||
|
const trackArtist = trackMetadata.artist || artistName;
|
||||||
|
|
||||||
|
// Если исполнитель трека отличается от исполнителя альбома, найти или создать
|
||||||
|
let trackArtistId = artist._id;
|
||||||
|
if (trackArtist !== artistName) {
|
||||||
|
const trackArtistObj = await findOrCreateArtist(trackArtist, req.user._id);
|
||||||
|
trackArtistId = trackArtistObj._id;
|
||||||
|
}
|
||||||
|
|
||||||
// Создать трек
|
// Создать трек
|
||||||
const track = await Track.create({
|
const track = await Track.create({
|
||||||
title: fileName.replace(ext, ''),
|
title: trackTitle,
|
||||||
artist: artist._id,
|
artist: trackArtistId,
|
||||||
album: album._id,
|
album: album._id,
|
||||||
fileUrl: `/uploads/music/${newFileName}`,
|
fileUrl: `/uploads/music/${newFileName}`,
|
||||||
|
coverImage: trackMetadata.coverImage || albumCoverImage,
|
||||||
file: {
|
file: {
|
||||||
size: stats.size,
|
size: stats.size,
|
||||||
mimeType: 'audio/mpeg', // TODO: определить правильный MIME type
|
mimeType: req.file.mimetype,
|
||||||
duration: 0
|
duration: Math.round(trackMetadata.duration)
|
||||||
},
|
},
|
||||||
trackNumber: i + 1,
|
trackNumber: trackMetadata.trackNumber || (i + 1),
|
||||||
year: year ? parseInt(year) : null,
|
year: trackMetadata.year || year ? parseInt(year) : null,
|
||||||
genre: genre || '',
|
genre: trackMetadata.genre || genre || '',
|
||||||
addedBy: req.user._id
|
addedBy: req.user._id
|
||||||
});
|
});
|
||||||
|
|
||||||
tracks.push(track);
|
tracks.push(track);
|
||||||
|
totalDuration += Math.round(trackMetadata.duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Удалить ZIP после обработки
|
// Удалить ZIP после обработки
|
||||||
|
|
@ -235,6 +324,7 @@ router.post('/upload-album', authenticate, upload.single('album'), async (req, r
|
||||||
await artist.save();
|
await artist.save();
|
||||||
|
|
||||||
album.stats.tracks = tracks.length;
|
album.stats.tracks = tracks.length;
|
||||||
|
album.stats.duration = totalDuration;
|
||||||
await album.save();
|
await album.save();
|
||||||
|
|
||||||
// Populate для ответа
|
// Populate для ответа
|
||||||
|
|
@ -244,7 +334,7 @@ router.post('/upload-album', authenticate, upload.single('album'), async (req, r
|
||||||
success: true,
|
success: true,
|
||||||
album,
|
album,
|
||||||
tracks,
|
tracks,
|
||||||
message: `Загружено ${tracks.length} треков`
|
message: `Загружено ${tracks.length} треков из альбома "${albumTitle}"`
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка загрузки альбома:', error);
|
console.error('Ошибка загрузки альбома:', error);
|
||||||
|
|
@ -253,7 +343,7 @@ router.post('/upload-album', authenticate, upload.single('album'), async (req, r
|
||||||
await fs.unlink(req.file.path).catch(() => {});
|
await fs.unlink(req.file.path).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(500).json({ error: 'Ошибка загрузки альбома' });
|
res.status(500).json({ error: 'Ошибка загрузки альбома: ' + error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -398,6 +398,11 @@
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Прикрепленный трек */
|
||||||
|
.attached-track-preview {
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.user-result {
|
.user-result {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { X, Image as ImageIcon, Tag, AtSign, Info } from 'lucide-react'
|
import { X, Image as ImageIcon, Tag, AtSign, Info, Music } from 'lucide-react'
|
||||||
import { createPost, searchUsers, autocompleteTags, suggestTag } from '../utils/api'
|
import { createPost, searchUsers, autocompleteTags, suggestTag } from '../utils/api'
|
||||||
import { hapticFeedback } from '../utils/telegram'
|
import { hapticFeedback } from '../utils/telegram'
|
||||||
|
import MusicAttachment from './MusicAttachment'
|
||||||
|
import MusicPickerModal from './MusicPickerModal'
|
||||||
import './CreatePostModal.css'
|
import './CreatePostModal.css'
|
||||||
|
|
||||||
const TAG_CATEGORIES = {
|
const TAG_CATEGORIES = {
|
||||||
|
|
@ -28,6 +30,8 @@ export default function CreatePostModal({ user, onClose, onPostCreated, initialI
|
||||||
const [userSearchQuery, setUserSearchQuery] = useState('')
|
const [userSearchQuery, setUserSearchQuery] = useState('')
|
||||||
const [searchResults, setSearchResults] = useState([])
|
const [searchResults, setSearchResults] = useState([])
|
||||||
const [mentionedUsers, setMentionedUsers] = useState([])
|
const [mentionedUsers, setMentionedUsers] = useState([])
|
||||||
|
const [attachedTrack, setAttachedTrack] = useState(null)
|
||||||
|
const [showMusicPicker, setShowMusicPicker] = useState(false)
|
||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
const tagInputRef = useRef(null)
|
const tagInputRef = useRef(null)
|
||||||
const tagSuggestionsRef = useRef(null)
|
const tagSuggestionsRef = useRef(null)
|
||||||
|
|
@ -287,6 +291,11 @@ export default function CreatePostModal({ user, onClose, onPostCreated, initialI
|
||||||
formData.append('isNSFW', isNSFW)
|
formData.append('isNSFW', isNSFW)
|
||||||
formData.append('isHomo', isHomo)
|
formData.append('isHomo', isHomo)
|
||||||
|
|
||||||
|
// Прикрепленный трек
|
||||||
|
if (attachedTrack) {
|
||||||
|
formData.append('attachedTrackId', attachedTrack._id)
|
||||||
|
}
|
||||||
|
|
||||||
// Отправляем все файлы, которые являются File объектами
|
// Отправляем все файлы, которые являются File объектами
|
||||||
const fileImages = images.filter(img => img instanceof File)
|
const fileImages = images.filter(img => img instanceof File)
|
||||||
|
|
||||||
|
|
@ -507,8 +516,23 @@ export default function CreatePostModal({ user, onClose, onPostCreated, initialI
|
||||||
<button className="action-icon-btn" onClick={() => setShowUserSearch(true)}>
|
<button className="action-icon-btn" onClick={() => setShowUserSearch(true)}>
|
||||||
<AtSign size={22} />
|
<AtSign size={22} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button className="action-icon-btn" onClick={() => setShowMusicPicker(true)}>
|
||||||
|
<Music size={22} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Прикрепленный трек */}
|
||||||
|
{attachedTrack && (
|
||||||
|
<div className="attached-track-preview">
|
||||||
|
<MusicAttachment
|
||||||
|
track={attachedTrack}
|
||||||
|
showRemove={true}
|
||||||
|
onRemove={() => setAttachedTrack(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Поиск пользователей */}
|
{/* Поиск пользователей */}
|
||||||
{showUserSearch && (
|
{showUserSearch && (
|
||||||
<div className="user-search-modal">
|
<div className="user-search-modal">
|
||||||
|
|
@ -537,6 +561,14 @@ export default function CreatePostModal({ user, onClose, onPostCreated, initialI
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Модал выбора музыки */}
|
||||||
|
{showMusicPicker && (
|
||||||
|
<MusicPickerModal
|
||||||
|
onClose={() => setShowMusicPicker(false)}
|
||||||
|
onSelect={(track) => setAttachedTrack(track)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
|
|
|
||||||
|
|
@ -285,6 +285,39 @@
|
||||||
text-align: right;
|
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) {
|
@media (max-width: 400px) {
|
||||||
.full-player-cover {
|
.full-player-cover {
|
||||||
|
|
|
||||||
|
|
@ -102,3 +102,18 @@
|
||||||
transform: scale(0.9);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,3 +91,23 @@
|
||||||
color: #e74c3c;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { X, Search, Music, Heart } from 'lucide-react'
|
||||||
|
import { searchMusic, getFavorites } from '../utils/musicApi'
|
||||||
|
import { hapticFeedback } from '../utils/telegram'
|
||||||
|
import './MusicPickerModal.css'
|
||||||
|
|
||||||
|
export default function MusicPickerModal({ onClose, onSelect }) {
|
||||||
|
const [activeTab, setActiveTab] = useState('favorites')
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [searchResults, setSearchResults] = useState([])
|
||||||
|
const [favorites, setFavorites] = useState([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'favorites') {
|
||||||
|
loadFavorites()
|
||||||
|
}
|
||||||
|
}, [activeTab])
|
||||||
|
|
||||||
|
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(), 'tracks')
|
||||||
|
setSearchResults(results.tracks || [])
|
||||||
|
hapticFeedback('success')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка поиска:', error)
|
||||||
|
hapticFeedback('error')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectTrack = (track) => {
|
||||||
|
hapticFeedback('light')
|
||||||
|
onSelect(track)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderTrack = (track) => (
|
||||||
|
<div
|
||||||
|
key={track._id}
|
||||||
|
className="music-picker-track"
|
||||||
|
onClick={() => handleSelectTrack(track)}
|
||||||
|
>
|
||||||
|
<div className="track-cover-small">
|
||||||
|
{track.coverImage ? (
|
||||||
|
<img src={track.coverImage} alt={track.title} />
|
||||||
|
) : (
|
||||||
|
<Music size={20} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="track-info-small">
|
||||||
|
<div className="track-title-small">{track.title}</div>
|
||||||
|
<div className="track-artist-small">{track.artist?.name || 'Unknown'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="music-picker-overlay" onClick={onClose}>
|
||||||
|
<div className="music-picker-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="music-picker-header">
|
||||||
|
<h2>Выбрать трек</h2>
|
||||||
|
<button className="close-btn" onClick={onClose}>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="music-picker-tabs">
|
||||||
|
<button
|
||||||
|
className={`picker-tab ${activeTab === 'favorites' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('favorites')}
|
||||||
|
>
|
||||||
|
<Heart size={18} />
|
||||||
|
Избранное
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`picker-tab ${activeTab === 'search' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('search')}
|
||||||
|
>
|
||||||
|
<Search size={18} />
|
||||||
|
Поиск
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="music-picker-content">
|
||||||
|
{activeTab === 'search' && (
|
||||||
|
<div className="search-section">
|
||||||
|
<div className="search-input-wrapper">
|
||||||
|
<Search size={18} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Поиск треков..."
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
/>
|
||||||
|
<button onClick={handleSearch} disabled={!query.trim() || loading}>
|
||||||
|
Найти
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="picker-loading">
|
||||||
|
<div className="spinner" />
|
||||||
|
<p>Поиск...</p>
|
||||||
|
</div>
|
||||||
|
) : searchResults.length === 0 && query ? (
|
||||||
|
<div className="picker-empty">
|
||||||
|
<p>Ничего не найдено</p>
|
||||||
|
</div>
|
||||||
|
) : searchResults.length > 0 ? (
|
||||||
|
<div className="tracks-list-picker">
|
||||||
|
{searchResults.map(renderTrack)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="picker-empty">
|
||||||
|
<Music size={48} color="var(--text-secondary)" />
|
||||||
|
<p>Введите запрос для поиска</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'favorites' && (
|
||||||
|
loading ? (
|
||||||
|
<div className="picker-loading">
|
||||||
|
<div className="spinner" />
|
||||||
|
<p>Загрузка...</p>
|
||||||
|
</div>
|
||||||
|
) : favorites.length === 0 ? (
|
||||||
|
<div className="picker-empty">
|
||||||
|
<Heart size={48} color="var(--text-secondary)" />
|
||||||
|
<p>Нет избранных треков</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="tracks-list-picker">
|
||||||
|
{favorites.map(renderTrack)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -68,6 +68,10 @@
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post-music {
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.post-images {
|
.post-images {
|
||||||
margin: 0 -16px 12px;
|
margin: 0 -16px 12px;
|
||||||
width: calc(100% + 32px);
|
width: calc(100% + 32px);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { hapticFeedback, showConfirm, openTelegramLink } from '../utils/telegram
|
||||||
import { decodeHtmlEntities } from '../utils/htmlEntities'
|
import { decodeHtmlEntities } from '../utils/htmlEntities'
|
||||||
import CommentsModal from './CommentsModal'
|
import CommentsModal from './CommentsModal'
|
||||||
import PostMenu from './PostMenu'
|
import PostMenu from './PostMenu'
|
||||||
|
import MusicAttachment from './MusicAttachment'
|
||||||
import './PostCard.css'
|
import './PostCard.css'
|
||||||
|
|
||||||
const TAG_COLORS = {
|
const TAG_COLORS = {
|
||||||
|
|
@ -194,6 +195,13 @@ export default function PostCard({ post, currentUser, onUpdate }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Прикрепленная музыка */}
|
||||||
|
{post.attachedTrack && (
|
||||||
|
<div className="post-music">
|
||||||
|
<MusicAttachment track={post.attachedTrack} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Изображения */}
|
{/* Изображения */}
|
||||||
{images.length > 0 && (
|
{images.length > 0 && (
|
||||||
<div className="post-images">
|
<div className="post-images">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
box-shadow: 0 2px 8px var(--shadow-sm);
|
box-shadow: 0 2px 8px var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -22,11 +22,13 @@
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-grid {
|
.media-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 24px 16px;
|
padding: 24px 16px;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
|
|
@ -117,23 +119,15 @@
|
||||||
opacity: 0.2;
|
opacity: 0.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Адаптив для маленьких экранов */
|
/* Всегда 2 в ряд */
|
||||||
@media (max-width: 400px) {
|
|
||||||
.media-grid {
|
/* Темная тема */
|
||||||
grid-template-columns: 1fr;
|
[data-theme="dark"] .media-card {
|
||||||
max-width: 200px;
|
background: var(--bg-secondary);
|
||||||
margin: 0 auto;
|
border: 1px solid var(--border-color);
|
||||||
}
|
|
||||||
|
|
||||||
.media-card {
|
|
||||||
max-width: 200px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Адаптив для больших экранов */
|
[data-theme="dark"] .media-card:hover {
|
||||||
@media (min-width: 600px) {
|
border-color: var(--category-color);
|
||||||
.media-grid {
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,18 @@
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Image, Music, Palette } from 'lucide-react'
|
import { Music, User } from 'lucide-react'
|
||||||
import { hapticFeedback } from '../utils/telegram'
|
import { hapticFeedback } from '../utils/telegram'
|
||||||
import './Media.css'
|
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 }) {
|
export default function Media({ user }) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
|
@ -10,14 +20,14 @@ export default function Media({ user }) {
|
||||||
{
|
{
|
||||||
id: 'furry',
|
id: 'furry',
|
||||||
name: 'Furry',
|
name: 'Furry',
|
||||||
icon: Image,
|
icon: FoxIcon,
|
||||||
color: 'var(--tag-furry)',
|
color: 'var(--tag-furry)',
|
||||||
path: '/media/furry'
|
path: '/media/furry'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'anime',
|
id: 'anime',
|
||||||
name: 'Anime',
|
name: 'Anime',
|
||||||
icon: Palette,
|
icon: User,
|
||||||
color: 'var(--tag-anime)',
|
color: 'var(--tag-anime)',
|
||||||
path: '/media/anime'
|
path: '/media/anime'
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -294,7 +294,7 @@ export default function MediaAnime({ user }) {
|
||||||
<button className="back-btn" onClick={() => navigate('/media')}>
|
<button className="back-btn" onClick={() => navigate('/media')}>
|
||||||
<ArrowLeft size={24} />
|
<ArrowLeft size={24} />
|
||||||
</button>
|
</button>
|
||||||
<h1 style={{ color: 'var(--tag-anime)' }}>Anime</h1>
|
<h1>Anime</h1>
|
||||||
{results.length > 0 && (
|
{results.length > 0 && (
|
||||||
<button
|
<button
|
||||||
className={`selection-toggle ${selectionMode ? 'active' : ''}`}
|
className={`selection-toggle ${selectionMode ? 'active' : ''}`}
|
||||||
|
|
|
||||||
|
|
@ -294,7 +294,7 @@ export default function MediaFurry({ user }) {
|
||||||
<button className="back-btn" onClick={() => navigate('/media')}>
|
<button className="back-btn" onClick={() => navigate('/media')}>
|
||||||
<ArrowLeft size={24} />
|
<ArrowLeft size={24} />
|
||||||
</button>
|
</button>
|
||||||
<h1 style={{ color: 'var(--tag-furry)' }}>Furry</h1>
|
<h1>Furry</h1>
|
||||||
{results.length > 0 && (
|
{results.length > 0 && (
|
||||||
<button
|
<button
|
||||||
className={`selection-toggle ${selectionMode ? 'active' : ''}`}
|
className={`selection-toggle ${selectionMode ? 'active' : ''}`}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,8 @@
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: center;
|
text-align: left;
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-music-header .back-btn {
|
.media-music-header .back-btn {
|
||||||
|
|
@ -75,6 +76,18 @@
|
||||||
transform: scale(0.95);
|
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 {
|
.music-search {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
|
@ -371,3 +384,42 @@
|
||||||
padding: 16px;
|
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,24 +1,26 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { ArrowLeft, Search as SearchIcon, Music, Play, Heart, Download, X } from 'lucide-react'
|
import { ArrowLeft, Search as SearchIcon, Music, Play, Heart, Download, X, Upload } from 'lucide-react'
|
||||||
import { searchMusic, getTracks, addToFavorites, removeFromFavorites, getFavorites } from '../utils/musicApi'
|
import { searchMusic, getTracks, addToFavorites, removeFromFavorites, getFavorites } from '../utils/musicApi'
|
||||||
import { hapticFeedback, getTelegramUser } from '../utils/telegram'
|
import { hapticFeedback, getTelegramUser } from '../utils/telegram'
|
||||||
import { useMusicPlayer } from '../contexts/MusicPlayerContext'
|
import { useMusicPlayer } from '../contexts/MusicPlayerContext'
|
||||||
|
import UploadTrackModal from '../components/UploadTrackModal'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import './MediaMusic.css'
|
import './MediaMusic.css'
|
||||||
|
|
||||||
export default function MediaMusic({ user }) {
|
export default function MediaMusic({ user }) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { play } = useMusicPlayer()
|
const { play } = useMusicPlayer()
|
||||||
const [activeTab, setActiveTab] = useState('search') // search, tracks, favorites
|
const [activeTab, setActiveTab] = useState('favorites') // favorites, browse
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [searchResults, setSearchResults] = useState({ tracks: [], artists: [], albums: [] })
|
const [searchResults, setSearchResults] = useState({ tracks: [], artists: [], albums: [] })
|
||||||
const [tracks, setTracks] = useState([])
|
const [tracks, setTracks] = useState([])
|
||||||
const [favorites, setFavorites] = useState([])
|
const [favorites, setFavorites] = useState([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [showUpload, setShowUpload] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'tracks') {
|
if (activeTab === 'browse') {
|
||||||
loadTracks()
|
loadTracks()
|
||||||
} else if (activeTab === 'favorites') {
|
} else if (activeTab === 'favorites') {
|
||||||
loadFavorites()
|
loadFavorites()
|
||||||
|
|
@ -167,34 +169,56 @@ export default function MediaMusic({ user }) {
|
||||||
<button className="back-btn" onClick={() => navigate('/media')}>
|
<button className="back-btn" onClick={() => navigate('/media')}>
|
||||||
<ArrowLeft size={24} />
|
<ArrowLeft size={24} />
|
||||||
</button>
|
</button>
|
||||||
<h1 style={{ color: '#9b59b6' }}>Music</h1>
|
<h1>Music</h1>
|
||||||
<div style={{ width: '40px' }} />
|
<div style={{ width: '40px' }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Табы */}
|
{/* Табы */}
|
||||||
<div className="music-tabs">
|
<div className="music-tabs">
|
||||||
<button
|
|
||||||
className={`music-tab ${activeTab === 'search' ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveTab('search')}
|
|
||||||
>
|
|
||||||
Поиск
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`music-tab ${activeTab === 'tracks' ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveTab('tracks')}
|
|
||||||
>
|
|
||||||
Все треки
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
className={`music-tab ${activeTab === 'favorites' ? 'active' : ''}`}
|
className={`music-tab ${activeTab === 'favorites' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('favorites')}
|
onClick={() => setActiveTab('favorites')}
|
||||||
>
|
>
|
||||||
Избранное
|
Избранное
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Поиск */}
|
{/* Избранное */}
|
||||||
{activeTab === 'search' && (
|
{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="music-search">
|
||||||
<div className="search-input-wrapper">
|
<div className="search-input-wrapper">
|
||||||
<SearchIcon size={20} className="search-icon" />
|
<SearchIcon size={20} className="search-icon" />
|
||||||
|
|
@ -297,48 +321,16 @@ export default function MediaMusic({ user }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Все треки */}
|
{/* Модал загрузки */}
|
||||||
{activeTab === 'tracks' && (
|
{showUpload && (
|
||||||
<div className="music-tracks">
|
<UploadTrackModal
|
||||||
{loading ? (
|
user={user}
|
||||||
<div className="loading-state">
|
onClose={() => setShowUpload(false)}
|
||||||
<div className="spinner" />
|
onUploaded={() => {
|
||||||
<p>Загрузка...</p>
|
setShowUpload(false)
|
||||||
</div>
|
loadTracks()
|
||||||
) : tracks.length === 0 ? (
|
}}
|
||||||
<div className="empty-state">
|
/>
|
||||||
<Music size={48} color="var(--text-secondary)" />
|
|
||||||
<p>Нет треков</p>
|
|
||||||
<span>Загрузите первый трек</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="tracks-list">
|
|
||||||
{tracks.map(track => renderTrackItem(track, tracks))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Избранное */}
|
|
||||||
{activeTab === 'favorites' && (
|
|
||||||
<div className="music-favorites">
|
|
||||||
{loading ? (
|
|
||||||
<div className="loading-state">
|
|
||||||
<div className="spinner" />
|
|
||||||
<p>Загрузка...</p>
|
|
||||||
</div>
|
|
||||||
) : favorites.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
<Heart size={48} color="var(--text-secondary)" />
|
|
||||||
<p>Нет избранных треков</p>
|
|
||||||
<span>Добавьте треки в избранное</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="tracks-list">
|
|
||||||
{favorites.map(track => renderTrackItem(track, favorites))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,8 @@
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: center;
|
text-align: left;
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-search-header .back-btn {
|
.media-search-header .back-btn {
|
||||||
|
|
@ -66,3 +67,27 @@
|
||||||
color: white;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,9 @@
|
||||||
"aws-sdk": "^2.1499.0",
|
"aws-sdk": "^2.1499.0",
|
||||||
"nodemailer": "^6.9.7",
|
"nodemailer": "^6.9.7",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cookie-parser": "^1.4.6"
|
"cookie-parser": "^1.4.6",
|
||||||
|
"adm-zip": "^0.5.10",
|
||||||
|
"music-metadata": "^8.1.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.1",
|
"nodemon": "^3.0.1",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue