diff --git a/ALBUM_UPLOAD_GUIDE.md b/ALBUM_UPLOAD_GUIDE.md new file mode 100644 index 0000000..9e504d9 --- /dev/null +++ b/ALBUM_UPLOAD_GUIDE.md @@ -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 +``` + +--- + +**Удачной загрузки! 🚀** + diff --git a/CHANGES_SUMMARY.md b/CHANGES_SUMMARY.md new file mode 100644 index 0000000..70e854c --- /dev/null +++ b/CHANGES_SUMMARY.md @@ -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` + +--- + +**Все изменения готовы! 🎉** + diff --git a/DEV_SETUP.md b/DEV_SETUP.md new file mode 100644 index 0000000..e02a6a3 --- /dev/null +++ b/DEV_SETUP.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 +``` + +--- + +**Удачной разработки! 🚀** + diff --git a/Dockerfile.backend b/Dockerfile.backend index 88b533b..7b1ccac 100644 --- a/Dockerfile.backend +++ b/Dockerfile.backend @@ -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 diff --git a/MUSIC_SETUP.md b/MUSIC_SETUP.md index 39455e2..6f0c9a5 100644 --- a/MUSIC_SETUP.md +++ b/MUSIC_SETUP.md @@ -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 тегов) +- Возможность редактирования метаданных при загрузке - Автоматическое создание исполнителей и альбомов **Поиск:** diff --git a/QUICK_DEV_START.md b/QUICK_DEV_START.md new file mode 100644 index 0000000..2e2998b --- /dev/null +++ b/QUICK_DEV_START.md @@ -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 /F +``` + +### Dev user не создается + +Проверьте `.env`: +```cmd +type .env | findstr DISABLE_TELEGRAM_AUTH +``` + +Должно быть: `DISABLE_TELEGRAM_AUTH=true` + +--- + +**Быстрого старта! 🚀** + diff --git a/UPDATE_SUMMARY.md b/UPDATE_SUMMARY.md new file mode 100644 index 0000000..97e875d --- /dev/null +++ b/UPDATE_SUMMARY.md @@ -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! ✅** + diff --git a/WIND.md b/WIND.md index 574b26b..c384424 100644 --- a/WIND.md +++ b/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`) diff --git a/backend/routes/music.js b/backend/routes/music.js index 10502bd..4953c3c 100644 --- a/backend/routes/music.js +++ b/backend/routes/music.js @@ -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 }); } }); diff --git a/frontend/src/components/CreatePostModal.css b/frontend/src/components/CreatePostModal.css index 5bad7cb..309304e 100644 --- a/frontend/src/components/CreatePostModal.css +++ b/frontend/src/components/CreatePostModal.css @@ -398,6 +398,11 @@ overflow-y: auto; } +/* Прикрепленный трек */ +.attached-track-preview { + margin: 12px 0; +} + .user-result { display: flex; gap: 12px; diff --git a/frontend/src/components/CreatePostModal.jsx b/frontend/src/components/CreatePostModal.jsx index b543e7e..ba0fbec 100644 --- a/frontend/src/components/CreatePostModal.jsx +++ b/frontend/src/components/CreatePostModal.jsx @@ -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 + + + {/* Прикрепленный трек */} + {attachedTrack && ( +
+ setAttachedTrack(null)} + /> +
+ )} + {/* Поиск пользователей */} {showUserSearch && (
@@ -537,6 +561,14 @@ export default function CreatePostModal({ user, onClose, onPostCreated, initialI
)} + + {/* Модал выбора музыки */} + {showMusicPicker && ( + setShowMusicPicker(false)} + onSelect={(track) => setAttachedTrack(track)} + /> + )} , document.body diff --git a/frontend/src/components/FullPlayer.css b/frontend/src/components/FullPlayer.css index 355041a..7ab38dd 100644 --- a/frontend/src/components/FullPlayer.css +++ b/frontend/src/components/FullPlayer.css @@ -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 { diff --git a/frontend/src/components/MiniPlayer.css b/frontend/src/components/MiniPlayer.css index 09d9748..fbfb253 100644 --- a/frontend/src/components/MiniPlayer.css +++ b/frontend/src/components/MiniPlayer.css @@ -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); +} + diff --git a/frontend/src/components/MusicAttachment.css b/frontend/src/components/MusicAttachment.css index ca40a02..21e3770 100644 --- a/frontend/src/components/MusicAttachment.css +++ b/frontend/src/components/MusicAttachment.css @@ -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); +} + diff --git a/frontend/src/components/MusicPickerModal.css b/frontend/src/components/MusicPickerModal.css new file mode 100644 index 0000000..9a172d8 --- /dev/null +++ b/frontend/src/components/MusicPickerModal.css @@ -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); +} + diff --git a/frontend/src/components/MusicPickerModal.jsx b/frontend/src/components/MusicPickerModal.jsx new file mode 100644 index 0000000..cc5190c --- /dev/null +++ b/frontend/src/components/MusicPickerModal.jsx @@ -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) => ( +
handleSelectTrack(track)} + > +
+ {track.coverImage ? ( + {track.title} + ) : ( + + )} +
+
+
{track.title}
+
{track.artist?.name || 'Unknown'}
+
+
+ ) + + return createPortal( +
+
e.stopPropagation()}> +
+

