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

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