Add multiple features: auth, uploads, queue management, and filters

- Replace email with username for authentication
  - Update User model, schemas, and auth endpoints
  - Update frontend login and register views
  - Add migration to remove email column

- Add multiple track upload support
  - New backend endpoint for bulk upload
  - Frontend multi-file selection with progress
  - Auto-extract metadata from ID3 tags
  - Visual upload progress for each file

- Prevent duplicate tracks in room queue
  - Backend validation for duplicates
  - Visual indication of tracks already in queue
  - Error handling with user feedback

- Add bulk track selection for rooms
  - Multi-select mode with checkboxes
  - Bulk add endpoint with duplicate filtering
  - Selection counter and controls

- Add track filters in room modal
  - Search by title and artist
  - Filter by "My tracks"
  - Filter by "Not in queue"
  - Live filtering with result counter

- Improve Makefile
  - Add build-backend and build-frontend commands
  - Add rebuild-backend and rebuild-frontend commands
  - Add rebuild-clean variants
  - Update migrations to run in Docker

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-19 19:22:35 +03:00
parent fdc854256c
commit 8a2ea5b4af
17 changed files with 848 additions and 143 deletions

View File

@@ -1,4 +1,4 @@
.PHONY: help dev dev-backend dev-frontend install install-backend install-frontend build up down logs migrate
.PHONY: help dev dev-backend dev-frontend install install-backend install-frontend build build-backend build-frontend rebuild rebuild-backend rebuild-frontend rebuild-clean rebuild-clean-backend rebuild-clean-frontend up down logs migrate
help:
@echo "EnigFM - Команды:"
@@ -8,7 +8,15 @@ help:
@echo " make dev-backend - Запустить только backend"
@echo " make dev-frontend - Запустить только frontend"
@echo ""
@echo " make build - Собрать Docker образы"
@echo " make build - Собрать Docker образы (backend + frontend)"
@echo " make build-backend - Собрать только backend"
@echo " make build-frontend - Собрать только frontend"
@echo " make rebuild - Пересобрать и запустить всё"
@echo " make rebuild-backend - Пересобрать и перезапустить только backend"
@echo " make rebuild-frontend - Пересобрать и перезапустить только frontend"
@echo " make rebuild-clean - Пересобрать всё без кеша"
@echo " make rebuild-clean-backend - Пересобрать backend без кеша"
@echo " make rebuild-clean-frontend - Пересобрать frontend без кеша"
@echo " make up - Запустить через Docker"
@echo " make down - Остановить Docker"
@echo " make logs - Показать логи Docker"
@@ -16,6 +24,7 @@ help:
@echo " make migrate - Создать миграцию БД"
@echo " make migrate-up - Применить миграции"
@echo " make migrate-down - Откатить миграцию"
@echo " make migrate-rebuild - Пересобрать контейнеры и применить миграции"
# Установка зависимостей
install: install-backend install-frontend
@@ -41,6 +50,12 @@ dev-frontend:
build:
docker-compose build
build-backend:
docker-compose build backend
build-frontend:
docker-compose build frontend
up:
docker-compose up -d
@@ -51,11 +66,35 @@ rebuild:
docker-compose down
docker-compose up -d --build
rebuild-backend:
docker-compose stop backend
docker-compose rm -f backend
docker-compose build backend
docker-compose up -d backend
rebuild-frontend:
docker-compose stop frontend
docker-compose rm -f frontend
docker-compose build frontend
docker-compose up -d frontend
rebuild-clean:
docker-compose down
docker-compose build --no-cache
docker-compose up -d
rebuild-clean-backend:
docker-compose stop backend
docker-compose rm -f backend
docker-compose build --no-cache backend
docker-compose up -d backend
rebuild-clean-frontend:
docker-compose stop frontend
docker-compose rm -f frontend
docker-compose build --no-cache frontend
docker-compose up -d frontend
logs:
docker-compose logs -f
@@ -67,13 +106,20 @@ logs-frontend:
# Миграции
migrate:
cd backend && alembic revision --autogenerate -m "$(msg)"
docker-compose exec backend alembic revision --autogenerate -m "$(msg)"
migrate-up:
cd backend && alembic upgrade head
docker-compose exec backend alembic upgrade head
migrate-down:
cd backend && alembic downgrade -1
docker-compose exec backend alembic downgrade -1
migrate-rebuild:
docker-compose down
docker-compose up -d --build
@echo "Waiting for containers to start..."
@sleep 5
docker-compose exec backend alembic upgrade head
# БД
db-shell:

View File

@@ -0,0 +1,30 @@
"""Remove email from users
Revision ID: 003
Revises: 002
Create Date: 2024-12-19
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '003'
down_revision: Union[str, None] = '002'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Drop unique constraint on email
op.drop_constraint('users_email_key', 'users', type_='unique')
# Drop email column
op.drop_column('users', 'email')
def downgrade() -> None:
# Add email column back
op.add_column('users', sa.Column('email', sa.String(255), nullable=True))
# Add unique constraint back
op.create_unique_constraint('users_email_key', 'users', ['email'])

View File

@@ -11,7 +11,6 @@ class User(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

View File

@@ -12,14 +12,6 @@ router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/register", response_model=Token)
async def register(user_data: UserCreate, db: AsyncSession = Depends(get_db)):
# Check if email exists
result = await db.execute(select(User).where(User.email == user_data.email))
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
# Check if username exists
result = await db.execute(select(User).where(User.username == user_data.username))
if result.scalar_one_or_none():
@@ -31,7 +23,6 @@ async def register(user_data: UserCreate, db: AsyncSession = Depends(get_db)):
# Create user
user = User(
username=user_data.username,
email=user_data.email,
password_hash=get_password_hash(user_data.password),
)
db.add(user)
@@ -44,13 +35,13 @@ async def register(user_data: UserCreate, db: AsyncSession = Depends(get_db)):
@router.post("/login", response_model=Token)
async def login(user_data: UserLogin, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.email == user_data.email))
result = await db.execute(select(User).where(User.username == user_data.username))
user = result.scalar_one_or_none()
if not user or not verify_password(user_data.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
detail="Invalid username or password",
)
access_token = create_access_token(data={"sub": str(user.id)})

View File

@@ -7,7 +7,7 @@ from ..database import get_db
from ..models.user import User
from ..models.room import Room, RoomParticipant
from ..models.track import RoomQueue
from ..schemas.room import RoomCreate, RoomResponse, RoomDetailResponse, QueueAdd
from ..schemas.room import RoomCreate, RoomResponse, RoomDetailResponse, QueueAdd, QueueAddMultiple
from ..schemas.track import TrackResponse
from ..schemas.user import UserResponse
from ..services.auth import get_current_user
@@ -197,6 +197,21 @@ async def add_to_queue(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Check if track already in queue
result = await db.execute(
select(RoomQueue).where(
RoomQueue.room_id == room_id,
RoomQueue.track_id == data.track_id,
)
)
existing_item = result.scalar_one_or_none()
if existing_item:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Track already in queue",
)
# Get max position
result = await db.execute(
select(func.max(RoomQueue.position)).where(RoomQueue.room_id == room_id)
@@ -221,6 +236,69 @@ async def add_to_queue(
return {"status": "added"}
@router.post("/{room_id}/queue/bulk")
async def add_multiple_to_queue(
room_id: UUID,
data: QueueAddMultiple,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Add multiple tracks to queue at once, skipping duplicates."""
if not data.track_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No tracks provided",
)
# Get existing tracks in queue
result = await db.execute(
select(RoomQueue.track_id).where(RoomQueue.room_id == room_id)
)
existing_track_ids = set(result.scalars().all())
# Filter out duplicates
new_track_ids = [tid for tid in data.track_ids if tid not in existing_track_ids]
if not new_track_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="All tracks already in queue",
)
# Get max position
result = await db.execute(
select(func.max(RoomQueue.position)).where(RoomQueue.room_id == room_id)
)
max_pos = result.scalar() or 0
# Add all new tracks
added_count = 0
for i, track_id in enumerate(new_track_ids):
queue_item = RoomQueue(
room_id=room_id,
track_id=track_id,
position=max_pos + i + 1,
added_by=current_user.id,
)
db.add(queue_item)
added_count += 1
await db.flush()
# Notify others
await manager.broadcast_to_room(
room_id,
{"type": "queue_updated"},
)
skipped_count = len(data.track_ids) - added_count
return {
"status": "added",
"added": added_count,
"skipped": skipped_count,
}
@router.delete("/{room_id}/queue/{track_id}")
async def remove_from_queue(
room_id: UUID,

View File

@@ -32,20 +32,17 @@ async def get_tracks(
return result.scalars().all()
@router.post("/upload", response_model=TrackResponse)
async def upload_track(
file: UploadFile = File(...),
title: str = Form(None),
artist: str = Form(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
async def _process_single_track(
file: UploadFile,
title: str,
artist: str,
current_user: User,
) -> tuple[Track, Exception | None]:
"""Process a single track upload. Returns (track, error)."""
try:
# Check file type
if not file.content_type or not file.content_type.startswith("audio/"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File must be an audio file",
)
return None, Exception("File must be an audio file")
# Read file content
content = await file.read()
@@ -54,17 +51,11 @@ async def upload_track(
# Check file size
max_size = settings.max_file_size_mb * 1024 * 1024
if file_size > max_size:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File size exceeds {settings.max_file_size_mb}MB limit",
)
return None, Exception(f"File size exceeds {settings.max_file_size_mb}MB limit")
# Check storage limit
if not await can_upload_file(file_size):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Storage limit exceeded",
)
return None, Exception("Storage limit exceeded")
# Get duration and metadata from MP3
try:
@@ -87,11 +78,8 @@ async def upload_track(
if not artist:
artist = "Unknown"
except Exception:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Could not read audio file",
)
except Exception as e:
return None, Exception("Could not read audio file")
# Upload to S3
s3_key = f"tracks/{uuid.uuid4()}.mp3"
@@ -106,12 +94,60 @@ async def upload_track(
file_size=file_size,
uploaded_by=current_user.id,
)
return track, None
except Exception as e:
return None, e
@router.post("/upload", response_model=TrackResponse)
async def upload_track(
file: UploadFile = File(...),
title: str = Form(None),
artist: str = Form(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
track, error = await _process_single_track(file, title, artist, current_user)
if error:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(error),
)
db.add(track)
await db.flush()
return track
@router.post("/upload-multiple", response_model=list[TrackResponse])
async def upload_multiple_tracks(
files: list[UploadFile] = File(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Upload multiple tracks at once. Each file's metadata is auto-detected from ID3 tags."""
if not files:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No files provided",
)
# Process all files
results = []
for file in files:
track, error = await _process_single_track(file, None, None, current_user)
if track:
db.add(track)
results.append(track)
# Commit all at once
await db.flush()
return results
@router.get("/{track_id}", response_model=TrackWithUrl)
async def get_track(track_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Track).where(Track.id == track_id))

View File

@@ -45,3 +45,7 @@ class PlayerAction(BaseModel):
class QueueAdd(BaseModel):
track_id: UUID
class QueueAddMultiple(BaseModel):
track_ids: list[UUID]

View File

@@ -1,23 +1,21 @@
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel
from uuid import UUID
from datetime import datetime
class UserCreate(BaseModel):
username: str
email: EmailStr
password: str
class UserLogin(BaseModel):
email: EmailStr
username: str
password: str
class UserResponse(BaseModel):
id: UUID
username: str
email: str
created_at: datetime
class Config:

View File

@@ -1,19 +1,27 @@
<template>
<div class="track-item" @click="selectable && $emit('select')">
<div class="track-item" :class="{ 'in-queue': inQueue, 'selected': isSelected }" @click="handleClick">
<input
v-if="multiSelect && !inQueue"
type="checkbox"
:checked="isSelected"
@click.stop="$emit('toggle-select')"
class="track-checkbox"
/>
<div class="track-info">
<span class="track-title">{{ track.title }}</span>
<span class="track-artist">{{ track.artist }}</span>
</div>
<span class="track-duration">{{ formatDuration(track.duration) }}</span>
<span v-if="inQueue" class="in-queue-label">В очереди</span>
<button
v-if="selectable"
v-if="selectable && !multiSelect && !inQueue"
class="btn-primary add-btn"
@click.stop="$emit('select')"
>
+
</button>
<button
v-if="!selectable"
v-if="!selectable && !multiSelect"
class="btn-danger delete-btn"
@click.stop="$emit('delete')"
>
@@ -23,7 +31,7 @@
</template>
<script setup>
defineProps({
const props = defineProps({
track: {
type: Object,
required: true
@@ -31,10 +39,30 @@ defineProps({
selectable: {
type: Boolean,
default: false
},
multiSelect: {
type: Boolean,
default: false
},
isSelected: {
type: Boolean,
default: false
},
inQueue: {
type: Boolean,
default: false
}
})
defineEmits(['select', 'delete'])
const emit = defineEmits(['select', 'toggle-select', 'delete'])
function handleClick() {
if (props.multiSelect && !props.inQueue) {
emit('toggle-select')
} else if (props.selectable && !props.inQueue) {
emit('select')
}
}
function formatDuration(ms) {
const seconds = Math.floor(ms / 1000)
@@ -60,6 +88,16 @@ function formatDuration(ms) {
background: #2d2d44;
}
.track-item.in-queue {
background: #2a2a3e;
opacity: 0.6;
cursor: not-allowed;
}
.track-item.in-queue:hover {
background: #2a2a3e;
}
.track-info {
flex: 1;
min-width: 0;
@@ -100,4 +138,29 @@ function formatDuration(ms) {
padding: 6px 12px;
font-size: 12px;
}
.in-queue-label {
color: #888;
font-size: 12px;
font-style: italic;
padding: 4px 8px;
background: #333;
border-radius: 4px;
}
.track-checkbox {
width: 18px;
height: 18px;
cursor: pointer;
flex-shrink: 0;
}
.track-item.selected {
background: #2d4a2d;
border: 1px solid var(--color-primary, #1db954);
}
.track-item.selected:hover {
background: #355535;
}
</style>

View File

@@ -8,7 +8,11 @@
:key="track.id"
:track="track"
:selectable="selectable"
:multi-select="multiSelect"
:is-selected="selectedTrackIds.includes(track.id)"
:in-queue="queueTrackIds.includes(track.id)"
@select="$emit('select', track)"
@toggle-select="$emit('toggle-select', track.id)"
@delete="$emit('delete', track)"
/>
</div>
@@ -25,10 +29,22 @@ defineProps({
selectable: {
type: Boolean,
default: false
},
multiSelect: {
type: Boolean,
default: false
},
queueTrackIds: {
type: Array,
default: () => []
},
selectedTrackIds: {
type: Array,
default: () => []
}
})
defineEmits(['select', 'delete'])
defineEmits(['select', 'toggle-select', 'delete'])
</script>
<style scoped>

View File

@@ -1,27 +1,43 @@
<template>
<form @submit.prevent="handleUpload" class="upload-form">
<div class="form-group">
<label>MP3 файл (макс. {{ maxFileSizeMb }}MB)</label>
<label>MP3 файлы (макс. {{ maxFileSizeMb }}MB каждый)</label>
<input
type="file"
accept="audio/mpeg,audio/mp3"
@change="handleFileSelect"
required
multiple
ref="fileInput"
/>
<small class="hint">Название и исполнитель будут взяты из тегов файла</small>
<small class="hint">Можно выбрать несколько файлов. Название и исполнитель будут взяты из тегов</small>
</div>
<div class="form-group">
<label>Название <span class="optional">(необязательно)</span></label>
<input type="text" v-model="title" placeholder="Оставьте пустым для автоопределения" />
<div v-if="files.length > 0" class="files-list">
<div class="files-header">
<span>Выбрано файлов: {{ files.length }}</span>
<button type="button" @click="clearFiles" class="btn-text">Очистить</button>
</div>
<div class="form-group">
<label>Исполнитель <span class="optional">(необязательно)</span></label>
<input type="text" v-model="artist" placeholder="Оставьте пустым для автоопределения" />
<div class="file-item" v-for="(file, index) in files" :key="index">
<div class="file-info">
<span class="file-name">{{ file.name }}</span>
<span class="file-size">{{ formatFileSize(file.size) }}</span>
</div>
<button type="button" @click="removeFile(index)" class="btn-remove">×</button>
</div>
</div>
<div v-if="uploading" class="upload-progress">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: uploadProgress + '%' }"></div>
</div>
<span class="progress-text">{{ uploadProgress }}%</span>
</div>
<p v-if="error" class="error-message">{{ error }}</p>
<button type="submit" class="btn-primary" :disabled="uploading">
{{ uploading ? 'Загрузка...' : 'Загрузить' }}
<p v-if="successMessage" class="success-message">{{ successMessage }}</p>
<button type="submit" class="btn-primary" :disabled="uploading || files.length === 0">
{{ uploading ? 'Загрузка...' : `Загрузить (${files.length})` }}
</button>
</form>
</template>
@@ -37,46 +53,78 @@ const tracksStore = useTracksStore()
const maxFileSizeMb = import.meta.env.VITE_MAX_FILE_SIZE_MB || 10
const maxFileSize = maxFileSizeMb * 1024 * 1024
const title = ref('')
const artist = ref('')
const file = ref(null)
const files = ref([])
const fileInput = ref(null)
const error = ref('')
const successMessage = ref('')
const uploading = ref(false)
const uploadProgress = ref(0)
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
function handleFileSelect(e) {
const selectedFile = e.target.files[0]
if (!selectedFile) return
const selectedFiles = Array.from(e.target.files)
if (selectedFiles.length === 0) return
// Check file size
if (selectedFile.size > maxFileSize) {
error.value = `Файл слишком большой (макс. ${maxFileSizeMb}MB)`
fileInput.value.value = ''
error.value = ''
successMessage.value = ''
// Check file sizes
const invalidFiles = selectedFiles.filter(f => f.size > maxFileSize)
if (invalidFiles.length > 0) {
error.value = `${invalidFiles.length} файл(ов) слишком большие (макс. ${maxFileSizeMb}MB каждый)`
return
}
file.value = selectedFile
files.value = selectedFiles
}
function removeFile(index) {
files.value = files.value.filter((_, i) => i !== index)
}
function clearFiles() {
files.value = []
fileInput.value.value = ''
error.value = ''
successMessage.value = ''
}
async function handleUpload() {
if (!file.value) {
error.value = 'Выберите файл'
if (files.value.length === 0) {
error.value = 'Выберите файлы'
return
}
uploading.value = true
error.value = ''
successMessage.value = ''
uploadProgress.value = 0
try {
await tracksStore.uploadTrack(file.value, title.value, artist.value)
title.value = ''
artist.value = ''
file.value = null
const uploadedTracks = await tracksStore.uploadMultipleTracks(
files.value,
(progress) => {
uploadProgress.value = progress
}
)
successMessage.value = `Успешно загружено ${uploadedTracks.length} трек(ов)!`
files.value = []
fileInput.value.value = ''
uploadProgress.value = 100
// Emit event after a short delay to show success message
setTimeout(() => {
emit('uploaded')
}, 1500)
} catch (e) {
error.value = e.response?.data?.detail || 'Ошибка загрузки'
uploadProgress.value = 0
} finally {
uploading.value = false
}
@@ -101,8 +149,124 @@ async function handleUpload() {
display: block;
}
.optional {
color: #666;
font-weight: normal;
.files-list {
background: #1a1a1a;
border-radius: 8px;
padding: 12px;
max-height: 300px;
overflow-y: auto;
}
.files-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #333;
font-size: 14px;
color: #aaa;
}
.btn-text {
background: none;
border: none;
color: var(--color-primary, #1db954);
cursor: pointer;
font-size: 14px;
padding: 4px 8px;
}
.btn-text:hover {
text-decoration: underline;
}
.file-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
background: #252525;
border-radius: 4px;
margin-bottom: 6px;
}
.file-item:last-child {
margin-bottom: 0;
}
.file-info {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.file-name {
font-size: 14px;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-size {
font-size: 12px;
color: #888;
margin-top: 2px;
}
.btn-remove {
background: #ff4444;
border: none;
color: #fff;
width: 24px;
height: 24px;
border-radius: 50%;
cursor: pointer;
font-size: 18px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
margin-left: 8px;
flex-shrink: 0;
}
.btn-remove:hover {
background: #ff6666;
}
.upload-progress {
display: flex;
flex-direction: column;
gap: 8px;
}
.progress-bar {
width: 100%;
height: 8px;
background: #333;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--color-primary, #1db954);
transition: width 0.3s ease;
}
.progress-text {
text-align: center;
font-size: 14px;
color: #aaa;
}
.success-message {
color: #4caf50;
font-size: 14px;
text-align: center;
margin: 0;
}
</style>

View File

@@ -8,15 +8,15 @@ export const useAuthStore = defineStore('auth', () => {
const isAuthenticated = computed(() => !!token.value)
async function login(email, password) {
const response = await api.post('/api/auth/login', { email, password })
async function login(username, password) {
const response = await api.post('/api/auth/login', { username, password })
token.value = response.data.access_token
localStorage.setItem('token', token.value)
await fetchUser()
}
async function register(username, email, password) {
const response = await api.post('/api/auth/register', { username, email, password })
async function register(username, password) {
const response = await api.post('/api/auth/register', { username, password })
token.value = response.data.access_token
localStorage.setItem('token', token.value)
await fetchUser()

View File

@@ -46,6 +46,11 @@ export const useRoomStore = defineStore('room', () => {
await api.post(`/api/rooms/${roomId}/queue`, { track_id: trackId })
}
async function addMultipleToQueue(roomId, trackIds) {
const response = await api.post(`/api/rooms/${roomId}/queue/bulk`, { track_ids: trackIds })
return response.data
}
async function removeFromQueue(roomId, trackId) {
await api.delete(`/api/rooms/${roomId}/queue/${trackId}`)
}
@@ -77,6 +82,7 @@ export const useRoomStore = defineStore('room', () => {
leaveRoom,
fetchQueue,
addToQueue,
addMultipleToQueue,
removeFromQueue,
updateParticipants,
addParticipant,

View File

@@ -29,6 +29,29 @@ export const useTracksStore = defineStore('tracks', () => {
return response.data
}
async function uploadMultipleTracks(files, onProgress) {
const formData = new FormData()
files.forEach(file => {
formData.append('files', file)
})
const response = await api.post('/api/tracks/upload-multiple', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (progressEvent) => {
if (onProgress) {
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total)
onProgress(percentCompleted)
}
}
})
// Add all uploaded tracks to the beginning
response.data.forEach(track => {
tracks.value.unshift(track)
})
return response.data
}
async function deleteTrack(trackId) {
await api.delete(`/api/tracks/${trackId}`)
tracks.value = tracks.value.filter(t => t.id !== trackId)
@@ -44,6 +67,7 @@ export const useTracksStore = defineStore('tracks', () => {
loading,
fetchTracks,
uploadTrack,
uploadMultipleTracks,
deleteTrack,
getTrackUrl,
}

View File

@@ -4,8 +4,8 @@
<h2>Вход</h2>
<form @submit.prevent="handleLogin">
<div class="form-group">
<label>Email</label>
<input type="email" v-model="email" required />
<label>Имя пользователя</label>
<input type="text" v-model="username" required />
</div>
<div class="form-group">
<label>Пароль</label>
@@ -31,7 +31,7 @@ import { useAuthStore } from '../stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const email = ref('')
const username = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
@@ -41,7 +41,7 @@ async function handleLogin() {
loading.value = true
try {
await authStore.login(email.value, password.value)
await authStore.login(username.value, password.value)
router.push('/')
} catch (e) {
error.value = e.response?.data?.detail || 'Ошибка входа'

View File

@@ -7,10 +7,6 @@
<label>Имя пользователя</label>
<input type="text" v-model="username" required />
</div>
<div class="form-group">
<label>Email</label>
<input type="email" v-model="email" required />
</div>
<div class="form-group">
<label>Пароль</label>
<input type="password" v-model="password" required minlength="6" />
@@ -36,7 +32,6 @@ const router = useRouter()
const authStore = useAuthStore()
const username = ref('')
const email = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
@@ -46,7 +41,7 @@ async function handleRegister() {
loading.value = true
try {
await authStore.register(username.value, email.value, password.value)
await authStore.register(username.value, password.value)
router.push('/')
} catch (e) {
error.value = e.response?.data?.detail || 'Ошибка регистрации'

View File

@@ -21,11 +21,64 @@
</div>
</div>
<Modal v-if="showAddTrack" title="Добавить в очередь" @close="showAddTrack = false">
<Modal v-if="showAddTrack" title="Добавить в очередь" @close="closeAddTrackModal">
<div class="filters-section">
<div class="search-filters">
<input
v-model="searchTitle"
type="text"
placeholder="Поиск по названию..."
class="search-input"
/>
<input
v-model="searchArtist"
type="text"
placeholder="Поиск по артисту..."
class="search-input"
/>
</div>
<div class="checkbox-filters">
<label class="checkbox-label">
<input type="checkbox" v-model="filterMyTracks" />
<span>Мои треки</span>
</label>
<label class="checkbox-label">
<input type="checkbox" v-model="filterNotInQueue" />
<span>Не добавленные в комнату</span>
</label>
</div>
</div>
<div class="add-track-controls">
<div class="selection-info">
<span>Найдено: {{ filteredTracks.length }}</span>
<span v-if="selectedTracks.length > 0" class="selected-count">
Выбрано: {{ selectedTracks.length }}
</span>
<button
v-if="selectedTracks.length > 0"
class="btn-text"
@click="clearSelection"
>
Очистить
</button>
</div>
<button
class="btn-primary"
:disabled="selectedTracks.length === 0"
@click="addSelectedTracks"
>
Добавить выбранные ({{ selectedTracks.length }})
</button>
</div>
<p v-if="addTrackError" class="error-message">{{ addTrackError }}</p>
<p v-if="addTrackSuccess" class="success-message">{{ addTrackSuccess }}</p>
<TrackList
:tracks="tracksStore.tracks"
selectable
@select="addTrackToQueue"
:tracks="filteredTracks"
:queue-track-ids="queueTrackIds"
:selected-track-ids="selectedTracks"
multi-select
@toggle-select="toggleTrackSelection"
/>
</Modal>
</div>
@@ -33,11 +86,12 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useRoomStore } from '../stores/room'
import { useTracksStore } from '../stores/tracks'
import { useActiveRoomStore } from '../stores/activeRoom'
import { useAuthStore } from '../stores/auth'
import Queue from '../components/room/Queue.vue'
import ParticipantsList from '../components/room/ParticipantsList.vue'
import ChatWindow from '../components/chat/ChatWindow.vue'
@@ -48,10 +102,57 @@ const route = useRoute()
const roomStore = useRoomStore()
const tracksStore = useTracksStore()
const activeRoomStore = useActiveRoomStore()
const authStore = useAuthStore()
const roomId = route.params.id
const room = ref(null)
const showAddTrack = ref(false)
const addTrackError = ref('')
const addTrackSuccess = ref('')
const selectedTracks = ref([])
// Filters
const searchTitle = ref('')
const searchArtist = ref('')
const filterMyTracks = ref(false)
const filterNotInQueue = ref(false)
const queueTrackIds = computed(() => {
return roomStore.queue.map(track => track.id)
})
const filteredTracks = computed(() => {
let tracks = tracksStore.tracks
// Filter by title
if (searchTitle.value.trim()) {
const searchLower = searchTitle.value.toLowerCase()
tracks = tracks.filter(track =>
track.title.toLowerCase().includes(searchLower)
)
}
// Filter by artist
if (searchArtist.value.trim()) {
const searchLower = searchArtist.value.toLowerCase()
tracks = tracks.filter(track =>
track.artist.toLowerCase().includes(searchLower)
)
}
// Filter my tracks
if (filterMyTracks.value) {
const currentUserId = authStore.user?.id
tracks = tracks.filter(track => track.uploaded_by === currentUserId)
}
// Filter not in queue
if (filterNotInQueue.value) {
tracks = tracks.filter(track => !queueTrackIds.value.includes(track.id))
}
return tracks
})
onMounted(async () => {
await roomStore.fetchRoom(roomId)
@@ -69,9 +170,59 @@ function playTrack(track) {
activeRoomStore.sendPlayerAction('set_track', null, track.id)
}
async function addTrackToQueue(track) {
await roomStore.addToQueue(roomId, track.id)
function toggleTrackSelection(trackId) {
const index = selectedTracks.value.indexOf(trackId)
if (index === -1) {
selectedTracks.value.push(trackId)
} else {
selectedTracks.value.splice(index, 1)
}
}
function clearSelection() {
selectedTracks.value = []
}
function closeAddTrackModal() {
showAddTrack.value = false
clearSelection()
addTrackError.value = ''
addTrackSuccess.value = ''
// Reset filters
searchTitle.value = ''
searchArtist.value = ''
filterMyTracks.value = false
filterNotInQueue.value = false
}
async function addSelectedTracks() {
if (selectedTracks.value.length === 0) return
try {
addTrackError.value = ''
addTrackSuccess.value = ''
const result = await roomStore.addMultipleToQueue(roomId, selectedTracks.value)
if (result.skipped > 0) {
addTrackSuccess.value = `Добавлено: ${result.added}, пропущено (уже в очереди): ${result.skipped}`
} else {
addTrackSuccess.value = `Добавлено треков: ${result.added}`
}
clearSelection()
// Close modal after a short delay
setTimeout(() => {
closeAddTrackModal()
}, 1500)
} catch (e) {
if (e.response?.data?.detail === 'All tracks already in queue') {
addTrackError.value = 'Все выбранные треки уже в очереди'
} else {
addTrackError.value = e.response?.data?.detail || 'Ошибка добавления треков'
}
}
}
async function removeFromQueue(track) {
@@ -134,6 +285,110 @@ async function removeFromQueue(track) {
color: #aaa;
}
.filters-section {
margin-bottom: 16px;
padding: 16px;
background: #1a1a1a;
border-radius: 8px;
}
.search-filters {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
.search-input {
padding: 10px 12px;
background: #252525;
border: 1px solid #333;
border-radius: 6px;
color: #fff;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.search-input:focus {
border-color: var(--color-primary, #1db954);
}
.search-input::placeholder {
color: #666;
}
.checkbox-filters {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
color: #ccc;
font-size: 14px;
user-select: none;
}
.checkbox-label input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--color-primary, #1db954);
}
.checkbox-label:hover {
color: #fff;
}
.add-track-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #333;
}
.selection-info {
display: flex;
align-items: center;
gap: 12px;
color: #aaa;
font-size: 14px;
flex-wrap: wrap;
}
.selected-count {
color: var(--color-primary, #1db954);
font-weight: 500;
}
.btn-text {
background: none;
border: none;
color: var(--color-primary, #1db954);
cursor: pointer;
font-size: 14px;
padding: 4px 8px;
text-decoration: underline;
}
.btn-text:hover {
opacity: 0.8;
}
.success-message {
color: #4caf50;
font-size: 14px;
text-align: center;
margin: 8px 0;
}
@media (max-width: 900px) {
.room-layout {
grid-template-columns: 1fr;