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 кода
|
||||
COPY backend ./backend
|
||||
|
||||
# Создание директории для uploads
|
||||
RUN mkdir -p backend/uploads/posts backend/uploads/mod-channel
|
||||
# Создание директорий для uploads
|
||||
RUN mkdir -p backend/uploads/posts backend/uploads/mod-channel backend/uploads/music
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@
|
|||
|
||||
## Установка зависимостей
|
||||
|
||||
Для работы музыкального модуля требуется установить дополнительный пакет:
|
||||
Для работы музыкального модуля требуется установить дополнительные пакеты:
|
||||
|
||||
```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
|
||||
|
||||
**Загрузка:**
|
||||
- Загрузка отдельных треков
|
||||
- Загрузка отдельных треков (MP3, WAV, OGG, M4A, FLAC)
|
||||
- Загрузка альбомов из 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. Установите дополнительные пакеты для музыкального модуля
|
||||
```cmd
|
||||
npm install adm-zip
|
||||
npm install adm-zip music-metadata
|
||||
```
|
||||
|
||||
### 4. Установите зависимости для frontend
|
||||
|
|
@ -574,11 +574,11 @@ REM Или измените порт в .env
|
|||
REM PORT=3001
|
||||
```
|
||||
|
||||
### Ошибка: "Cannot find module 'adm-zip'"
|
||||
### Ошибка: "Cannot find module 'adm-zip'" или "Cannot find module 'music-metadata'"
|
||||
|
||||
**Решение:**
|
||||
```cmd
|
||||
npm install adm-zip
|
||||
npm install adm-zip music-metadata
|
||||
```
|
||||
|
||||
### Frontend не подключается к Backend
|
||||
|
|
@ -634,7 +634,7 @@ npm cache clean --force
|
|||
- [ ] `.env` создан и настроен
|
||||
- [ ] `npm install` выполнен в корне проекта
|
||||
- [ ] `npm install` выполнен в `frontend\`
|
||||
- [ ] `npm install adm-zip` выполнен
|
||||
- [ ] `npm install adm-zip music-metadata` выполнен
|
||||
- [ ] `DISABLE_TELEGRAM_AUTH=true` в `.env` (для разработки)
|
||||
- [ ] Backend запущен (`npm run server`)
|
||||
- [ ] Frontend запущен (`cd frontend && npm run dev`)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ const multer = require('multer');
|
|||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const AdmZip = require('adm-zip');
|
||||
const mm = require('music-metadata');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
const Artist = require('../models/Artist');
|
||||
const Album = require('../models/Album');
|
||||
|
|
@ -31,11 +32,14 @@ const storage = multer.diskStorage({
|
|||
const upload = multer({
|
||||
storage,
|
||||
limits: {
|
||||
fileSize: 50 * 1024 * 1024 // 50MB для треков
|
||||
fileSize: 100 * 1024 * 1024 // 100MB для ZIP альбомов
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedMimeTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/mp4', 'application/zip'];
|
||||
if (allowedMimeTypes.includes(file.mimetype)) {
|
||||
const allowedMimeTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/mp4', 'audio/m4a', 'application/zip', 'application/x-zip-compressed'];
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const allowedExts = ['.mp3', '.wav', '.ogg', '.m4a', '.flac', '.zip'];
|
||||
|
||||
if (allowedMimeTypes.includes(file.mimetype) || allowedExts.includes(ext)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Неподдерживаемый формат файла'));
|
||||
|
|
@ -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) {
|
||||
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: 'Файл не загружен' });
|
||||
}
|
||||
|
||||
const { title, artistName, albumTitle, trackNumber, year, genre } = req.body;
|
||||
let { title, artistName, albumTitle, trackNumber, year, genre } = req.body;
|
||||
|
||||
if (!title || !artistName) {
|
||||
// Удалить загруженный файл
|
||||
await fs.unlink(req.file.path).catch(() => {});
|
||||
return res.status(400).json({ error: 'Название трека и исполнитель обязательны' });
|
||||
}
|
||||
// Извлечь метаданные из файла
|
||||
const metadata = await extractAudioMetadata(req.file.path);
|
||||
|
||||
// Использовать метаданные если поля не заполнены
|
||||
title = title || metadata.title || path.basename(req.file.originalname, path.extname(req.file.originalname));
|
||||
artistName = artistName || metadata.artist || 'Unknown Artist';
|
||||
albumTitle = albumTitle || metadata.album;
|
||||
trackNumber = trackNumber || metadata.trackNumber;
|
||||
year = year || metadata.year;
|
||||
genre = genre || metadata.genre;
|
||||
|
||||
// Найти или создать исполнителя
|
||||
const artist = await findOrCreateArtist(artistName, req.user._id);
|
||||
|
|
@ -87,6 +136,7 @@ router.post('/upload-track', authenticate, upload.single('track'), async (req, r
|
|||
album = await Album.create({
|
||||
title: albumTitle,
|
||||
artist: artist._id,
|
||||
coverImage: metadata.coverImage,
|
||||
year: year ? parseInt(year) : null,
|
||||
genre: genre || '',
|
||||
addedBy: req.user._id
|
||||
|
|
@ -102,10 +152,11 @@ router.post('/upload-track', authenticate, upload.single('track'), async (req, r
|
|||
artist: artist._id,
|
||||
album: album ? album._id : null,
|
||||
fileUrl,
|
||||
coverImage: metadata.coverImage || (album ? album.coverImage : null),
|
||||
file: {
|
||||
size: req.file.size,
|
||||
mimeType: req.file.mimetype,
|
||||
duration: 0 // TODO: извлечь из метаданных
|
||||
duration: Math.round(metadata.duration)
|
||||
},
|
||||
trackNumber: trackNumber ? parseInt(trackNumber) : 0,
|
||||
year: year ? parseInt(year) : null,
|
||||
|
|
@ -119,6 +170,7 @@ router.post('/upload-track', authenticate, upload.single('track'), async (req, r
|
|||
|
||||
if (album) {
|
||||
album.stats.tracks += 1;
|
||||
album.stats.duration += Math.round(metadata.duration);
|
||||
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) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
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(() => {});
|
||||
return res.status(400).json({ error: 'Требуется ZIP файл' });
|
||||
}
|
||||
|
||||
const { artistName, albumTitle, year, genre } = req.body;
|
||||
|
||||
if (!artistName || !albumTitle) {
|
||||
await fs.unlink(req.file.path).catch(() => {});
|
||||
return res.status(400).json({ error: 'Исполнитель и название альбома обязательны' });
|
||||
}
|
||||
let { artistName, albumTitle, year, genre } = req.body;
|
||||
|
||||
// Распаковать ZIP
|
||||
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: 'В архиве нет аудио файлов' });
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
|
|
@ -183,6 +255,7 @@ router.post('/upload-album', authenticate, upload.single('album'), async (req, r
|
|||
const album = await Album.create({
|
||||
title: albumTitle,
|
||||
artist: artist._id,
|
||||
coverImage: albumCoverImage,
|
||||
year: year ? parseInt(year) : null,
|
||||
genre: genre || '',
|
||||
addedBy: req.user._id
|
||||
|
|
@ -190,7 +263,7 @@ router.post('/upload-album', authenticate, upload.single('album'), async (req, r
|
|||
|
||||
// Извлечь и сохранить треки
|
||||
const tracks = [];
|
||||
const uploadDir = path.join(__dirname, '../uploads/music');
|
||||
let totalDuration = 0;
|
||||
|
||||
for (let i = 0; i < audioFiles.length; 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);
|
||||
|
||||
// Извлечь метаданные из трека
|
||||
const trackMetadata = await extractAudioMetadata(filePath);
|
||||
|
||||
// Получить размер файла
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
// Определить название трека
|
||||
const trackTitle = trackMetadata.title || fileName.replace(ext, '');
|
||||
const trackArtist = trackMetadata.artist || artistName;
|
||||
|
||||
// Если исполнитель трека отличается от исполнителя альбома, найти или создать
|
||||
let trackArtistId = artist._id;
|
||||
if (trackArtist !== artistName) {
|
||||
const trackArtistObj = await findOrCreateArtist(trackArtist, req.user._id);
|
||||
trackArtistId = trackArtistObj._id;
|
||||
}
|
||||
|
||||
// Создать трек
|
||||
const track = await Track.create({
|
||||
title: fileName.replace(ext, ''),
|
||||
artist: artist._id,
|
||||
title: trackTitle,
|
||||
artist: trackArtistId,
|
||||
album: album._id,
|
||||
fileUrl: `/uploads/music/${newFileName}`,
|
||||
coverImage: trackMetadata.coverImage || albumCoverImage,
|
||||
file: {
|
||||
size: stats.size,
|
||||
mimeType: 'audio/mpeg', // TODO: определить правильный MIME type
|
||||
duration: 0
|
||||
mimeType: req.file.mimetype,
|
||||
duration: Math.round(trackMetadata.duration)
|
||||
},
|
||||
trackNumber: i + 1,
|
||||
year: year ? parseInt(year) : null,
|
||||
genre: genre || '',
|
||||
trackNumber: trackMetadata.trackNumber || (i + 1),
|
||||
year: trackMetadata.year || year ? parseInt(year) : null,
|
||||
genre: trackMetadata.genre || genre || '',
|
||||
addedBy: req.user._id
|
||||
});
|
||||
|
||||
tracks.push(track);
|
||||
totalDuration += Math.round(trackMetadata.duration);
|
||||
}
|
||||
|
||||
// Удалить ZIP после обработки
|
||||
|
|
@ -235,6 +324,7 @@ router.post('/upload-album', authenticate, upload.single('album'), async (req, r
|
|||
await artist.save();
|
||||
|
||||
album.stats.tracks = tracks.length;
|
||||
album.stats.duration = totalDuration;
|
||||
await album.save();
|
||||
|
||||
// Populate для ответа
|
||||
|
|
@ -244,7 +334,7 @@ router.post('/upload-album', authenticate, upload.single('album'), async (req, r
|
|||
success: true,
|
||||
album,
|
||||
tracks,
|
||||
message: `Загружено ${tracks.length} треков`
|
||||
message: `Загружено ${tracks.length} треков из альбома "${albumTitle}"`
|
||||
});
|
||||
} catch (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(() => {});
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Ошибка загрузки альбома' });
|
||||
res.status(500).json({ error: 'Ошибка загрузки альбома: ' + error.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -398,6 +398,11 @@
|
|||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Прикрепленный трек */
|
||||
.attached-track-preview {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.user-result {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { useState, useRef, useEffect } from 'react'
|
||||
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 { hapticFeedback } from '../utils/telegram'
|
||||
import MusicAttachment from './MusicAttachment'
|
||||
import MusicPickerModal from './MusicPickerModal'
|
||||
import './CreatePostModal.css'
|
||||
|
||||
const TAG_CATEGORIES = {
|
||||
|
|
@ -28,6 +30,8 @@ export default function CreatePostModal({ user, onClose, onPostCreated, initialI
|
|||
const [userSearchQuery, setUserSearchQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState([])
|
||||
const [mentionedUsers, setMentionedUsers] = useState([])
|
||||
const [attachedTrack, setAttachedTrack] = useState(null)
|
||||
const [showMusicPicker, setShowMusicPicker] = useState(false)
|
||||
const fileInputRef = useRef(null)
|
||||
const tagInputRef = useRef(null)
|
||||
const tagSuggestionsRef = useRef(null)
|
||||
|
|
@ -287,6 +291,11 @@ export default function CreatePostModal({ user, onClose, onPostCreated, initialI
|
|||
formData.append('isNSFW', isNSFW)
|
||||
formData.append('isHomo', isHomo)
|
||||
|
||||
// Прикрепленный трек
|
||||
if (attachedTrack) {
|
||||
formData.append('attachedTrackId', attachedTrack._id)
|
||||
}
|
||||
|
||||
// Отправляем все файлы, которые являются 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)}>
|
||||
<AtSign size={22} />
|
||||
</button>
|
||||
|
||||
<button className="action-icon-btn" onClick={() => setShowMusicPicker(true)}>
|
||||
<Music size={22} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Прикрепленный трек */}
|
||||
{attachedTrack && (
|
||||
<div className="attached-track-preview">
|
||||
<MusicAttachment
|
||||
track={attachedTrack}
|
||||
showRemove={true}
|
||||
onRemove={() => setAttachedTrack(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Поиск пользователей */}
|
||||
{showUserSearch && (
|
||||
<div className="user-search-modal">
|
||||
|
|
@ -537,6 +561,14 @@ export default function CreatePostModal({ user, onClose, onPostCreated, initialI
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Модал выбора музыки */}
|
||||
{showMusicPicker && (
|
||||
<MusicPickerModal
|
||||
onClose={() => setShowMusicPicker(false)}
|
||||
onSelect={(track) => setAttachedTrack(track)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
|
|
|
|||
|
|
@ -285,6 +285,39 @@
|
|||
text-align: right;
|
||||
}
|
||||
|
||||
/* Адаптив */
|
||||
/* Темная тема */
|
||||
[data-theme="dark"] .full-player {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .full-player-header {
|
||||
background: var(--bg-secondary);
|
||||
border-bottom-color: var(--divider-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .full-player-cover {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .full-player-control-btn:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .full-player-action-btn:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .full-player-volume {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .full-player-progress-bar {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
/* Адаптив */
|
||||
@media (max-width: 400px) {
|
||||
.full-player-cover {
|
||||
|
|
|
|||
|
|
@ -102,3 +102,18 @@
|
|||
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;
|
||||
}
|
||||
|
||||
/* Темная тема */
|
||||
[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;
|
||||
}
|
||||
|
||||
.post-music {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.post-images {
|
||||
margin: 0 -16px 12px;
|
||||
width: calc(100% + 32px);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { hapticFeedback, showConfirm, openTelegramLink } from '../utils/telegram
|
|||
import { decodeHtmlEntities } from '../utils/htmlEntities'
|
||||
import CommentsModal from './CommentsModal'
|
||||
import PostMenu from './PostMenu'
|
||||
import MusicAttachment from './MusicAttachment'
|
||||
import './PostCard.css'
|
||||
|
||||
const TAG_COLORS = {
|
||||
|
|
@ -194,6 +195,13 @@ export default function PostCard({ post, currentUser, onUpdate }) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Прикрепленная музыка */}
|
||||
{post.attachedTrack && (
|
||||
<div className="post-music">
|
||||
<MusicAttachment track={post.attachedTrack} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Изображения */}
|
||||
{images.length > 0 && (
|
||||
<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;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
box-shadow: 0 2px 8px var(--shadow-sm);
|
||||
}
|
||||
|
||||
|
|
@ -22,11 +22,13 @@
|
|||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
padding: 24px 16px;
|
||||
max-width: 600px;
|
||||
|
|
@ -117,23 +119,15 @@
|
|||
opacity: 0.2;
|
||||
}
|
||||
|
||||
/* Адаптив для маленьких экранов */
|
||||
@media (max-width: 400px) {
|
||||
.media-grid {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.media-card {
|
||||
max-width: 200px;
|
||||
}
|
||||
/* Всегда 2 в ряд */
|
||||
|
||||
/* Темная тема */
|
||||
[data-theme="dark"] .media-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Адаптив для больших экранов */
|
||||
@media (min-width: 600px) {
|
||||
.media-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
[data-theme="dark"] .media-card:hover {
|
||||
border-color: var(--category-color);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,18 @@
|
|||
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 './Media.css'
|
||||
|
||||
// Иконка лисы (SVG)
|
||||
const FoxIcon = ({ size = 48 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 3 L8 8 L4 6 L6 11 L2 13 L5 15 L3 19 C3 19 7 21 12 21 C17 21 21 19 21 19 L19 15 L22 13 L18 11 L20 6 L16 8 Z" />
|
||||
<circle cx="9" cy="13" r="1" fill="currentColor" />
|
||||
<circle cx="15" cy="13" r="1" fill="currentColor" />
|
||||
<path d="M9 16 C10 17 11 17.5 12 17.5 C13 17.5 14 17 15 16" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default function Media({ user }) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
|
|
@ -10,14 +20,14 @@ export default function Media({ user }) {
|
|||
{
|
||||
id: 'furry',
|
||||
name: 'Furry',
|
||||
icon: Image,
|
||||
icon: FoxIcon,
|
||||
color: 'var(--tag-furry)',
|
||||
path: '/media/furry'
|
||||
},
|
||||
{
|
||||
id: 'anime',
|
||||
name: 'Anime',
|
||||
icon: Palette,
|
||||
icon: User,
|
||||
color: 'var(--tag-anime)',
|
||||
path: '/media/anime'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -294,7 +294,7 @@ export default function MediaAnime({ user }) {
|
|||
<button className="back-btn" onClick={() => navigate('/media')}>
|
||||
<ArrowLeft size={24} />
|
||||
</button>
|
||||
<h1 style={{ color: 'var(--tag-anime)' }}>Anime</h1>
|
||||
<h1>Anime</h1>
|
||||
{results.length > 0 && (
|
||||
<button
|
||||
className={`selection-toggle ${selectionMode ? 'active' : ''}`}
|
||||
|
|
|
|||
|
|
@ -294,7 +294,7 @@ export default function MediaFurry({ user }) {
|
|||
<button className="back-btn" onClick={() => navigate('/media')}>
|
||||
<ArrowLeft size={24} />
|
||||
</button>
|
||||
<h1 style={{ color: 'var(--tag-furry)' }}>Furry</h1>
|
||||
<h1>Furry</h1>
|
||||
{results.length > 0 && (
|
||||
<button
|
||||
className={`selection-toggle ${selectionMode ? 'active' : ''}`}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@
|
|||
font-weight: 600;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
text-align: left;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.media-music-header .back-btn {
|
||||
|
|
@ -75,6 +76,18 @@
|
|||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.music-tab.upload-tab {
|
||||
flex: 0;
|
||||
padding: 12px 20px;
|
||||
color: #9b59b6;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.music-tab.upload-tab:hover {
|
||||
background: var(--bg-primary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Поиск */
|
||||
.music-search {
|
||||
padding: 16px;
|
||||
|
|
@ -371,3 +384,42 @@
|
|||
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 { 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 { hapticFeedback, getTelegramUser } from '../utils/telegram'
|
||||
import { useMusicPlayer } from '../contexts/MusicPlayerContext'
|
||||
import UploadTrackModal from '../components/UploadTrackModal'
|
||||
import api from '../utils/api'
|
||||
import './MediaMusic.css'
|
||||
|
||||
export default function MediaMusic({ user }) {
|
||||
const navigate = useNavigate()
|
||||
const { play } = useMusicPlayer()
|
||||
const [activeTab, setActiveTab] = useState('search') // search, tracks, favorites
|
||||
const [activeTab, setActiveTab] = useState('favorites') // favorites, browse
|
||||
const [query, setQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState({ tracks: [], artists: [], albums: [] })
|
||||
const [tracks, setTracks] = useState([])
|
||||
const [favorites, setFavorites] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showUpload, setShowUpload] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'tracks') {
|
||||
if (activeTab === 'browse') {
|
||||
loadTracks()
|
||||
} else if (activeTab === 'favorites') {
|
||||
loadFavorites()
|
||||
|
|
@ -167,34 +169,56 @@ export default function MediaMusic({ user }) {
|
|||
<button className="back-btn" onClick={() => navigate('/media')}>
|
||||
<ArrowLeft size={24} />
|
||||
</button>
|
||||
<h1 style={{ color: '#9b59b6' }}>Music</h1>
|
||||
<h1>Music</h1>
|
||||
<div style={{ width: '40px' }} />
|
||||
</div>
|
||||
|
||||
{/* Табы */}
|
||||
<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
|
||||
className={`music-tab ${activeTab === 'favorites' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('favorites')}
|
||||
>
|
||||
Избранное
|
||||
</button>
|
||||
<button
|
||||
className={`music-tab ${activeTab === 'browse' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('browse')}
|
||||
>
|
||||
Обзор
|
||||
</button>
|
||||
<button
|
||||
className="music-tab upload-tab"
|
||||
onClick={() => setShowUpload(true)}
|
||||
>
|
||||
<Upload size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Поиск */}
|
||||
{activeTab === '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="search-input-wrapper">
|
||||
<SearchIcon size={20} className="search-icon" />
|
||||
|
|
@ -297,48 +321,16 @@ export default function MediaMusic({ user }) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Все треки */}
|
||||
{activeTab === 'tracks' && (
|
||||
<div className="music-tracks">
|
||||
{loading ? (
|
||||
<div className="loading-state">
|
||||
<div className="spinner" />
|
||||
<p>Загрузка...</p>
|
||||
</div>
|
||||
) : 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>
|
||||
{/* Модал загрузки */}
|
||||
{showUpload && (
|
||||
<UploadTrackModal
|
||||
user={user}
|
||||
onClose={() => setShowUpload(false)}
|
||||
onUploaded={() => {
|
||||
setShowUpload(false)
|
||||
loadTracks()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@
|
|||
font-weight: 600;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
text-align: left;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.media-search-header .back-btn {
|
||||
|
|
@ -66,3 +67,27 @@
|
|||
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",
|
||||
"nodemailer": "^6.9.7",
|
||||
"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": {
|
||||
"nodemon": "^3.0.1",
|
||||
|
|
|
|||
Loading…
Reference in New Issue