Redesign UI with Vuetify and improve configuration
Major changes: - Full UI redesign with Vuetify 3 (dark theme, modern components) - Sidebar navigation with gradient logo - Redesigned player controls with Material Design icons - New room cards, track lists, and filter UI with chips - Modern auth pages with centered cards Configuration improvements: - Centralized all settings in root .env file - Removed redundant backend/.env and frontend/.env files - Increased file upload limit to 100MB (nginx + backend) - Added build args for Vite environment variables - Frontend now uses relative paths (better for domain deployment) UI Components updated: - App.vue: v-navigation-drawer with sidebar - MiniPlayer: v-footer with modern controls - Queue: v-list with styled items - RoomView: improved filters with clickable chips - All views: Vuetify cards, buttons, text fields 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,37 +1,187 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<Header />
|
||||
<main class="main-content" :class="{ 'has-mini-player': activeRoomStore.isInRoom }">
|
||||
<router-view />
|
||||
</main>
|
||||
<v-app>
|
||||
<v-navigation-drawer
|
||||
permanent
|
||||
:width="260"
|
||||
color="surface"
|
||||
class="sidebar"
|
||||
>
|
||||
<div class="sidebar-content">
|
||||
<div class="logo-section">
|
||||
<v-icon size="40" color="primary">mdi-music-circle</v-icon>
|
||||
<h2 class="logo-text">EnigFM</h2>
|
||||
</div>
|
||||
|
||||
<v-list nav class="main-nav">
|
||||
<template v-if="authStore.isAuthenticated">
|
||||
<v-list-item
|
||||
to="/"
|
||||
prepend-icon="mdi-home-variant"
|
||||
title="Комнаты"
|
||||
rounded="xl"
|
||||
/>
|
||||
<v-list-item
|
||||
to="/tracks"
|
||||
prepend-icon="mdi-music-box-multiple"
|
||||
title="Моя библиотека"
|
||||
rounded="xl"
|
||||
/>
|
||||
</template>
|
||||
</v-list>
|
||||
|
||||
<v-spacer />
|
||||
|
||||
<div class="user-section" v-if="authStore.isAuthenticated">
|
||||
<v-divider class="mb-4" />
|
||||
<div class="user-info">
|
||||
<v-avatar color="primary" size="40">
|
||||
<v-icon>mdi-account</v-icon>
|
||||
</v-avatar>
|
||||
<div class="user-details">
|
||||
<div class="username">{{ authStore.user?.username }}</div>
|
||||
<v-btn
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
@click="logout"
|
||||
class="logout-btn"
|
||||
>
|
||||
Выйти
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="auth-section">
|
||||
<v-btn
|
||||
to="/login"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
block
|
||||
class="mb-2"
|
||||
>
|
||||
Войти
|
||||
</v-btn>
|
||||
<v-btn
|
||||
to="/register"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
block
|
||||
>
|
||||
Регистрация
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-navigation-drawer>
|
||||
|
||||
<v-main class="main-area">
|
||||
<div class="content-wrapper" :class="{ 'has-player': activeRoomStore.isInRoom }">
|
||||
<router-view />
|
||||
</div>
|
||||
</v-main>
|
||||
|
||||
<MiniPlayer />
|
||||
</div>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Header from './components/common/Header.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import MiniPlayer from './components/player/MiniPlayer.vue'
|
||||
import { useActiveRoomStore } from './stores/activeRoom'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const activeRoomStore = useActiveRoomStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
function logout() {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
.sidebar {
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
.logo-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #6c63ff 0%, #ff6584 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.main-content.has-mini-player {
|
||||
padding-bottom: 100px;
|
||||
.user-section {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
margin-top: 2px;
|
||||
padding: 0;
|
||||
min-width: auto;
|
||||
height: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.auth-section {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.main-area {
|
||||
background: linear-gradient(135deg, #0a0e27 0%, #151932 100%);
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
padding: 32px;
|
||||
min-height: 100vh;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.content-wrapper.has-player {
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,23 +1,38 @@
|
||||
<template>
|
||||
<div class="chat card">
|
||||
<h3>Чат</h3>
|
||||
<div class="chat">
|
||||
<div class="messages" ref="messagesRef">
|
||||
<ChatMessage
|
||||
v-for="msg in allMessages"
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
/>
|
||||
<div v-if="allMessages.length === 0" class="empty-chat">
|
||||
<v-icon size="48" color="primary" class="mb-2">mdi-message-outline</v-icon>
|
||||
<p class="text-caption text-medium-emphasis">Сообщений пока нет</p>
|
||||
</div>
|
||||
</div>
|
||||
<v-divider />
|
||||
<form @submit.prevent="sendMessage" class="chat-input">
|
||||
<input
|
||||
type="text"
|
||||
<v-text-field
|
||||
v-model="newMessage"
|
||||
placeholder="Написать сообщение..."
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
:disabled="!activeRoomStore.connected"
|
||||
/>
|
||||
<button type="submit" class="btn-primary" :disabled="!newMessage.trim()">
|
||||
Отправить
|
||||
</button>
|
||||
>
|
||||
<template v-slot:append-inner>
|
||||
<v-btn
|
||||
type="submit"
|
||||
icon
|
||||
size="small"
|
||||
color="primary"
|
||||
:disabled="!newMessage.trim()"
|
||||
>
|
||||
<v-icon>mdi-send</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -74,12 +89,7 @@ function scrollToBottom() {
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.chat h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 16px;
|
||||
height: 450px;
|
||||
}
|
||||
|
||||
.messages {
|
||||
@@ -88,20 +98,19 @@ function scrollToBottom() {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding-right: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.empty-chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.chat-input input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat-input button {
|
||||
white-space: nowrap;
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,76 +1,136 @@
|
||||
<template>
|
||||
<div class="mini-player" v-if="activeRoomStore.isInRoom">
|
||||
<!-- Progress bar at top -->
|
||||
<div class="progress-bar-top" @click="handleSeek">
|
||||
<div class="progress-fill" :style="{ width: progressPercent + '%' }"></div>
|
||||
</div>
|
||||
<v-footer
|
||||
app
|
||||
fixed
|
||||
class="mini-player"
|
||||
v-if="activeRoomStore.isInRoom"
|
||||
elevation="12"
|
||||
>
|
||||
<!-- Progress bar -->
|
||||
<v-progress-linear
|
||||
:model-value="progressPercent"
|
||||
height="4"
|
||||
color="primary"
|
||||
class="progress-bar"
|
||||
@click="handleSeek"
|
||||
style="cursor: pointer; position: absolute; top: 0; left: 0; right: 0;"
|
||||
/>
|
||||
|
||||
<div class="mini-player-content">
|
||||
<!-- Track info - left side -->
|
||||
<div class="mini-player-info" @click="goToRoom">
|
||||
<div class="track-title" v-if="currentTrack">{{ currentTrack.title }}</div>
|
||||
<div class="track-title" v-else>Нет трека</div>
|
||||
<div class="track-artist" v-if="currentTrack">{{ currentTrack.artist }}</div>
|
||||
<div class="room-name">{{ activeRoomStore.roomName }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls - center -->
|
||||
<div class="mini-player-controls">
|
||||
<button class="control-btn" @click="handlePrev">
|
||||
<span>⏮</span>
|
||||
</button>
|
||||
<button class="control-btn play-btn" @click="togglePlay">
|
||||
<span>{{ playerStore.isPlaying ? '⏸' : '▶' }}</span>
|
||||
</button>
|
||||
<button class="control-btn" @click="handleNext">
|
||||
<span>⏭</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Right side - time, volume, leave -->
|
||||
<div class="mini-player-right">
|
||||
<span class="time">{{ formatTime(playerStore.position) }} / {{ formatTime(playerStore.duration) }}</span>
|
||||
|
||||
<div class="volume-control">
|
||||
<img
|
||||
v-if="playerStore.volume === 0"
|
||||
src="/speaker-disabled-svgrepo-com.svg"
|
||||
class="volume-icon"
|
||||
@click="toggleMute"
|
||||
/>
|
||||
<img
|
||||
v-else-if="playerStore.volume < 50"
|
||||
src="/speaker-1-svgrepo-com.svg"
|
||||
class="volume-icon"
|
||||
@click="toggleMute"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
src="/speaker-2-svgrepo-com.svg"
|
||||
class="volume-icon"
|
||||
@click="toggleMute"
|
||||
/>
|
||||
<div class="volume-popup">
|
||||
<span class="volume-value">{{ playerStore.volume }}%</span>
|
||||
<div class="volume-slider-wrapper">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
:value="playerStore.volume"
|
||||
@input="handleVolume"
|
||||
class="volume-slider"
|
||||
/>
|
||||
<v-container fluid class="mini-player-content pa-0">
|
||||
<v-row align="center" no-gutters>
|
||||
<!-- Track info - left -->
|
||||
<v-col cols="12" md="3" class="track-info-section">
|
||||
<div class="track-info" @click="goToRoom">
|
||||
<v-avatar
|
||||
size="56"
|
||||
rounded="lg"
|
||||
color="surface-variant"
|
||||
class="mr-3"
|
||||
>
|
||||
<v-icon size="32" color="primary">mdi-music</v-icon>
|
||||
</v-avatar>
|
||||
<div class="track-details">
|
||||
<div class="track-title">
|
||||
{{ currentTrack?.title || 'Нет трека' }}
|
||||
</div>
|
||||
<div class="track-artist" v-if="currentTrack">
|
||||
{{ currentTrack.artist }}
|
||||
</div>
|
||||
<div class="room-name">
|
||||
<v-icon size="12" class="mr-1">mdi-account-group</v-icon>
|
||||
{{ activeRoomStore.roomName }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
<button class="leave-btn" @click="handleLeave" title="Выйти из комнаты">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Controls - center -->
|
||||
<v-col cols="12" md="6" class="controls-section">
|
||||
<div class="player-controls">
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="handlePrev"
|
||||
>
|
||||
<v-icon>mdi-skip-previous</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
icon
|
||||
color="primary"
|
||||
size="large"
|
||||
elevation="2"
|
||||
@click="togglePlay"
|
||||
>
|
||||
<v-icon size="32">
|
||||
{{ playerStore.isPlaying ? 'mdi-pause' : 'mdi-play' }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="handleNext"
|
||||
>
|
||||
<v-icon>mdi-skip-next</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<div class="time-display ml-4">
|
||||
<span class="current-time">{{ formatTime(playerStore.position) }}</span>
|
||||
<span class="time-separator">/</span>
|
||||
<span class="total-time">{{ formatTime(playerStore.duration) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
<!-- Right side - volume, leave -->
|
||||
<v-col cols="12" md="3" class="actions-section">
|
||||
<div class="player-actions">
|
||||
<div class="volume-control">
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="toggleMute"
|
||||
>
|
||||
<v-icon>
|
||||
{{
|
||||
playerStore.volume === 0
|
||||
? 'mdi-volume-mute'
|
||||
: playerStore.volume < 50
|
||||
? 'mdi-volume-low'
|
||||
: 'mdi-volume-high'
|
||||
}}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
<v-slider
|
||||
:model-value="playerStore.volume"
|
||||
@update:model-value="handleVolume"
|
||||
:min="0"
|
||||
:max="100"
|
||||
hide-details
|
||||
color="primary"
|
||||
class="volume-slider"
|
||||
density="compact"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
color="error"
|
||||
@click="handleLeave"
|
||||
title="Выйти из комнаты"
|
||||
>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-footer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -131,8 +191,8 @@ function goToRoom() {
|
||||
|
||||
let previousVolume = 100
|
||||
|
||||
function handleVolume(e) {
|
||||
activeRoomStore.setVolume(Number(e.target.value))
|
||||
function handleVolume(value) {
|
||||
activeRoomStore.setVolume(Number(value))
|
||||
}
|
||||
|
||||
|
||||
@@ -153,245 +213,142 @@ async function handleLeave() {
|
||||
|
||||
<style scoped>
|
||||
.mini-player {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #1a1a2e;
|
||||
background: rgba(21, 25, 50, 0.95) !important;
|
||||
backdrop-filter: blur(20px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding: 16px 24px !important;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.mini-player-content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.mini-player-controls {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
.track-info-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.track-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.track-info:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.track-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.track-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.track-artist {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.room-name {
|
||||
font-size: 12px;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.controls-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.player-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.progress-bar-top {
|
||||
height: 4px;
|
||||
background: #333;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.progress-bar-top:hover {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: #7c3aed;
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
.mini-player-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.track-title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.track-artist {
|
||||
font-size: 13px;
|
||||
color: #aaa;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.room-name {
|
||||
font-size: 11px;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
font-size: 16px;
|
||||
border-radius: 50%;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
background: #7c3aed;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
.time-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.play-btn:hover {
|
||||
background: #6d28d9;
|
||||
.time-separator {
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.mini-player-right {
|
||||
.actions-section {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.player-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
min-width: 80px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.volume-control {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.volume-icon {
|
||||
cursor: pointer;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 8px;
|
||||
filter: invert(60%);
|
||||
transition: filter 0.2s;
|
||||
}
|
||||
|
||||
.volume-icon:hover {
|
||||
filter: invert(100%);
|
||||
}
|
||||
|
||||
.volume-popup {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 40px;
|
||||
height: 120px;
|
||||
margin-bottom: 0;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.volume-value {
|
||||
position: absolute;
|
||||
top: -25px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.volume-control:hover .volume-popup {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.volume-slider-wrapper {
|
||||
width: 8px;
|
||||
height: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100px;
|
||||
height: 8px;
|
||||
background: #444;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transform: rotate(-90deg);
|
||||
transform-origin: center center;
|
||||
max-width: 100px;
|
||||
}
|
||||
|
||||
.volume-slider::-webkit-slider-runnable-track {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #444;
|
||||
border-radius: 4px;
|
||||
.progress-bar {
|
||||
transition: height 0.2s;
|
||||
}
|
||||
|
||||
.volume-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #7c3aed;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
margin-top: -5px;
|
||||
.progress-bar:hover {
|
||||
height: 6px !important;
|
||||
}
|
||||
|
||||
.volume-slider::-moz-range-track {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #444;
|
||||
border-radius: 4px;
|
||||
}
|
||||
@media (max-width: 960px) {
|
||||
.controls-section {
|
||||
order: -1;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.volume-slider::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #7c3aed;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
.track-info-section,
|
||||
.actions-section {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.leave-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
padding: 8px 12px;
|
||||
font-size: 18px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.time-display {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.leave-btn:hover {
|
||||
background: #ff4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.volume-control {
|
||||
display: none;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.time {
|
||||
.volume-slider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mini-player-content {
|
||||
padding: 10px 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
<template>
|
||||
<div class="participants card">
|
||||
<h3>Участники ({{ participants.length }})</h3>
|
||||
<div class="participants-list">
|
||||
<div
|
||||
<div class="participants">
|
||||
<v-list bg-color="transparent">
|
||||
<v-list-item
|
||||
v-for="participant in participants"
|
||||
:key="participant.id"
|
||||
class="participant"
|
||||
class="participant-item"
|
||||
>
|
||||
<div class="avatar">{{ participant.username.charAt(0).toUpperCase() }}</div>
|
||||
<span class="username">{{ participant.username }}</span>
|
||||
</div>
|
||||
<template v-slot:prepend>
|
||||
<v-avatar color="primary" size="36">
|
||||
<span class="text-subtitle-2">{{ participant.username.charAt(0).toUpperCase() }}</span>
|
||||
</v-avatar>
|
||||
</template>
|
||||
|
||||
<v-list-item-title class="participant-name">
|
||||
{{ participant.username }}
|
||||
</v-list-item-title>
|
||||
|
||||
<template v-slot:append>
|
||||
<v-icon size="10" color="success">mdi-circle</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<div v-if="participants.length === 0" class="empty-state">
|
||||
<v-icon size="48" color="primary" class="mb-2">mdi-account-group-outline</v-icon>
|
||||
<p class="text-caption text-medium-emphasis">Нет участников</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -24,38 +39,26 @@ defineProps({
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.participants h3 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 16px;
|
||||
.participants {
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.participants-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
.participant-item {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.participant {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
.participant-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #6c63ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.participant-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 14px;
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,36 +1,67 @@
|
||||
<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"
|
||||
>
|
||||
<span class="queue-index">{{ index + 1 }}</span>
|
||||
<div class="queue-track-info" @click="$emit('play-track', track)">
|
||||
<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>
|
||||
<button class="btn-remove" @click.stop="$emit('remove-track', track)" title="Удалить из очереди">
|
||||
✕
|
||||
</button>
|
||||
<v-list v-if="queue.length > 0" bg-color="transparent">
|
||||
<v-list-item
|
||||
v-for="(item, index) in queue"
|
||||
:key="item.track.id"
|
||||
class="queue-item"
|
||||
@click="$emit('play-track', item.track)"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<div class="queue-index">{{ index + 1 }}</div>
|
||||
</template>
|
||||
|
||||
<v-list-item-title class="queue-track-title">
|
||||
{{ item.track.title }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="queue-track-artist">
|
||||
{{ item.track.artist }}
|
||||
</v-list-item-subtitle>
|
||||
|
||||
<template v-slot:append>
|
||||
<div class="d-flex align-center gap-2">
|
||||
<span class="queue-duration">{{ formatDuration(item.track.duration) }}</span>
|
||||
<v-btn
|
||||
v-if="canRemove(item)"
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="error"
|
||||
@click.stop="$emit('remove-track', item.track)"
|
||||
>
|
||||
<v-icon size="20">mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<div v-else class="empty-queue">
|
||||
<v-icon size="64" color="primary" class="mb-2">mdi-playlist-music-outline</v-icon>
|
||||
<p class="text-medium-emphasis">Очередь пуста</p>
|
||||
<p class="text-caption">Добавьте треки для прослушивания</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
queue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
currentUserId: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['play-track', 'remove-track'])
|
||||
|
||||
function canRemove(item) {
|
||||
return props.currentUserId && item.added_by === props.currentUserId
|
||||
}
|
||||
|
||||
function formatDuration(ms) {
|
||||
const seconds = Math.floor(ms / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
@@ -41,76 +72,55 @@ function formatDuration(ms) {
|
||||
|
||||
<style scoped>
|
||||
.queue {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.empty-queue {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
padding: 20px;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.queue-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.queue-item:hover {
|
||||
background: #2d2d44;
|
||||
background: rgba(255, 255, 255, 0.05) !important;
|
||||
}
|
||||
|
||||
.queue-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.queue-index {
|
||||
color: #666;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
background: rgba(108, 99, 255, 0.1);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
font-size: 14px;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.queue-track-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-weight: 600;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.queue-track-title {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.queue-track-artist {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.queue-duration {
|
||||
color: #aaa;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-remove:hover {
|
||||
background: #ff4444;
|
||||
color: white;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 13px;
|
||||
min-width: 50px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,53 @@
|
||||
<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>
|
||||
<v-card
|
||||
class="room-card"
|
||||
elevation="2"
|
||||
hover
|
||||
>
|
||||
<div class="card-gradient" />
|
||||
<v-card-text class="pa-6">
|
||||
<div class="d-flex align-center mb-4">
|
||||
<v-avatar
|
||||
size="56"
|
||||
color="primary"
|
||||
class="mr-4"
|
||||
>
|
||||
<v-icon size="32">mdi-music-circle-outline</v-icon>
|
||||
</v-avatar>
|
||||
<div class="flex-grow-1">
|
||||
<h3 class="room-title">{{ room.name }}</h3>
|
||||
<div class="room-meta">
|
||||
<v-chip
|
||||
size="small"
|
||||
variant="flat"
|
||||
color="surface-variant"
|
||||
prepend-icon="mdi-account-group"
|
||||
class="mr-2"
|
||||
>
|
||||
{{ room.participants_count }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="room.is_playing"
|
||||
size="small"
|
||||
variant="flat"
|
||||
color="success"
|
||||
prepend-icon="mdi-play-circle"
|
||||
class="playing-chip"
|
||||
>
|
||||
Играет
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-divider class="my-3" />
|
||||
|
||||
<div class="room-actions">
|
||||
<v-icon size="20" color="primary" class="mr-2">mdi-arrow-right-circle</v-icon>
|
||||
<span class="action-text">Присоединиться</span>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -20,45 +62,74 @@ defineProps({
|
||||
<style scoped>
|
||||
.room-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, border-color 0.2s;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.02) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.room-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: #6c63ff;
|
||||
transform: translateY(-4px);
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
box-shadow: 0 8px 24px rgba(108, 99, 255, 0.2) !important;
|
||||
}
|
||||
|
||||
.room-card h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 18px;
|
||||
.room-card:hover .card-gradient {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.room-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: #aaa;
|
||||
font-size: 14px;
|
||||
.room-card:hover .action-text {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.playing {
|
||||
color: #2ed573;
|
||||
.card-gradient {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, #6c63ff, #ff6584);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.room-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.room-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.playing::before {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #2ed573;
|
||||
border-radius: 50%;
|
||||
animation: pulse 1s infinite;
|
||||
.room-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
.action-text {
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.playing-chip {
|
||||
animation: pulse-glow 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
v-if="!selectable && !multiSelect"
|
||||
v-if="!selectable && !multiSelect && isOwnTrack"
|
||||
class="btn-danger delete-btn"
|
||||
@click.stop="$emit('delete')"
|
||||
>
|
||||
@@ -31,6 +31,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
track: {
|
||||
type: Object,
|
||||
@@ -51,9 +53,17 @@ const props = defineProps({
|
||||
inQueue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
currentUserId: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const isOwnTrack = computed(() => {
|
||||
return props.currentUserId && props.track.uploaded_by === props.currentUserId
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select', 'toggle-select', 'delete'])
|
||||
|
||||
function handleClick() {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
:multi-select="multiSelect"
|
||||
:is-selected="selectedTrackIds.includes(track.id)"
|
||||
:in-queue="queueTrackIds.includes(track.id)"
|
||||
:current-user-id="currentUserId"
|
||||
@select="$emit('select', track)"
|
||||
@toggle-select="$emit('toggle-select', track.id)"
|
||||
@delete="$emit('delete', track)"
|
||||
@@ -41,6 +42,10 @@ defineProps({
|
||||
selectedTrackIds: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
currentUserId: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -2,11 +2,13 @@ import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import vuetify from './plugins/vuetify'
|
||||
import './assets/styles/main.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(vuetify)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
29
frontend/src/plugins/vuetify.js
Normal file
29
frontend/src/plugins/vuetify.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { createVuetify } from 'vuetify'
|
||||
import * as components from 'vuetify/components'
|
||||
import * as directives from 'vuetify/directives'
|
||||
import 'vuetify/styles'
|
||||
import '@mdi/font/css/materialdesignicons.css'
|
||||
|
||||
export default createVuetify({
|
||||
components,
|
||||
directives,
|
||||
theme: {
|
||||
defaultTheme: 'dark',
|
||||
themes: {
|
||||
dark: {
|
||||
dark: true,
|
||||
colors: {
|
||||
background: '#0a0e27',
|
||||
surface: '#151932',
|
||||
primary: '#6c63ff',
|
||||
secondary: '#ff6584',
|
||||
accent: '#00d9ff',
|
||||
error: '#ff4444',
|
||||
info: '#2196F3',
|
||||
success: '#4CAF50',
|
||||
warning: '#FB8C00',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,17 +1,41 @@
|
||||
<template>
|
||||
<div class="home">
|
||||
<div class="header-section">
|
||||
<h1>Комнаты</h1>
|
||||
<button v-if="authStore.isAuthenticated" class="btn-primary" @click="showCreateModal = true">
|
||||
<div>
|
||||
<h1 class="page-title">Комнаты</h1>
|
||||
<p class="page-subtitle">Присоединяйтесь к комнатам и слушайте музыку вместе</p>
|
||||
</div>
|
||||
<v-btn
|
||||
v-if="authStore.isAuthenticated"
|
||||
color="primary"
|
||||
size="large"
|
||||
prepend-icon="mdi-plus"
|
||||
@click="showCreateModal = true"
|
||||
elevation="2"
|
||||
>
|
||||
Создать комнату
|
||||
</button>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Загрузка...</div>
|
||||
<v-progress-circular
|
||||
v-if="loading"
|
||||
indeterminate
|
||||
color="primary"
|
||||
size="64"
|
||||
class="loading-spinner"
|
||||
/>
|
||||
|
||||
<div v-else-if="roomStore.rooms.length === 0" class="empty">
|
||||
<p>Пока нет комнат. Создайте первую!</p>
|
||||
</div>
|
||||
<v-card
|
||||
v-else-if="roomStore.rooms.length === 0"
|
||||
class="empty-state"
|
||||
elevation="0"
|
||||
>
|
||||
<v-card-text class="text-center">
|
||||
<v-icon size="80" color="primary" class="mb-4">mdi-music-note-off</v-icon>
|
||||
<h3>Пока нет комнат</h3>
|
||||
<p class="text-medium-emphasis">Создайте первую комнату и начните слушать музыку с друзьями!</p>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<div v-else class="rooms-grid">
|
||||
<RoomCard
|
||||
@@ -22,17 +46,45 @@
|
||||
/>
|
||||
</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>
|
||||
<v-dialog v-model="showCreateModal" max-width="500">
|
||||
<v-card>
|
||||
<v-card-title class="text-h5">
|
||||
<v-icon class="mr-2">mdi-music-circle</v-icon>
|
||||
Создать комнату
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-form @submit.prevent="createRoom">
|
||||
<v-text-field
|
||||
v-model="newRoomName"
|
||||
label="Название комнаты"
|
||||
placeholder="Моя музыкальная комната"
|
||||
variant="outlined"
|
||||
required
|
||||
autofocus
|
||||
:disabled="creating"
|
||||
/>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
variant="text"
|
||||
@click="showCreateModal = false"
|
||||
:disabled="creating"
|
||||
>
|
||||
Отмена
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="createRoom"
|
||||
:loading="creating"
|
||||
>
|
||||
Создать
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -42,7 +94,6 @@ 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()
|
||||
@@ -81,29 +132,59 @@ function goToRoom(roomId) {
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
padding-top: 20px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 32px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.header-section h1 {
|
||||
.page-title {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px 0;
|
||||
background: linear-gradient(135deg, #fff 0%, rgba(255, 255, 255, 0.8) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 16px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rooms-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.loading, .empty {
|
||||
text-align: center;
|
||||
.loading-spinner {
|
||||
display: block;
|
||||
margin: 80px auto;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
background: rgba(255, 255, 255, 0.02) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding: 40px;
|
||||
color: #aaa;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.header-section {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.rooms-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,25 +1,76 @@
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="auth-card card">
|
||||
<h2>Вход</h2>
|
||||
<form @submit.prevent="handleLogin">
|
||||
<div class="form-group">
|
||||
<label>Имя пользователя</label>
|
||||
<input type="text" v-model="username" required />
|
||||
<v-card class="auth-card" elevation="8">
|
||||
<div class="auth-header">
|
||||
<v-avatar size="64" color="primary" class="mb-4">
|
||||
<v-icon size="40">mdi-music-circle</v-icon>
|
||||
</v-avatar>
|
||||
<h2 class="auth-title">Добро пожаловать</h2>
|
||||
<p class="auth-subtitle">Войдите в свой аккаунт EnigFM</p>
|
||||
</div>
|
||||
|
||||
<v-card-text class="pt-8">
|
||||
<v-form @submit.prevent="handleLogin">
|
||||
<v-text-field
|
||||
v-model="username"
|
||||
label="Имя пользователя"
|
||||
prepend-inner-icon="mdi-account"
|
||||
variant="outlined"
|
||||
required
|
||||
:disabled="loading"
|
||||
autofocus
|
||||
class="mb-2"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="password"
|
||||
label="Пароль"
|
||||
prepend-inner-icon="mdi-lock"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
required
|
||||
:disabled="loading"
|
||||
class="mb-2"
|
||||
/>
|
||||
|
||||
<v-alert
|
||||
v-if="error"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
closable
|
||||
@click:close="error = ''"
|
||||
>
|
||||
{{ error }}
|
||||
</v-alert>
|
||||
|
||||
<v-btn
|
||||
type="submit"
|
||||
color="primary"
|
||||
size="large"
|
||||
block
|
||||
:loading="loading"
|
||||
class="mb-4"
|
||||
>
|
||||
Войти
|
||||
</v-btn>
|
||||
</v-form>
|
||||
|
||||
<v-divider class="my-6" />
|
||||
|
||||
<div class="text-center">
|
||||
<span class="text-medium-emphasis">Нет аккаунта?</span>
|
||||
<v-btn
|
||||
to="/register"
|
||||
variant="text"
|
||||
color="primary"
|
||||
class="ml-2"
|
||||
>
|
||||
Зарегистрироваться
|
||||
</v-btn>
|
||||
</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>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -56,27 +107,30 @@ async function handleLogin() {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: calc(100vh - 100px);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
max-width: 450px;
|
||||
background: rgba(21, 25, 50, 0.95) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.auth-card h2 {
|
||||
margin-bottom: 24px;
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
padding: 40px 40px 0;
|
||||
}
|
||||
|
||||
.auth-card button {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
.auth-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.auth-link {
|
||||
text-align: center;
|
||||
margin-top: 16px;
|
||||
color: #aaa;
|
||||
.auth-subtitle {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 15px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,25 +1,78 @@
|
||||
<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 />
|
||||
<v-card class="auth-card" elevation="8">
|
||||
<div class="auth-header">
|
||||
<v-avatar size="64" color="primary" class="mb-4">
|
||||
<v-icon size="40">mdi-music-circle</v-icon>
|
||||
</v-avatar>
|
||||
<h2 class="auth-title">Создать аккаунт</h2>
|
||||
<p class="auth-subtitle">Присоединяйтесь к EnigFM</p>
|
||||
</div>
|
||||
|
||||
<v-card-text class="pt-8">
|
||||
<v-form @submit.prevent="handleRegister">
|
||||
<v-text-field
|
||||
v-model="username"
|
||||
label="Имя пользователя"
|
||||
prepend-inner-icon="mdi-account"
|
||||
variant="outlined"
|
||||
required
|
||||
:disabled="loading"
|
||||
autofocus
|
||||
class="mb-2"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="password"
|
||||
label="Пароль"
|
||||
prepend-inner-icon="mdi-lock"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
required
|
||||
:disabled="loading"
|
||||
:rules="[v => v.length >= 6 || 'Минимум 6 символов']"
|
||||
counter
|
||||
class="mb-2"
|
||||
/>
|
||||
|
||||
<v-alert
|
||||
v-if="error"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
closable
|
||||
@click:close="error = ''"
|
||||
>
|
||||
{{ error }}
|
||||
</v-alert>
|
||||
|
||||
<v-btn
|
||||
type="submit"
|
||||
color="primary"
|
||||
size="large"
|
||||
block
|
||||
:loading="loading"
|
||||
class="mb-4"
|
||||
>
|
||||
Зарегистрироваться
|
||||
</v-btn>
|
||||
</v-form>
|
||||
|
||||
<v-divider class="my-6" />
|
||||
|
||||
<div class="text-center">
|
||||
<span class="text-medium-emphasis">Уже есть аккаунт?</span>
|
||||
<v-btn
|
||||
to="/login"
|
||||
variant="text"
|
||||
color="primary"
|
||||
class="ml-2"
|
||||
>
|
||||
Войти
|
||||
</v-btn>
|
||||
</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>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -56,27 +109,30 @@ async function handleRegister() {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: calc(100vh - 100px);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
max-width: 450px;
|
||||
background: rgba(21, 25, 50, 0.95) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.auth-card h2 {
|
||||
margin-bottom: 24px;
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
padding: 40px 40px 0;
|
||||
}
|
||||
|
||||
.auth-card button {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
.auth-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.auth-link {
|
||||
text-align: center;
|
||||
margin-top: 16px;
|
||||
color: #aaa;
|
||||
.auth-subtitle {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 15px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,88 +1,214 @@
|
||||
<template>
|
||||
<div class="room-page" v-if="room">
|
||||
<div class="room-header">
|
||||
<h1>{{ room.name }}</h1>
|
||||
<div>
|
||||
<h1 class="page-title">{{ room.name }}</h1>
|
||||
<p class="page-subtitle">Комната для совместного прослушивания музыки</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="room-layout">
|
||||
<div class="main-section">
|
||||
<div class="queue-section card">
|
||||
<div class="queue-header">
|
||||
<h3>Очередь</h3>
|
||||
<button class="btn-secondary" @click="showAddTrack = true">Добавить</button>
|
||||
<v-row class="room-layout">
|
||||
<v-col cols="12" lg="8">
|
||||
<v-card class="queue-card" elevation="0">
|
||||
<v-card-title class="d-flex justify-space-between align-center">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon class="mr-2" color="primary">mdi-playlist-music</v-icon>
|
||||
<span>Очередь треков</span>
|
||||
</div>
|
||||
<v-btn
|
||||
color="primary"
|
||||
prepend-icon="mdi-plus"
|
||||
@click="showAddTrack = true"
|
||||
>
|
||||
Добавить
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text class="pa-0">
|
||||
<Queue
|
||||
:queue="roomStore.queue"
|
||||
:current-user-id="authStore.user?.id"
|
||||
@play-track="playTrack"
|
||||
@remove-track="removeFromQueue"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" lg="4">
|
||||
<v-card class="participants-card mb-4" elevation="0">
|
||||
<v-card-title class="d-flex align-center">
|
||||
<v-icon class="mr-2" color="primary">mdi-account-group</v-icon>
|
||||
<span>Участники</span>
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text>
|
||||
<ParticipantsList :participants="roomStore.participants" />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-card class="chat-card" elevation="0">
|
||||
<v-card-title class="d-flex align-center">
|
||||
<v-icon class="mr-2" color="primary">mdi-chat</v-icon>
|
||||
<span>Чат</span>
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text class="pa-0">
|
||||
<ChatWindow :room-id="roomId" />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-dialog v-model="showAddTrack" max-width="900" scrollable>
|
||||
<v-card>
|
||||
<v-card-title class="text-h5">
|
||||
<v-icon class="mr-2">mdi-playlist-plus</v-icon>
|
||||
Добавить в очередь
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text class="pa-6">
|
||||
<!-- Поиск -->
|
||||
<v-row class="mb-4" dense>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="searchTitle"
|
||||
label="Поиск по названию"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
clearable
|
||||
bg-color="surface"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="searchArtist"
|
||||
label="Поиск по артисту"
|
||||
prepend-inner-icon="mdi-account-music"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
clearable
|
||||
bg-color="surface"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Фильтры и действия -->
|
||||
<div class="filters-bar mb-4">
|
||||
<div class="filters-chips">
|
||||
<v-chip
|
||||
:color="filterMyTracks ? 'primary' : 'default'"
|
||||
:variant="filterMyTracks ? 'flat' : 'outlined'"
|
||||
@click="filterMyTracks = !filterMyTracks"
|
||||
class="filter-chip"
|
||||
>
|
||||
<v-icon start>mdi-account</v-icon>
|
||||
Мои треки
|
||||
</v-chip>
|
||||
|
||||
<v-chip
|
||||
:color="filterNotInQueue ? 'primary' : 'default'"
|
||||
:variant="filterNotInQueue ? 'flat' : 'outlined'"
|
||||
@click="filterNotInQueue = !filterNotInQueue"
|
||||
class="filter-chip"
|
||||
>
|
||||
<v-icon start>mdi-playlist-remove</v-icon>
|
||||
Не в очереди
|
||||
</v-chip>
|
||||
|
||||
<v-divider vertical class="mx-2" />
|
||||
|
||||
<v-chip
|
||||
variant="text"
|
||||
prepend-icon="mdi-music-note"
|
||||
size="default"
|
||||
>
|
||||
Найдено: <strong class="ml-1">{{ filteredTracks.length }}</strong>
|
||||
</v-chip>
|
||||
|
||||
<v-chip
|
||||
v-if="selectedTracks.length > 0"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
prepend-icon="mdi-check-circle"
|
||||
>
|
||||
Выбрано: <strong class="ml-1">{{ selectedTracks.length }}</strong>
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<div class="filters-actions">
|
||||
<v-btn
|
||||
v-if="selectedTracks.length > 0"
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="clearSelection"
|
||||
prepend-icon="mdi-close"
|
||||
>
|
||||
Очистить
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
color="primary"
|
||||
:disabled="selectedTracks.length === 0"
|
||||
@click="addSelectedTracks"
|
||||
prepend-icon="mdi-playlist-plus"
|
||||
size="large"
|
||||
>
|
||||
Добавить ({{ selectedTracks.length }})
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
<Queue :queue="roomStore.queue" @play-track="playTrack" @remove-track="removeFromQueue" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="side-section">
|
||||
<ParticipantsList :participants="roomStore.participants" />
|
||||
<ChatWindow :room-id="roomId" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal v-if="showAddTrack" title="Добавить в очередь" @close="closeAddTrackModal">
|
||||
<div class="filters-section">
|
||||
<div class="search-filters">
|
||||
<input
|
||||
v-model="searchTitle"
|
||||
type="text"
|
||||
placeholder="Поиск по названию..."
|
||||
class="search-input"
|
||||
/>
|
||||
<input
|
||||
v-model="searchArtist"
|
||||
type="text"
|
||||
placeholder="Поиск по артисту..."
|
||||
class="search-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="checkbox-filters">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="filterMyTracks" />
|
||||
<span>Мои треки</span>
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="filterNotInQueue" />
|
||||
<span>Не добавленные в комнату</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="add-track-controls">
|
||||
<div class="selection-info">
|
||||
<span>Найдено: {{ filteredTracks.length }}</span>
|
||||
<span v-if="selectedTracks.length > 0" class="selected-count">
|
||||
Выбрано: {{ selectedTracks.length }}
|
||||
</span>
|
||||
<button
|
||||
v-if="selectedTracks.length > 0"
|
||||
class="btn-text"
|
||||
@click="clearSelection"
|
||||
<v-alert
|
||||
v-if="addTrackError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
closable
|
||||
class="mb-4"
|
||||
@click:close="addTrackError = ''"
|
||||
>
|
||||
Очистить
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="btn-primary"
|
||||
:disabled="selectedTracks.length === 0"
|
||||
@click="addSelectedTracks"
|
||||
>
|
||||
Добавить выбранные ({{ selectedTracks.length }})
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="addTrackError" class="error-message">{{ addTrackError }}</p>
|
||||
<p v-if="addTrackSuccess" class="success-message">{{ addTrackSuccess }}</p>
|
||||
<TrackList
|
||||
:tracks="filteredTracks"
|
||||
:queue-track-ids="queueTrackIds"
|
||||
:selected-track-ids="selectedTracks"
|
||||
multi-select
|
||||
@toggle-select="toggleTrackSelection"
|
||||
/>
|
||||
</Modal>
|
||||
{{ addTrackError }}
|
||||
</v-alert>
|
||||
|
||||
<v-alert
|
||||
v-if="addTrackSuccess"
|
||||
type="success"
|
||||
variant="tonal"
|
||||
closable
|
||||
class="mb-4"
|
||||
@click:close="addTrackSuccess = ''"
|
||||
>
|
||||
{{ addTrackSuccess }}
|
||||
</v-alert>
|
||||
|
||||
<TrackList
|
||||
:tracks="filteredTracks"
|
||||
:queue-track-ids="queueTrackIds"
|
||||
:selected-track-ids="selectedTracks"
|
||||
multi-select
|
||||
@toggle-select="toggleTrackSelection"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-divider />
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="closeAddTrackModal">
|
||||
Закрыть
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
<div v-else class="loading-container">
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
color="primary"
|
||||
size="64"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="loading">Загрузка...</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -118,7 +244,7 @@ const filterMyTracks = ref(false)
|
||||
const filterNotInQueue = ref(false)
|
||||
|
||||
const queueTrackIds = computed(() => {
|
||||
return roomStore.queue.map(track => track.id)
|
||||
return roomStore.queue.map(item => item.track.id)
|
||||
})
|
||||
|
||||
const filteredTracks = computed(() => {
|
||||
@@ -232,166 +358,95 @@ async function removeFromQueue(track) {
|
||||
|
||||
<style scoped>
|
||||
.room-page {
|
||||
padding-top: 20px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.room-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.room-header h1 {
|
||||
.page-title {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px 0;
|
||||
background: linear-gradient(135deg, #fff 0%, rgba(255, 255, 255, 0.8) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 16px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.room-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 350px;
|
||||
gap: 20px;
|
||||
.queue-card,
|
||||
.participants-card,
|
||||
.chat-card {
|
||||
background: rgba(255, 255, 255, 0.02) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.main-section {
|
||||
.loading-container {
|
||||
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;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.queue-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.filters-section {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
background: #1a1a1a;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.search-filters {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 10px 12px;
|
||||
background: #252525;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: var(--color-primary, #1db954);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.checkbox-filters {
|
||||
.filters-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
color: #ccc;
|
||||
font-size: 14px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--color-primary, #1db954);
|
||||
}
|
||||
|
||||
.checkbox-label:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.add-track-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #333;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.selection-info {
|
||||
.filters-chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #aaa;
|
||||
font-size: 14px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.selected-count {
|
||||
color: var(--color-primary, #1db954);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary, #1db954);
|
||||
.filter-chip {
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 4px 8px;
|
||||
text-decoration: underline;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-text:hover {
|
||||
opacity: 0.8;
|
||||
.filter-chip:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(108, 99, 255, 0.3);
|
||||
}
|
||||
|
||||
.success-message {
|
||||
color: #4caf50;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
margin: 8px 0;
|
||||
.filters-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.room-layout {
|
||||
grid-template-columns: 1fr;
|
||||
@media (max-width: 768px) {
|
||||
.filters-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.filters-chips {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.filters-actions {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filters-actions .v-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,63 +1,179 @@
|
||||
<template>
|
||||
<div class="tracks-page">
|
||||
<div class="header-section">
|
||||
<h1>Библиотека треков</h1>
|
||||
<button class="btn-primary" @click="showUpload = true">Загрузить трек</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-tabs">
|
||||
<button
|
||||
:class="['filter-tab', { active: !showMyOnly }]"
|
||||
@click="setFilter(false)"
|
||||
<div>
|
||||
<h1 class="page-title">Моя библиотека</h1>
|
||||
<p class="page-subtitle">Управляйте своей музыкальной коллекцией</p>
|
||||
</div>
|
||||
<v-btn
|
||||
color="primary"
|
||||
size="large"
|
||||
prepend-icon="mdi-upload"
|
||||
@click="showUpload = true"
|
||||
elevation="2"
|
||||
>
|
||||
Все треки
|
||||
</button>
|
||||
<button
|
||||
:class="['filter-tab', { active: showMyOnly }]"
|
||||
@click="setFilter(true)"
|
||||
>
|
||||
Мои треки
|
||||
</button>
|
||||
Загрузить трек
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="tracksStore.loading" class="loading">Загрузка...</div>
|
||||
<v-card class="filters-card mb-6" elevation="0">
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="12" md="5">
|
||||
<v-text-field
|
||||
v-model="searchTitle"
|
||||
label="Поиск по названию"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
clearable
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="5">
|
||||
<v-text-field
|
||||
v-model="searchArtist"
|
||||
label="Поиск по артисту"
|
||||
prepend-inner-icon="mdi-account-music"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
clearable
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="2">
|
||||
<v-checkbox
|
||||
v-model="filterMyTracks"
|
||||
label="Мои треки"
|
||||
color="primary"
|
||||
hide-details
|
||||
density="comfortable"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-divider class="my-4" />
|
||||
<div class="results-info">
|
||||
<v-icon size="20" color="primary" class="mr-2">mdi-music-note</v-icon>
|
||||
<span class="result-text">Найдено: <strong>{{ filteredTracks.length }}</strong> треков</span>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<div v-else-if="tracksStore.tracks.length === 0" class="empty">
|
||||
<p>Нет треков. Загрузите первый!</p>
|
||||
</div>
|
||||
<v-progress-circular
|
||||
v-if="tracksStore.loading"
|
||||
indeterminate
|
||||
color="primary"
|
||||
size="64"
|
||||
class="loading-spinner"
|
||||
/>
|
||||
|
||||
<div v-else class="tracks-list card">
|
||||
<v-card
|
||||
v-else-if="filteredTracks.length === 0 && tracksStore.tracks.length > 0"
|
||||
class="empty-state"
|
||||
elevation="0"
|
||||
>
|
||||
<v-card-text class="text-center">
|
||||
<v-icon size="80" color="warning" class="mb-4">mdi-file-search</v-icon>
|
||||
<h3>Не найдено треков</h3>
|
||||
<p class="text-medium-emphasis">Попробуйте изменить фильтры поиска</p>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-card
|
||||
v-else-if="tracksStore.tracks.length === 0"
|
||||
class="empty-state"
|
||||
elevation="0"
|
||||
>
|
||||
<v-card-text class="text-center">
|
||||
<v-icon size="80" color="primary" class="mb-4">mdi-music-note-plus</v-icon>
|
||||
<h3>Библиотека пуста</h3>
|
||||
<p class="text-medium-emphasis">Загрузите первый трек в свою коллекцию!</p>
|
||||
<v-btn
|
||||
color="primary"
|
||||
size="large"
|
||||
prepend-icon="mdi-upload"
|
||||
@click="showUpload = true"
|
||||
class="mt-4"
|
||||
>
|
||||
Загрузить трек
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-card v-else class="tracks-list-card" elevation="0">
|
||||
<TrackList
|
||||
:tracks="tracksStore.tracks"
|
||||
:tracks="filteredTracks"
|
||||
:current-user-id="authStore.user?.id"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<Modal v-if="showUpload" title="Загрузить трек" @close="showUpload = false">
|
||||
<UploadTrack @uploaded="showUpload = false" />
|
||||
</Modal>
|
||||
<v-dialog v-model="showUpload" max-width="600">
|
||||
<v-card>
|
||||
<v-card-title class="text-h5">
|
||||
<v-icon class="mr-2">mdi-upload</v-icon>
|
||||
Загрузить треки
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<UploadTrack @uploaded="handleUploadComplete" />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useTracksStore } from '../stores/tracks'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
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 authStore = useAuthStore()
|
||||
|
||||
const showUpload = ref(false)
|
||||
const showMyOnly = ref(false)
|
||||
|
||||
// Filters
|
||||
const searchTitle = ref('')
|
||||
const searchArtist = ref('')
|
||||
const filterMyTracks = ref(false)
|
||||
|
||||
const filteredTracks = computed(() => {
|
||||
let tracks = tracksStore.tracks
|
||||
|
||||
// Filter by title
|
||||
if (searchTitle.value.trim()) {
|
||||
const searchLower = searchTitle.value.toLowerCase()
|
||||
tracks = tracks.filter(track =>
|
||||
track.title.toLowerCase().includes(searchLower)
|
||||
)
|
||||
}
|
||||
|
||||
// Filter by artist
|
||||
if (searchArtist.value.trim()) {
|
||||
const searchLower = searchArtist.value.toLowerCase()
|
||||
tracks = tracks.filter(track =>
|
||||
track.artist.toLowerCase().includes(searchLower)
|
||||
)
|
||||
}
|
||||
|
||||
// Filter my tracks
|
||||
if (filterMyTracks.value) {
|
||||
const currentUserId = authStore.user?.id
|
||||
tracks = tracks.filter(track => track.uploaded_by === currentUserId)
|
||||
}
|
||||
|
||||
return tracks
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
tracksStore.fetchTracks()
|
||||
})
|
||||
|
||||
function setFilter(myOnly) {
|
||||
showMyOnly.value = myOnly
|
||||
tracksStore.fetchTracks(myOnly)
|
||||
function handleUploadComplete() {
|
||||
showUpload.value = false
|
||||
tracksStore.fetchTracks()
|
||||
}
|
||||
|
||||
async function handleDelete(track) {
|
||||
@@ -69,52 +185,71 @@ async function handleDelete(track) {
|
||||
|
||||
<style scoped>
|
||||
.tracks-page {
|
||||
padding-top: 20px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 32px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.header-section h1 {
|
||||
.page-title {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px 0;
|
||||
background: linear-gradient(135deg, #fff 0%, rgba(255, 255, 255, 0.8) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 16px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tracks-list {
|
||||
.filters-card {
|
||||
background: rgba(255, 255, 255, 0.02) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.results-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.result-text strong {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.tracks-list-card {
|
||||
background: rgba(255, 255, 255, 0.02) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.loading, .empty {
|
||||
text-align: center;
|
||||
.loading-spinner {
|
||||
display: block;
|
||||
margin: 80px auto;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
background: rgba(255, 255, 255, 0.02) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding: 40px;
|
||||
color: #aaa;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
padding: 8px 16px;
|
||||
background: #333;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
color: #aaa;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-tab:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
.filter-tab.active {
|
||||
background: var(--color-primary, #1db954);
|
||||
color: #fff;
|
||||
@media (max-width: 600px) {
|
||||
.header-section {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user