Выбрать трек

+ +
+ +
+ + +
+ +
+ {activeTab === 'search' && ( +
+
+ + setQuery(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleSearch()} + /> + +
+ + {loading ? ( +
+
+

Поиск...

+
+ ) : searchResults.length === 0 && query ? ( +
+

Ничего не найдено

+
+ ) : searchResults.length > 0 ? ( +
+ {searchResults.map(renderTrack)} +
+ ) : ( +
+ +

Введите запрос для поиска

+
+ )} +
+ )} + + {activeTab === 'favorites' && ( + loading ? ( +
+
+

Загрузка...

+
+ ) : favorites.length === 0 ? ( +
+ +

Нет избранных треков

+
+ ) : ( +
+ {favorites.map(renderTrack)} +
+ ) + )} +
+
+
, + document.body + ) +} + diff --git a/frontend/src/components/PostCard.css b/frontend/src/components/PostCard.css index 5d47e80..96da3c2 100644 --- a/frontend/src/components/PostCard.css +++ b/frontend/src/components/PostCard.css @@ -68,6 +68,10 @@ word-wrap: break-word; } +.post-music { + margin: 12px 0; +} + .post-images { margin: 0 -16px 12px; width: calc(100% + 32px); diff --git a/frontend/src/components/PostCard.jsx b/frontend/src/components/PostCard.jsx index 59f5045..b9496bb 100644 --- a/frontend/src/components/PostCard.jsx +++ b/frontend/src/components/PostCard.jsx @@ -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 }) {
)} + {/* Прикрепленная музыка */} + {post.attachedTrack && ( +
+ +
+ )} + {/* Изображения */} {images.length > 0 && (
diff --git a/frontend/src/components/UploadTrackModal.css b/frontend/src/components/UploadTrackModal.css new file mode 100644 index 0000000..9b09cb8 --- /dev/null +++ b/frontend/src/components/UploadTrackModal.css @@ -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; +} + diff --git a/frontend/src/components/UploadTrackModal.jsx b/frontend/src/components/UploadTrackModal.jsx new file mode 100644 index 0000000..e4272fd --- /dev/null +++ b/frontend/src/components/UploadTrackModal.jsx @@ -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( +
+
e.stopPropagation()}> +
+

{uploadType === 'album' ? 'Загрузить альбом' : 'Загрузить трек'}

+ +
+ +
+ {/* Выбор файла */} +
+ + + {!file ? ( + + ) : ( +
+ {uploadType === 'album' ? : } +
+
{uploadType === 'album' ? 'Альбом (ZIP)' : 'Трек'}
+
{file.name}
+
{(file.size / 1024 / 1024).toFixed(2)} MB
+
+ +
+ )} +
+ + {/* Метаданные */} + {file && ( +
+ {extracting && ( +
+ + Извлечение метаданных... +
+ )} + + {uploadType === 'album' && ( +
+ + Метаданные (название, исполнитель, обложка) будут автоматически извлечены из аудио файлов в архиве +
+ )} + + {uploadType === 'track' && ( +
+ + setMetadata({ ...metadata, title: e.target.value })} + placeholder="My Awesome Track" + /> +
+ )} + +
+ + setMetadata({ ...metadata, artist: e.target.value })} + placeholder="Artist Name" + /> +
+ + {uploadType === 'album' && ( +
+ + setMetadata({ ...metadata, album: e.target.value })} + placeholder="Album Name" + /> +
+ )} + + {uploadType === 'track' && ( +
+ + setMetadata({ ...metadata, album: e.target.value })} + placeholder="Album Name (необязательно)" + /> +
+ )} + +
+
+ + setMetadata({ ...metadata, year: e.target.value })} + placeholder="2024" + min="1900" + max="2099" + /> +
+ + {uploadType === 'track' && ( +
+ + setMetadata({ ...metadata, trackNumber: e.target.value })} + placeholder="1" + min="1" + /> +
+ )} +
+ +
+ + setMetadata({ ...metadata, genre: e.target.value })} + placeholder="Electronic, Rock, Pop..." + /> +
+
+ )} +
+ +
+ + +
+
+
, + document.body + ) +} + diff --git a/frontend/src/pages/Media.css b/frontend/src/pages/Media.css index 9e69407..a378c47 100644 --- a/frontend/src/pages/Media.css +++ b/frontend/src/pages/Media.css @@ -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); } diff --git a/frontend/src/pages/Media.jsx b/frontend/src/pages/Media.jsx index 8d504fb..4b17142 100644 --- a/frontend/src/pages/Media.jsx +++ b/frontend/src/pages/Media.jsx @@ -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 }) => ( + + + + + + +) + 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' }, diff --git a/frontend/src/pages/MediaAnime.jsx b/frontend/src/pages/MediaAnime.jsx index f2e9876..0abc69f 100644 --- a/frontend/src/pages/MediaAnime.jsx +++ b/frontend/src/pages/MediaAnime.jsx @@ -294,7 +294,7 @@ export default function MediaAnime({ user }) { -

