Add user ping system and room deletion functionality

Backend changes:
- Fix track deletion foreign key constraint (tracks.py)
  * Clear current_track_id from rooms before deleting track
  * Prevent deletion errors when track is currently playing

- Implement user ping/keepalive system (sync.py, websocket.py, ping_task.py, main.py)
  * Track last pong timestamp for each user
  * Background task sends ping every 30s, disconnects users after 60s timeout
  * Auto-pause playback when room becomes empty
  * Remove disconnected users from room_participants

- Enhance room deletion (rooms.py)
  * Broadcast room_deleted event to all connected users
  * Close all WebSocket connections before deletion
  * Cascade delete participants, queue, and messages

Frontend changes:
- Add ping/pong WebSocket handling (activeRoom.js)
  * Auto-respond to server pings
  * Handle room_deleted event with redirect to home

- Add room deletion UI (RoomView.vue, HomeView.vue, RoomCard.vue)
  * Delete button visible only to room owner
  * Confirmation dialog with warning
  * Delete button on room cards (shows on hover)
  * Redirect to home page after deletion

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-19 20:46:00 +03:00
parent ee8d79d155
commit 0fb16f791d
10 changed files with 398 additions and 9 deletions

View File

@@ -38,6 +38,15 @@
</v-chip>
</div>
</div>
<v-btn
v-if="isOwner"
icon="mdi-delete"
size="small"
variant="text"
color="error"
@click.stop="$emit('delete')"
class="delete-btn"
/>
</div>
<v-divider class="my-3" />
@@ -51,12 +60,23 @@
</template>
<script setup>
defineProps({
import { computed } from 'vue'
import { useAuthStore } from '../../stores/auth'
const props = defineProps({
room: {
type: Object,
required: true
}
})
defineEmits(['delete'])
const authStore = useAuthStore()
const isOwner = computed(() => {
return authStore.user && props.room.owner_id === authStore.user.id
})
</script>
<style scoped>
@@ -132,4 +152,13 @@ defineProps({
opacity: 0.7;
}
}
.delete-btn {
opacity: 0;
transition: opacity 0.2s;
}
.room-card:hover .delete-btn {
opacity: 1;
}
</style>

View File

@@ -181,6 +181,17 @@ export const useActiveRoomStore = defineStore('activeRoom', () => {
function handleMessage(msg) {
switch (msg.type) {
case 'ping':
// Отвечаем на серверный ping
send({ type: 'pong' })
break
case 'pong':
// Ответ на наш клиентский ping (игнорируем)
break
case 'room_deleted':
// Комната удалена владельцем
handleRoomDeleted(msg.message)
break
case 'player_state':
case 'sync_state':
syncToState(msg)
@@ -209,6 +220,16 @@ export const useActiveRoomStore = defineStore('activeRoom', () => {
}
}
function handleRoomDeleted(message) {
// Отключаемся от комнаты
disconnect()
// Перенаправляем на главную страницу
if (typeof window !== 'undefined') {
window.location.href = '/'
}
}
function syncToState(state) {
if (!audio) {
initAudio()

View File

@@ -43,6 +43,7 @@
:key="room.id"
:room="room"
@click="goToRoom(room.id)"
@delete="confirmDeleteRoom(room)"
/>
</div>
@@ -85,6 +86,38 @@
</v-card-actions>
</v-card>
</v-dialog>
<!-- Delete Confirmation Dialog -->
<v-dialog v-model="showDeleteDialog" max-width="500">
<v-card>
<v-card-title class="text-h5 text-error">
<v-icon class="mr-2">mdi-alert</v-icon>
Удалить комнату?
</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<p>Вы уверены, что хотите удалить комнату "{{ roomToDelete?.name }}"?</p>
<p class="mt-2 text-medium-emphasis">
Это действие нельзя отменить. Все участники будут отключены, а очередь и сообщения будут удалены.
</p>
</v-card-text>
<v-divider />
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showDeleteDialog = false">
Отмена
</v-btn>
<v-btn
color="error"
variant="flat"
@click="deleteRoom"
:loading="deleting"
>
Удалить
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
@@ -103,6 +136,9 @@ const loading = ref(true)
const showCreateModal = ref(false)
const newRoomName = ref('')
const creating = ref(false)
const showDeleteDialog = ref(false)
const roomToDelete = ref(null)
const deleting = ref(false)
onMounted(async () => {
await roomStore.fetchRooms()
@@ -128,6 +164,27 @@ function goToRoom(roomId) {
router.push('/login')
}
}
function confirmDeleteRoom(room) {
roomToDelete.value = room
showDeleteDialog.value = true
}
async function deleteRoom() {
if (!roomToDelete.value) return
try {
deleting.value = true
await roomStore.deleteRoom(roomToDelete.value.id)
showDeleteDialog.value = false
roomToDelete.value = null
} catch (e) {
console.error('Failed to delete room:', e)
alert('Ошибка при удалении комнаты')
} finally {
deleting.value = false
}
}
</script>
<style scoped>

View File

@@ -5,6 +5,16 @@
<h1 class="page-title">{{ room.name }}</h1>
<p class="page-subtitle">Комната для совместного прослушивания музыки</p>
</div>
<div class="room-actions" v-if="isOwner">
<v-btn
color="error"
variant="outlined"
prepend-icon="mdi-delete"
@click="showDeleteDialog = true"
>
Удалить комнату
</v-btn>
</div>
</div>
<v-row class="room-layout">
@@ -201,6 +211,38 @@
</v-card-actions>
</v-card>
</v-dialog>
<!-- Delete Confirmation Dialog -->
<v-dialog v-model="showDeleteDialog" max-width="500">
<v-card>
<v-card-title class="text-h5 text-error">
<v-icon class="mr-2">mdi-alert</v-icon>
Удалить комнату?
</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<p>Вы уверены, что хотите удалить комнату "{{ room.name }}"?</p>
<p class="mt-2 text-medium-emphasis">
Это действие нельзя отменить. Все участники будут отключены, а очередь и сообщения будут удалены.
</p>
</v-card-text>
<v-divider />
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showDeleteDialog = false">
Отмена
</v-btn>
<v-btn
color="error"
variant="flat"
@click="handleDeleteRoom"
:loading="deletingRoom"
>
Удалить
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
<div v-else class="loading-container">
<v-progress-circular
@@ -213,7 +255,7 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useRoomStore } from '../stores/room'
import { useTracksStore } from '../stores/tracks'
import { useActiveRoomStore } from '../stores/activeRoom'
@@ -225,6 +267,7 @@ import TrackList from '../components/tracks/TrackList.vue'
import Modal from '../components/common/Modal.vue'
const route = useRoute()
const router = useRouter()
const roomStore = useRoomStore()
const tracksStore = useTracksStore()
const activeRoomStore = useActiveRoomStore()
@@ -236,6 +279,8 @@ const showAddTrack = ref(false)
const addTrackError = ref('')
const addTrackSuccess = ref('')
const selectedTracks = ref([])
const showDeleteDialog = ref(false)
const deletingRoom = ref(false)
// Filters
const searchTitle = ref('')
@@ -243,6 +288,10 @@ const searchArtist = ref('')
const filterMyTracks = ref(false)
const filterNotInQueue = ref(false)
const isOwner = computed(() => {
return room.value && authStore.user && room.value.owner.id === authStore.user.id
})
const queueTrackIds = computed(() => {
return roomStore.queue.map(item => item.track.id)
})
@@ -354,6 +403,25 @@ async function addSelectedTracks() {
async function removeFromQueue(track) {
await roomStore.removeFromQueue(roomId, track.id)
}
async function handleDeleteRoom() {
try {
deletingRoom.value = true
await roomStore.deleteRoom(roomId)
// Отключаемся от WebSocket
activeRoomStore.disconnect()
// Перенаправляем на главную страницу
router.push('/')
} catch (e) {
console.error('Failed to delete room:', e)
alert('Ошибка при удалении комнаты')
} finally {
deletingRoom.value = false
showDeleteDialog.value = false
}
}
</script>
<style scoped>
@@ -362,7 +430,15 @@ async function removeFromQueue(track) {
}
.room-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
gap: 24px;
}
.room-actions {
flex-shrink: 0;
}
.page-title {