Files
enigFM/frontend/src/views/RoomView.vue
mamonov.ep 8a2ea5b4af 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>
2025-12-19 19:22:35 +03:00

398 lines
9.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="room-page" v-if="room">
<div class="room-header">
<h1>{{ room.name }}</h1>
</div>
<div class="room-layout">
<div class="main-section">
<div class="queue-section card">
<div class="queue-header">
<h3>Очередь</h3>
<button class="btn-secondary" @click="showAddTrack = true">Добавить</button>
</div>
<Queue :queue="roomStore.queue" @play-track="playTrack" @remove-track="removeFromQueue" />
</div>
</div>
<div class="side-section">
<ParticipantsList :participants="roomStore.participants" />
<ChatWindow :room-id="roomId" />
</div>
</div>
<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="filteredTracks"
:queue-track-ids="queueTrackIds"
:selected-track-ids="selectedTracks"
multi-select
@toggle-select="toggleTrackSelection"
/>
</Modal>
</div>
<div v-else class="loading">Загрузка...</div>
</template>
<script setup>
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'
import TrackList from '../components/tracks/TrackList.vue'
import Modal from '../components/common/Modal.vue'
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)
room.value = roomStore.currentRoom
await roomStore.joinRoom(roomId)
await roomStore.fetchQueue(roomId)
await tracksStore.fetchTracks()
// Connect to room via global store
activeRoomStore.connect(roomId, room.value.name)
})
function playTrack(track) {
activeRoomStore.sendPlayerAction('set_track', null, 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) {
await roomStore.removeFromQueue(roomId, track.id)
}
</script>
<style scoped>
.room-page {
padding-top: 20px;
}
.room-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.room-header h1 {
margin: 0;
}
.room-layout {
display: grid;
grid-template-columns: 1fr 350px;
gap: 20px;
}
.main-section {
display: flex;
flex-direction: column;
gap: 20px;
}
.side-section {
display: flex;
flex-direction: column;
gap: 20px;
}
.queue-section {
flex: 1;
}
.queue-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.queue-header h3 {
margin: 0;
}
.loading {
text-align: center;
padding: 40px;
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;
}
}
</style>