Update files

This commit is contained in:
glpshchn 2025-12-15 11:04:16 +03:00
parent fe45fbb159
commit e160cf06d5
28 changed files with 2493 additions and 125 deletions

215
ALBUM_UPLOAD_GUIDE.md Normal file
View File

@ -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
```
---
**Удачной загрузки! 🚀**

174
CHANGES_SUMMARY.md Normal file
View File

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

285
DEV_SETUP.md Normal file
View File

@ -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
```
---
**Удачной разработки! 🚀**

View File

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

View File

@ -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 тегов)
- Возможность редактирования метаданных при загрузке
- Автоматическое создание исполнителей и альбомов
**Поиск:**

221
QUICK_DEV_START.md Normal file
View File

@ -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`
---
**Быстрого старта! 🚀**

83
UPDATE_SUMMARY.md Normal file
View File

@ -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! ✅**

View File

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

View File

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

View File

@ -398,6 +398,11 @@
overflow-y: auto;
}
/* Прикрепленный трек */
.attached-track-preview {
margin: 12px 0;
}
.user-result {
display: flex;
gap: 12px;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -68,6 +68,10 @@
word-wrap: break-word;
}
.post-music {
margin: 12px 0;
}
.post-images {
margin: 0 -16px 12px;
width: calc(100% + 32px);

View File

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

View File

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

View File

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

View File

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

View File

@ -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'
},

View File

@ -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' : ''}`}

View File

@ -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' : ''}`}

View File

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

View File

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

View File

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

View File

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