This commit is contained in:
2025-12-12 13:30:09 +03:00
commit 2f1e1f35e3
75 changed files with 4603 additions and 0 deletions

View 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>

View 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>

View 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>

View 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')">&times;</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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>