Anime

+

Anime

{results.length > 0 && ( -

Furry

+

Furry

{results.length > 0 && ( -

Music

+

Music

{/* Табы */}
- - + +
- {/* Поиск */} - {activeTab === 'search' && ( + {/* Избранное */} + {activeTab === 'favorites' && ( +
+ {loading ? ( +
+
+

Загрузка...

+
+ ) : favorites.length === 0 ? ( +
+ +

Нет избранных треков

+ Добавьте треки в избранное +
+ ) : ( +
+ {favorites.map(track => renderTrackItem(track, favorites))} +
+ )} +
+ )} + + {/* Обзор (поиск + все треки) */} + {activeTab === 'browse' && (
@@ -297,48 +321,16 @@ export default function MediaMusic({ user }) {
)} - {/* Все треки */} - {activeTab === 'tracks' && ( -
- {loading ? ( -
-
-

Загрузка...

-
- ) : tracks.length === 0 ? ( -
- -

Нет треков

- Загрузите первый трек -
- ) : ( -
- {tracks.map(track => renderTrackItem(track, tracks))} -
- )} -
- )} - - {/* Избранное */} - {activeTab === 'favorites' && ( -
- {loading ? ( -
-
-

Загрузка...

-
- ) : favorites.length === 0 ? ( -
- -

Нет избранных треков

- Добавьте треки в избранное -
- ) : ( -
- {favorites.map(track => renderTrackItem(track, favorites))} -
- )} -
+ {/* Модал загрузки */} + {showUpload && ( + setShowUpload(false)} + onUploaded={() => { + setShowUpload(false) + loadTracks() + }} + /> )}
) diff --git a/frontend/src/pages/MediaSearch.css b/frontend/src/pages/MediaSearch.css index 99b37a3..e02a21e 100644 --- a/frontend/src/pages/MediaSearch.css +++ b/frontend/src/pages/MediaSearch.css @@ -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; +} + diff --git a/package.json b/package.json index 3bbe606..12cc613 100644 --- a/package.json +++ b/package.json @@ -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",