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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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="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 class="form-group">
|
||||
<label>Исполнитель <span class="optional">(необязательно)</span></label>
|
||||
<input type="text" v-model="artist" placeholder="Оставьте пустым для автоопределения" />
|
||||
|
||||
<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 = ''
|
||||
emit('uploaded')
|
||||
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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 || 'Ошибка входа'
|
||||
|
||||
@@ -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 || 'Ошибка регистрации'
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user