init
This commit is contained in:
2
frontend/.env.example
Normal file
2
frontend/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_URL=http://localhost:4001
|
||||
VITE_WS_URL=ws://localhost:4001
|
||||
20
frontend/Dockerfile
Normal file
20
frontend/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine as build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>EnigFM - Слушай музыку вместе</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
38
frontend/nginx.conf
Normal file
38
frontend/nginx.conf
Normal file
@@ -0,0 +1,38 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
# API proxy
|
||||
location /api {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# WebSocket proxy
|
||||
location /ws {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
# SPA routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
21
frontend/package.json
Normal file
21
frontend/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "enigfm-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.15",
|
||||
"vue-router": "^4.2.5",
|
||||
"pinia": "^2.1.7",
|
||||
"axios": "^1.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.3",
|
||||
"vite": "^5.0.11"
|
||||
}
|
||||
}
|
||||
28
frontend/src/App.vue
Normal file
28
frontend/src/App.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<Header />
|
||||
<main class="main-content">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Header from './components/common/Header.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
97
frontend/src/assets/styles/main.css
Normal file
97
frontend/src/assets/styles/main.css
Normal file
@@ -0,0 +1,97 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background-color: #1a1a2e;
|
||||
color: #eee;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #6c63ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #6c63ff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #5a52d5;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #2d2d44;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #3d3d5c;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ff4757;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #ff3344;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #3d3d5c;
|
||||
border-radius: 8px;
|
||||
background: #16162a;
|
||||
color: #eee;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input:focus, textarea:focus {
|
||||
outline: none;
|
||||
border-color: #6c63ff;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #16162a;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid #2d2d44;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #aaa;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ff4757;
|
||||
font-size: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
34
frontend/src/components/chat/ChatMessage.vue
Normal file
34
frontend/src/components/chat/ChatMessage.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="message">
|
||||
<span class="message-author">{{ message.username }}</span>
|
||||
<span class="message-text">{{ message.text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.message-author {
|
||||
font-size: 12px;
|
||||
color: #6c63ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
font-size: 14px;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
117
frontend/src/components/chat/ChatWindow.vue
Normal file
117
frontend/src/components/chat/ChatWindow.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div class="chat card">
|
||||
<h3>Чат</h3>
|
||||
<div class="messages" ref="messagesRef">
|
||||
<ChatMessage
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
/>
|
||||
</div>
|
||||
<form @submit.prevent="sendMessage" class="chat-input">
|
||||
<input
|
||||
type="text"
|
||||
v-model="newMessage"
|
||||
placeholder="Написать сообщение..."
|
||||
:disabled="!ws.connected"
|
||||
/>
|
||||
<button type="submit" class="btn-primary" :disabled="!newMessage.trim()">
|
||||
Отправить
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||
import api from '../../composables/useApi'
|
||||
import ChatMessage from './ChatMessage.vue'
|
||||
|
||||
const props = defineProps({
|
||||
roomId: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
ws: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const messages = ref([])
|
||||
const newMessage = ref('')
|
||||
const messagesRef = ref(null)
|
||||
|
||||
onMounted(async () => {
|
||||
const response = await api.get(`/api/rooms/${props.roomId}/messages`)
|
||||
messages.value = response.data
|
||||
scrollToBottom()
|
||||
})
|
||||
|
||||
// Listen for new messages from WebSocket
|
||||
watch(() => props.ws, (wsObj) => {
|
||||
if (wsObj?.messages) {
|
||||
watch(wsObj.messages, (msgs) => {
|
||||
const lastMsg = msgs[msgs.length - 1]
|
||||
if (lastMsg?.type === 'chat_message') {
|
||||
messages.value.push({
|
||||
id: lastMsg.id,
|
||||
user_id: lastMsg.user_id,
|
||||
username: lastMsg.username,
|
||||
text: lastMsg.text,
|
||||
created_at: lastMsg.created_at
|
||||
})
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
}, { deep: true })
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function sendMessage() {
|
||||
if (!newMessage.value.trim()) return
|
||||
props.ws.sendChatMessage(newMessage.value)
|
||||
newMessage.value = ''
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (messagesRef.value) {
|
||||
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.chat h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.chat-input input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat-input button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
78
frontend/src/components/common/Header.vue
Normal file
78
frontend/src/components/common/Header.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<router-link to="/" class="logo">EnigFM</router-link>
|
||||
<nav class="nav">
|
||||
<template v-if="authStore.isAuthenticated">
|
||||
<router-link to="/">Комнаты</router-link>
|
||||
<router-link to="/tracks">Треки</router-link>
|
||||
<span class="username">{{ authStore.user?.username }}</span>
|
||||
<button class="btn-secondary" @click="logout">Выйти</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<router-link to="/login">Войти</router-link>
|
||||
<router-link to="/register">Регистрация</router-link>
|
||||
</template>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
function logout() {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
background: #16162a;
|
||||
border-bottom: 1px solid #2d2d44;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #6c63ff;
|
||||
}
|
||||
|
||||
.logo:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.nav a:hover, .nav a.router-link-active {
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.username {
|
||||
color: #6c63ff;
|
||||
}
|
||||
</style>
|
||||
80
frontend/src/components/common/Modal.vue
Normal file
80
frontend/src/components/common/Modal.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="modal-overlay" @click.self="$emit('close')">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ title }}</h3>
|
||||
<button class="close-btn" @click="$emit('close')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['close'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #16162a;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #2d2d44;
|
||||
min-width: 400px;
|
||||
max-width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #2d2d44;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #aaa;
|
||||
font-size: 24px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
107
frontend/src/components/player/AudioPlayer.vue
Normal file
107
frontend/src/components/player/AudioPlayer.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="audio-player card">
|
||||
<div class="track-info">
|
||||
<div v-if="playerStore.currentTrack" class="track-details">
|
||||
<span class="track-title">{{ currentTrackInfo?.title || 'Трек' }}</span>
|
||||
<span class="track-artist">{{ currentTrackInfo?.artist || '' }}</span>
|
||||
</div>
|
||||
<div v-else class="no-track">
|
||||
Выберите трек для воспроизведения
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProgressBar
|
||||
:position="playerStore.position"
|
||||
:duration="playerStore.duration"
|
||||
@seek="handleSeek"
|
||||
/>
|
||||
|
||||
<PlayerControls
|
||||
:is-playing="playerStore.isPlaying"
|
||||
@play="handlePlay"
|
||||
@pause="handlePause"
|
||||
@next="handleNext"
|
||||
@prev="handlePrev"
|
||||
/>
|
||||
|
||||
<VolumeControl
|
||||
:volume="playerStore.volume"
|
||||
@change="handleVolumeChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { usePlayerStore } from '../../stores/player'
|
||||
import { useTracksStore } from '../../stores/tracks'
|
||||
import ProgressBar from './ProgressBar.vue'
|
||||
import PlayerControls from './PlayerControls.vue'
|
||||
import VolumeControl from './VolumeControl.vue'
|
||||
|
||||
const emit = defineEmits(['player-action'])
|
||||
|
||||
const playerStore = usePlayerStore()
|
||||
const tracksStore = useTracksStore()
|
||||
|
||||
const currentTrackInfo = computed(() => {
|
||||
if (!playerStore.currentTrack?.id) return null
|
||||
return tracksStore.tracks.find(t => t.id === playerStore.currentTrack.id)
|
||||
})
|
||||
|
||||
function handlePlay() {
|
||||
emit('player-action', 'play', playerStore.position)
|
||||
}
|
||||
|
||||
function handlePause() {
|
||||
emit('player-action', 'pause', playerStore.position)
|
||||
}
|
||||
|
||||
function handleSeek(position) {
|
||||
emit('player-action', 'seek', position)
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
emit('player-action', 'next')
|
||||
}
|
||||
|
||||
function handlePrev() {
|
||||
emit('player-action', 'prev')
|
||||
}
|
||||
|
||||
function handleVolumeChange(volume) {
|
||||
playerStore.setVolume(volume)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.audio-player {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.track-info {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.track-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.track-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.track-artist {
|
||||
color: #aaa;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.no-track {
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
80
frontend/src/components/player/PlayerControls.vue
Normal file
80
frontend/src/components/player/PlayerControls.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div class="player-controls">
|
||||
<button class="control-btn" @click="$emit('prev')">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="control-btn play-btn" @click="isPlaying ? $emit('pause') : $emit('play')">
|
||||
<svg v-if="isPlaying" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="control-btn" @click="$emit('next')">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
isPlaying: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['play', 'pause', 'next', 'prev'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.player-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #eee;
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: #2d2d44;
|
||||
}
|
||||
|
||||
.control-btn svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
background: #6c63ff;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.play-btn:hover {
|
||||
background: #5a52d5;
|
||||
}
|
||||
|
||||
.play-btn svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
</style>
|
||||
82
frontend/src/components/player/ProgressBar.vue
Normal file
82
frontend/src/components/player/ProgressBar.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="progress-container">
|
||||
<span class="time">{{ formatTime(position) }}</span>
|
||||
<div class="progress-bar" @click="handleClick" ref="progressRef">
|
||||
<div class="progress-fill" :style="{ width: progressPercent + '%' }"></div>
|
||||
</div>
|
||||
<span class="time">{{ formatTime(duration) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
position: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
duration: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['seek'])
|
||||
|
||||
const progressRef = ref(null)
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
if (props.duration === 0) return 0
|
||||
return (props.position / props.duration) * 100
|
||||
})
|
||||
|
||||
function formatTime(ms) {
|
||||
const seconds = Math.floor(ms / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function handleClick(e) {
|
||||
if (!progressRef.value || props.duration === 0) return
|
||||
const rect = progressRef.value.getBoundingClientRect()
|
||||
const percent = (e.clientX - rect.left) / rect.width
|
||||
const newPosition = Math.floor(percent * props.duration)
|
||||
emit('seek', newPosition)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.progress-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.time:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: #2d2d44;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: #6c63ff;
|
||||
border-radius: 3px;
|
||||
transition: width 0.1s;
|
||||
}
|
||||
</style>
|
||||
103
frontend/src/components/player/VolumeControl.vue
Normal file
103
frontend/src/components/player/VolumeControl.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="volume-control">
|
||||
<button class="volume-btn" @click="toggleMute">
|
||||
<svg v-if="volume === 0" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
|
||||
</svg>
|
||||
<svg v-else-if="volume < 50" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM5 9v6h4l5 5V4L9 9H5z"/>
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
:value="volume"
|
||||
@input="$emit('change', parseInt($event.target.value))"
|
||||
class="volume-slider"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
volume: {
|
||||
type: Number,
|
||||
default: 100
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const previousVolume = ref(100)
|
||||
|
||||
function toggleMute() {
|
||||
if (props.volume > 0) {
|
||||
previousVolume.value = props.volume
|
||||
emit('change', 0)
|
||||
} else {
|
||||
emit('change', previousVolume.value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.volume-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.volume-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #aaa;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.volume-btn:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.volume-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
width: 100px;
|
||||
height: 4px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: #2d2d44;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.volume-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #6c63ff;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.volume-slider::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #6c63ff;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
61
frontend/src/components/room/ParticipantsList.vue
Normal file
61
frontend/src/components/room/ParticipantsList.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="participants card">
|
||||
<h3>Участники ({{ participants.length }})</h3>
|
||||
<div class="participants-list">
|
||||
<div
|
||||
v-for="participant in participants"
|
||||
:key="participant.id"
|
||||
class="participant"
|
||||
>
|
||||
<div class="avatar">{{ participant.username.charAt(0).toUpperCase() }}</div>
|
||||
<span class="username">{{ participant.username }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
participants: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.participants h3 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.participants-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.participant {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #6c63ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
98
frontend/src/components/room/Queue.vue
Normal file
98
frontend/src/components/room/Queue.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<div class="queue">
|
||||
<div v-if="queue.length === 0" class="empty-queue">
|
||||
Очередь пуста
|
||||
</div>
|
||||
<div
|
||||
v-for="(track, index) in queue"
|
||||
:key="track.id"
|
||||
class="queue-item"
|
||||
@click="$emit('play-track', track)"
|
||||
>
|
||||
<span class="queue-index">{{ index + 1 }}</span>
|
||||
<div class="queue-track-info">
|
||||
<span class="queue-track-title">{{ track.title }}</span>
|
||||
<span class="queue-track-artist">{{ track.artist }}</span>
|
||||
</div>
|
||||
<span class="queue-duration">{{ formatDuration(track.duration) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
queue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['play-track'])
|
||||
|
||||
function formatDuration(ms) {
|
||||
const seconds = Math.floor(ms / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.queue {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.empty-queue {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.queue-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.queue-item:hover {
|
||||
background: #2d2d44;
|
||||
}
|
||||
|
||||
.queue-index {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.queue-track-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.queue-track-title {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.queue-track-artist {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.queue-duration {
|
||||
color: #aaa;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
64
frontend/src/components/room/RoomCard.vue
Normal file
64
frontend/src/components/room/RoomCard.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="room-card card">
|
||||
<h3>{{ room.name }}</h3>
|
||||
<div class="room-info">
|
||||
<span class="participants">{{ room.participants_count }} участников</span>
|
||||
<span v-if="room.is_playing" class="playing">Играет</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
room: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.room-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.room-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: #6c63ff;
|
||||
}
|
||||
|
||||
.room-card h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.room-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: #aaa;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.playing {
|
||||
color: #2ed573;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.playing::before {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #2ed573;
|
||||
border-radius: 50%;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
</style>
|
||||
103
frontend/src/components/tracks/TrackItem.vue
Normal file
103
frontend/src/components/tracks/TrackItem.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="track-item" @click="selectable && $emit('select')">
|
||||
<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>
|
||||
<button
|
||||
v-if="selectable"
|
||||
class="btn-primary add-btn"
|
||||
@click.stop="$emit('select')"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
v-if="!selectable"
|
||||
class="btn-danger delete-btn"
|
||||
@click.stop="$emit('delete')"
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
track: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
selectable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['select', 'delete'])
|
||||
|
||||
function formatDuration(ms) {
|
||||
const seconds = Math.floor(ms / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.track-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: #1a1a2e;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.track-item:hover {
|
||||
background: #2d2d44;
|
||||
}
|
||||
|
||||
.track-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.track-title {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.track-artist {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.track-duration {
|
||||
color: #aaa;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
font-size: 18px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
48
frontend/src/components/tracks/TrackList.vue
Normal file
48
frontend/src/components/tracks/TrackList.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="track-list">
|
||||
<div v-if="tracks.length === 0" class="empty">
|
||||
Нет треков
|
||||
</div>
|
||||
<TrackItem
|
||||
v-for="track in tracks"
|
||||
:key="track.id"
|
||||
:track="track"
|
||||
:selectable="selectable"
|
||||
@select="$emit('select', track)"
|
||||
@delete="$emit('delete', track)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import TrackItem from './TrackItem.vue'
|
||||
|
||||
defineProps({
|
||||
tracks: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
selectable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['select', 'delete'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.track-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
105
frontend/src/components/tracks/UploadTrack.vue
Normal file
105
frontend/src/components/tracks/UploadTrack.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<form @submit.prevent="handleUpload" class="upload-form">
|
||||
<div class="form-group">
|
||||
<label>MP3 файл (макс. 10MB)</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/mpeg,audio/mp3"
|
||||
@change="handleFileSelect"
|
||||
required
|
||||
ref="fileInput"
|
||||
/>
|
||||
<small class="hint">Название и исполнитель будут взяты из тегов файла</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Название <span class="optional">(необязательно)</span></label>
|
||||
<input type="text" v-model="title" placeholder="Оставьте пустым для автоопределения" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Исполнитель <span class="optional">(необязательно)</span></label>
|
||||
<input type="text" v-model="artist" placeholder="Оставьте пустым для автоопределения" />
|
||||
</div>
|
||||
<p v-if="error" class="error-message">{{ error }}</p>
|
||||
<button type="submit" class="btn-primary" :disabled="uploading">
|
||||
{{ uploading ? 'Загрузка...' : 'Загрузить' }}
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useTracksStore } from '../../stores/tracks'
|
||||
|
||||
const emit = defineEmits(['uploaded'])
|
||||
|
||||
const tracksStore = useTracksStore()
|
||||
|
||||
const title = ref('')
|
||||
const artist = ref('')
|
||||
const file = ref(null)
|
||||
const fileInput = ref(null)
|
||||
const error = ref('')
|
||||
const uploading = ref(false)
|
||||
|
||||
function handleFileSelect(e) {
|
||||
const selectedFile = e.target.files[0]
|
||||
if (!selectedFile) return
|
||||
|
||||
// Check file size (10MB)
|
||||
if (selectedFile.size > 10 * 1024 * 1024) {
|
||||
error.value = 'Файл слишком большой (макс. 10MB)'
|
||||
fileInput.value.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
file.value = selectedFile
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (!file.value) {
|
||||
error.value = 'Выберите файл'
|
||||
return
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
await tracksStore.uploadTrack(file.value, title.value, artist.value)
|
||||
title.value = ''
|
||||
artist.value = ''
|
||||
file.value = null
|
||||
fileInput.value.value = ''
|
||||
emit('uploaded')
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.detail || 'Ошибка загрузки'
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.upload-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.upload-form input[type="file"] {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.optional {
|
||||
color: #666;
|
||||
font-weight: normal;
|
||||
}
|
||||
</style>
|
||||
26
frontend/src/composables/useApi.js
Normal file
26
frontend/src/composables/useApi.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || '',
|
||||
})
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
117
frontend/src/composables/usePlayer.js
Normal file
117
frontend/src/composables/usePlayer.js
Normal file
@@ -0,0 +1,117 @@
|
||||
import { ref, watch, onUnmounted } from 'vue'
|
||||
import { usePlayerStore } from '../stores/player'
|
||||
|
||||
export function usePlayer(onTrackEnded = null) {
|
||||
const audio = ref(null)
|
||||
const playerStore = usePlayerStore()
|
||||
let endedCallback = onTrackEnded
|
||||
|
||||
function setOnTrackEnded(callback) {
|
||||
endedCallback = callback
|
||||
}
|
||||
|
||||
function initAudio() {
|
||||
audio.value = new Audio()
|
||||
audio.value.volume = playerStore.volume / 100
|
||||
|
||||
audio.value.addEventListener('timeupdate', () => {
|
||||
playerStore.setPosition(Math.floor(audio.value.currentTime * 1000))
|
||||
})
|
||||
|
||||
audio.value.addEventListener('loadedmetadata', () => {
|
||||
playerStore.setDuration(Math.floor(audio.value.duration * 1000))
|
||||
})
|
||||
|
||||
audio.value.addEventListener('ended', () => {
|
||||
if (endedCallback) {
|
||||
endedCallback()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function loadTrack(url) {
|
||||
if (!audio.value) initAudio()
|
||||
// If URL is relative, prepend API base URL
|
||||
const apiUrl = import.meta.env.VITE_API_URL || ''
|
||||
const fullUrl = url.startsWith('/') ? `${apiUrl}${url}` : url
|
||||
audio.value.src = fullUrl
|
||||
audio.value.load()
|
||||
}
|
||||
|
||||
function play() {
|
||||
if (audio.value) {
|
||||
audio.value.play().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function pause() {
|
||||
if (audio.value) {
|
||||
audio.value.pause()
|
||||
}
|
||||
}
|
||||
|
||||
function seek(positionMs) {
|
||||
if (audio.value) {
|
||||
audio.value.currentTime = positionMs / 1000
|
||||
}
|
||||
}
|
||||
|
||||
function setVolume(volume) {
|
||||
if (audio.value) {
|
||||
audio.value.volume = volume / 100
|
||||
}
|
||||
playerStore.setVolume(volume)
|
||||
}
|
||||
|
||||
function syncToState(state) {
|
||||
// Initialize audio if needed
|
||||
if (!audio.value) {
|
||||
initAudio()
|
||||
}
|
||||
|
||||
if (state.track_url && state.track_url !== playerStore.currentTrackUrl) {
|
||||
loadTrack(state.track_url)
|
||||
playerStore.currentTrackUrl = state.track_url
|
||||
}
|
||||
|
||||
if (state.position !== undefined) {
|
||||
const diff = Math.abs(state.position - playerStore.position)
|
||||
// Sync if difference > 2 seconds
|
||||
if (diff > 2000) {
|
||||
seek(state.position)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.is_playing) {
|
||||
play()
|
||||
} else {
|
||||
pause()
|
||||
}
|
||||
}
|
||||
|
||||
// Watch volume changes
|
||||
watch(() => playerStore.volume, (newVolume) => {
|
||||
if (audio.value) {
|
||||
audio.value.volume = newVolume / 100
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (audio.value) {
|
||||
audio.value.pause()
|
||||
audio.value = null
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
audio,
|
||||
initAudio,
|
||||
loadTrack,
|
||||
play,
|
||||
pause,
|
||||
seek,
|
||||
setVolume,
|
||||
syncToState,
|
||||
setOnTrackEnded,
|
||||
}
|
||||
}
|
||||
81
frontend/src/composables/useWebSocket.js
Normal file
81
frontend/src/composables/useWebSocket.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
export function useWebSocket(roomId, onMessage = null) {
|
||||
const ws = ref(null)
|
||||
const connected = ref(false)
|
||||
const messages = ref([])
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
function connect() {
|
||||
const wsUrl = import.meta.env.VITE_WS_URL || window.location.origin.replace('http', 'ws')
|
||||
ws.value = new WebSocket(`${wsUrl}/ws/rooms/${roomId}?token=${authStore.token}`)
|
||||
|
||||
ws.value.onopen = () => {
|
||||
connected.value = true
|
||||
// Request sync on connect
|
||||
send({ type: 'sync_request' })
|
||||
}
|
||||
|
||||
ws.value.onclose = () => {
|
||||
connected.value = false
|
||||
}
|
||||
|
||||
ws.value.onerror = (error) => {
|
||||
console.error('WebSocket error:', error)
|
||||
}
|
||||
|
||||
ws.value.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
messages.value.push(data)
|
||||
if (onMessage) {
|
||||
onMessage(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function send(data) {
|
||||
if (ws.value && ws.value.readyState === WebSocket.OPEN) {
|
||||
ws.value.send(JSON.stringify(data))
|
||||
}
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (ws.value) {
|
||||
ws.value.close()
|
||||
ws.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function sendPlayerAction(action, position = null, trackId = null) {
|
||||
send({
|
||||
type: 'player_action',
|
||||
action,
|
||||
position,
|
||||
track_id: trackId,
|
||||
})
|
||||
}
|
||||
|
||||
function sendChatMessage(text) {
|
||||
send({
|
||||
type: 'chat_message',
|
||||
text,
|
||||
})
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
disconnect()
|
||||
})
|
||||
|
||||
return {
|
||||
ws,
|
||||
connected,
|
||||
messages,
|
||||
connect,
|
||||
send,
|
||||
disconnect,
|
||||
sendPlayerAction,
|
||||
sendChatMessage,
|
||||
}
|
||||
}
|
||||
12
frontend/src/main.js
Normal file
12
frontend/src/main.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './assets/styles/main.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
53
frontend/src/router/index.js
Normal file
53
frontend/src/router/index.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: () => import('../views/HomeView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('../views/LoginView.vue'),
|
||||
meta: { guest: true },
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: () => import('../views/RegisterView.vue'),
|
||||
meta: { guest: true },
|
||||
},
|
||||
{
|
||||
path: '/room/:id',
|
||||
name: 'Room',
|
||||
component: () => import('../views/RoomView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/tracks',
|
||||
name: 'Tracks',
|
||||
component: () => import('../views/TracksView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
next({ name: 'Login' })
|
||||
} else if (to.meta.guest && authStore.isAuthenticated) {
|
||||
next({ name: 'Home' })
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
55
frontend/src/stores/auth.js
Normal file
55
frontend/src/stores/auth.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import api from '../composables/useApi'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref(localStorage.getItem('token') || null)
|
||||
const user = ref(null)
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value)
|
||||
|
||||
async function login(email, password) {
|
||||
const response = await api.post('/api/auth/login', { email, 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 })
|
||||
token.value = response.data.access_token
|
||||
localStorage.setItem('token', token.value)
|
||||
await fetchUser()
|
||||
}
|
||||
|
||||
async function fetchUser() {
|
||||
if (!token.value) return
|
||||
try {
|
||||
const response = await api.get('/api/auth/me')
|
||||
user.value = response.data
|
||||
} catch (error) {
|
||||
logout()
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = null
|
||||
user.value = null
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
|
||||
// Initialize
|
||||
if (token.value) {
|
||||
fetchUser()
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
isAuthenticated,
|
||||
login,
|
||||
register,
|
||||
fetchUser,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
71
frontend/src/stores/player.js
Normal file
71
frontend/src/stores/player.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const usePlayerStore = defineStore('player', () => {
|
||||
const isPlaying = ref(false)
|
||||
const currentTrack = ref(null)
|
||||
const currentTrackUrl = ref(null)
|
||||
const position = ref(0)
|
||||
const duration = ref(0)
|
||||
const volume = ref(100)
|
||||
|
||||
function setPlayerState(state) {
|
||||
isPlaying.value = state.is_playing
|
||||
position.value = state.position
|
||||
if (state.current_track_id) {
|
||||
currentTrack.value = { id: state.current_track_id }
|
||||
}
|
||||
if (state.track_url) {
|
||||
currentTrackUrl.value = state.track_url
|
||||
}
|
||||
}
|
||||
|
||||
function setTrack(track, url) {
|
||||
currentTrack.value = track
|
||||
currentTrackUrl.value = url
|
||||
position.value = 0
|
||||
}
|
||||
|
||||
function setPosition(pos) {
|
||||
position.value = pos
|
||||
}
|
||||
|
||||
function setDuration(dur) {
|
||||
duration.value = dur
|
||||
}
|
||||
|
||||
function setVolume(vol) {
|
||||
volume.value = vol
|
||||
localStorage.setItem('volume', vol)
|
||||
}
|
||||
|
||||
function play() {
|
||||
isPlaying.value = true
|
||||
}
|
||||
|
||||
function pause() {
|
||||
isPlaying.value = false
|
||||
}
|
||||
|
||||
// Load saved volume
|
||||
const savedVolume = localStorage.getItem('volume')
|
||||
if (savedVolume) {
|
||||
volume.value = parseInt(savedVolume)
|
||||
}
|
||||
|
||||
return {
|
||||
isPlaying,
|
||||
currentTrack,
|
||||
currentTrackUrl,
|
||||
position,
|
||||
duration,
|
||||
volume,
|
||||
setPlayerState,
|
||||
setTrack,
|
||||
setPosition,
|
||||
setDuration,
|
||||
setVolume,
|
||||
play,
|
||||
pause,
|
||||
}
|
||||
})
|
||||
85
frontend/src/stores/room.js
Normal file
85
frontend/src/stores/room.js
Normal file
@@ -0,0 +1,85 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import api from '../composables/useApi'
|
||||
|
||||
export const useRoomStore = defineStore('room', () => {
|
||||
const rooms = ref([])
|
||||
const currentRoom = ref(null)
|
||||
const participants = ref([])
|
||||
const queue = ref([])
|
||||
|
||||
async function fetchRooms() {
|
||||
const response = await api.get('/api/rooms')
|
||||
rooms.value = response.data
|
||||
}
|
||||
|
||||
async function fetchRoom(roomId) {
|
||||
const response = await api.get(`/api/rooms/${roomId}`)
|
||||
currentRoom.value = response.data
|
||||
participants.value = response.data.participants
|
||||
}
|
||||
|
||||
async function createRoom(name) {
|
||||
const response = await api.post('/api/rooms', { name })
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function deleteRoom(roomId) {
|
||||
await api.delete(`/api/rooms/${roomId}`)
|
||||
rooms.value = rooms.value.filter(r => r.id !== roomId)
|
||||
}
|
||||
|
||||
async function joinRoom(roomId) {
|
||||
await api.post(`/api/rooms/${roomId}/join`)
|
||||
}
|
||||
|
||||
async function leaveRoom(roomId) {
|
||||
await api.post(`/api/rooms/${roomId}/leave`)
|
||||
}
|
||||
|
||||
async function fetchQueue(roomId) {
|
||||
const response = await api.get(`/api/rooms/${roomId}/queue`)
|
||||
queue.value = response.data
|
||||
}
|
||||
|
||||
async function addToQueue(roomId, trackId) {
|
||||
await api.post(`/api/rooms/${roomId}/queue`, { track_id: trackId })
|
||||
}
|
||||
|
||||
async function removeFromQueue(roomId, trackId) {
|
||||
await api.delete(`/api/rooms/${roomId}/queue/${trackId}`)
|
||||
}
|
||||
|
||||
function updateParticipants(newParticipants) {
|
||||
participants.value = newParticipants
|
||||
}
|
||||
|
||||
function addParticipant(user) {
|
||||
if (!participants.value.find(p => p.id === user.id)) {
|
||||
participants.value.push(user)
|
||||
}
|
||||
}
|
||||
|
||||
function removeParticipant(userId) {
|
||||
participants.value = participants.value.filter(p => p.id !== userId)
|
||||
}
|
||||
|
||||
return {
|
||||
rooms,
|
||||
currentRoom,
|
||||
participants,
|
||||
queue,
|
||||
fetchRooms,
|
||||
fetchRoom,
|
||||
createRoom,
|
||||
deleteRoom,
|
||||
joinRoom,
|
||||
leaveRoom,
|
||||
fetchQueue,
|
||||
addToQueue,
|
||||
removeFromQueue,
|
||||
updateParticipants,
|
||||
addParticipant,
|
||||
removeParticipant,
|
||||
}
|
||||
})
|
||||
50
frontend/src/stores/tracks.js
Normal file
50
frontend/src/stores/tracks.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import api from '../composables/useApi'
|
||||
|
||||
export const useTracksStore = defineStore('tracks', () => {
|
||||
const tracks = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchTracks() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await api.get('/api/tracks')
|
||||
tracks.value = response.data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadTrack(file, title, artist) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('title', title)
|
||||
formData.append('artist', artist)
|
||||
|
||||
const response = await api.post('/api/tracks/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
tracks.value.unshift(response.data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function deleteTrack(trackId) {
|
||||
await api.delete(`/api/tracks/${trackId}`)
|
||||
tracks.value = tracks.value.filter(t => t.id !== trackId)
|
||||
}
|
||||
|
||||
async function getTrackUrl(trackId) {
|
||||
const response = await api.get(`/api/tracks/${trackId}`)
|
||||
return response.data.url
|
||||
}
|
||||
|
||||
return {
|
||||
tracks,
|
||||
loading,
|
||||
fetchTracks,
|
||||
uploadTrack,
|
||||
deleteTrack,
|
||||
getTrackUrl,
|
||||
}
|
||||
})
|
||||
109
frontend/src/views/HomeView.vue
Normal file
109
frontend/src/views/HomeView.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="home">
|
||||
<div class="header-section">
|
||||
<h1>Комнаты</h1>
|
||||
<button v-if="authStore.isAuthenticated" class="btn-primary" @click="showCreateModal = true">
|
||||
Создать комнату
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Загрузка...</div>
|
||||
|
||||
<div v-else-if="roomStore.rooms.length === 0" class="empty">
|
||||
<p>Пока нет комнат. Создайте первую!</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="rooms-grid">
|
||||
<RoomCard
|
||||
v-for="room in roomStore.rooms"
|
||||
:key="room.id"
|
||||
:room="room"
|
||||
@click="goToRoom(room.id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal v-if="showCreateModal" title="Создать комнату" @close="showCreateModal = false">
|
||||
<form @submit.prevent="createRoom">
|
||||
<div class="form-group">
|
||||
<label>Название комнаты</label>
|
||||
<input type="text" v-model="newRoomName" required placeholder="Моя комната" />
|
||||
</div>
|
||||
<button type="submit" class="btn-primary" :disabled="creating">
|
||||
{{ creating ? 'Создание...' : 'Создать' }}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useRoomStore } from '../stores/room'
|
||||
import RoomCard from '../components/room/RoomCard.vue'
|
||||
import Modal from '../components/common/Modal.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const roomStore = useRoomStore()
|
||||
|
||||
const loading = ref(true)
|
||||
const showCreateModal = ref(false)
|
||||
const newRoomName = ref('')
|
||||
const creating = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
await roomStore.fetchRooms()
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
async function createRoom() {
|
||||
creating.value = true
|
||||
try {
|
||||
const room = await roomStore.createRoom(newRoomName.value)
|
||||
showCreateModal.value = false
|
||||
newRoomName.value = ''
|
||||
router.push(`/room/${room.id}`)
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToRoom(roomId) {
|
||||
if (authStore.isAuthenticated) {
|
||||
router.push(`/room/${roomId}`)
|
||||
} else {
|
||||
router.push('/login')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header-section h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rooms-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.loading, .empty {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #aaa;
|
||||
}
|
||||
</style>
|
||||
82
frontend/src/views/LoginView.vue
Normal file
82
frontend/src/views/LoginView.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="auth-card card">
|
||||
<h2>Вход</h2>
|
||||
<form @submit.prevent="handleLogin">
|
||||
<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 />
|
||||
</div>
|
||||
<p v-if="error" class="error-message">{{ error }}</p>
|
||||
<button type="submit" class="btn-primary" :disabled="loading">
|
||||
{{ loading ? 'Вход...' : 'Войти' }}
|
||||
</button>
|
||||
</form>
|
||||
<p class="auth-link">
|
||||
Нет аккаунта? <router-link to="/register">Зарегистрироваться</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function handleLogin() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
await authStore.login(email.value, password.value)
|
||||
router.push('/')
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.detail || 'Ошибка входа'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: calc(100vh - 100px);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.auth-card h2 {
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-card button {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.auth-link {
|
||||
text-align: center;
|
||||
margin-top: 16px;
|
||||
color: #aaa;
|
||||
}
|
||||
</style>
|
||||
87
frontend/src/views/RegisterView.vue
Normal file
87
frontend/src/views/RegisterView.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="auth-card card">
|
||||
<h2>Регистрация</h2>
|
||||
<form @submit.prevent="handleRegister">
|
||||
<div class="form-group">
|
||||
<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" />
|
||||
</div>
|
||||
<p v-if="error" class="error-message">{{ error }}</p>
|
||||
<button type="submit" class="btn-primary" :disabled="loading">
|
||||
{{ loading ? 'Регистрация...' : 'Зарегистрироваться' }}
|
||||
</button>
|
||||
</form>
|
||||
<p class="auth-link">
|
||||
Уже есть аккаунт? <router-link to="/login">Войти</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const username = ref('')
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function handleRegister() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
await authStore.register(username.value, email.value, password.value)
|
||||
router.push('/')
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.detail || 'Ошибка регистрации'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: calc(100vh - 100px);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.auth-card h2 {
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-card button {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.auth-link {
|
||||
text-align: center;
|
||||
margin-top: 16px;
|
||||
color: #aaa;
|
||||
}
|
||||
</style>
|
||||
193
frontend/src/views/RoomView.vue
Normal file
193
frontend/src/views/RoomView.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div class="room-page" v-if="room">
|
||||
<div class="room-header">
|
||||
<h1>{{ room.name }}</h1>
|
||||
<button class="btn-secondary" @click="leaveAndGoHome">Выйти из комнаты</button>
|
||||
</div>
|
||||
|
||||
<div class="room-layout">
|
||||
<div class="main-section">
|
||||
<AudioPlayer
|
||||
:ws="websocket"
|
||||
@player-action="handlePlayerAction"
|
||||
/>
|
||||
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="side-section">
|
||||
<ParticipantsList :participants="roomStore.participants" />
|
||||
<ChatWindow :room-id="roomId" :ws="websocket" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal v-if="showAddTrack" title="Добавить в очередь" @close="showAddTrack = false">
|
||||
<TrackList
|
||||
:tracks="tracksStore.tracks"
|
||||
selectable
|
||||
@select="addTrackToQueue"
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
<div v-else class="loading">Загрузка...</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useRoomStore } from '../stores/room'
|
||||
import { useTracksStore } from '../stores/tracks'
|
||||
import { usePlayerStore } from '../stores/player'
|
||||
import { useWebSocket } from '../composables/useWebSocket'
|
||||
import { usePlayer } from '../composables/usePlayer'
|
||||
import AudioPlayer from '../components/player/AudioPlayer.vue'
|
||||
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 router = useRouter()
|
||||
const roomStore = useRoomStore()
|
||||
const tracksStore = useTracksStore()
|
||||
const playerStore = usePlayerStore()
|
||||
|
||||
const roomId = route.params.id
|
||||
const room = ref(null)
|
||||
const showAddTrack = ref(false)
|
||||
|
||||
const { syncToState, setOnTrackEnded } = usePlayer()
|
||||
|
||||
function handleTrackEnded() {
|
||||
sendPlayerAction('next')
|
||||
}
|
||||
|
||||
function handleWsMessage(msg) {
|
||||
switch (msg.type) {
|
||||
case 'player_state':
|
||||
case 'sync_state':
|
||||
// Call syncToState BEFORE updating store so it can detect URL changes
|
||||
syncToState(msg)
|
||||
playerStore.setPlayerState(msg)
|
||||
break
|
||||
case 'user_joined':
|
||||
roomStore.addParticipant(msg.user)
|
||||
break
|
||||
case 'user_left':
|
||||
roomStore.removeParticipant(msg.user_id)
|
||||
break
|
||||
case 'queue_updated':
|
||||
roomStore.fetchQueue(roomId)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const { connect, disconnect, sendPlayerAction, connected } = useWebSocket(roomId, handleWsMessage)
|
||||
|
||||
const websocket = { sendPlayerAction, connected }
|
||||
|
||||
onMounted(async () => {
|
||||
await roomStore.fetchRoom(roomId)
|
||||
room.value = roomStore.currentRoom
|
||||
|
||||
await roomStore.joinRoom(roomId)
|
||||
await roomStore.fetchQueue(roomId)
|
||||
await tracksStore.fetchTracks()
|
||||
|
||||
// Set callback for when track ends
|
||||
setOnTrackEnded(handleTrackEnded)
|
||||
|
||||
connect()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disconnect()
|
||||
})
|
||||
|
||||
function handlePlayerAction(action, position) {
|
||||
sendPlayerAction(action, position)
|
||||
}
|
||||
|
||||
function playTrack(track) {
|
||||
sendPlayerAction('set_track', null, track.id)
|
||||
}
|
||||
|
||||
async function addTrackToQueue(track) {
|
||||
await roomStore.addToQueue(roomId, track.id)
|
||||
showAddTrack.value = false
|
||||
}
|
||||
|
||||
async function leaveAndGoHome() {
|
||||
await roomStore.leaveRoom(roomId)
|
||||
router.push('/')
|
||||
}
|
||||
</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;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.room-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
74
frontend/src/views/TracksView.vue
Normal file
74
frontend/src/views/TracksView.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="tracks-page">
|
||||
<div class="header-section">
|
||||
<h1>Библиотека треков</h1>
|
||||
<button class="btn-primary" @click="showUpload = true">Загрузить трек</button>
|
||||
</div>
|
||||
|
||||
<div v-if="tracksStore.loading" class="loading">Загрузка...</div>
|
||||
|
||||
<div v-else-if="tracksStore.tracks.length === 0" class="empty">
|
||||
<p>Нет треков. Загрузите первый!</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="tracks-list card">
|
||||
<TrackList
|
||||
:tracks="tracksStore.tracks"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal v-if="showUpload" title="Загрузить трек" @close="showUpload = false">
|
||||
<UploadTrack @uploaded="showUpload = false" />
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useTracksStore } from '../stores/tracks'
|
||||
import TrackList from '../components/tracks/TrackList.vue'
|
||||
import UploadTrack from '../components/tracks/UploadTrack.vue'
|
||||
import Modal from '../components/common/Modal.vue'
|
||||
|
||||
const tracksStore = useTracksStore()
|
||||
|
||||
const showUpload = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
tracksStore.fetchTracks()
|
||||
})
|
||||
|
||||
async function handleDelete(track) {
|
||||
if (confirm(`Удалить трек "${track.title}"?`)) {
|
||||
await tracksStore.deleteTrack(track.id)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tracks-page {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header-section h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tracks-list {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.loading, .empty {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #aaa;
|
||||
}
|
||||
</style>
|
||||
19
frontend/vite.config.js
Normal file
19
frontend/vite.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 4000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:4001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:4001',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user