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:
2025-12-19 20:17:52 +03:00
parent 8a2ea5b4af
commit ee8d79d155
26 changed files with 1498 additions and 833 deletions

View File

@@ -1,3 +0,0 @@
VITE_API_URL=http://localhost:4001
VITE_WS_URL=ws://localhost:4001
VITE_MAX_FILE_SIZE_MB=10

View File

@@ -3,6 +3,12 @@ FROM node:20-alpine as build
WORKDIR /app
# Build arguments (from docker-compose)
ARG VITE_MAX_FILE_SIZE_MB
# Set as env variables for Vite build
ENV VITE_MAX_FILE_SIZE_MB=${VITE_MAX_FILE_SIZE_MB}
COPY package*.json ./
RUN npm install

View File

@@ -5,7 +5,7 @@ server {
index index.html;
# Max upload size
client_max_body_size 30M;
client_max_body_size 100M;
# Gzip compression
gzip on;
@@ -27,6 +27,9 @@ server {
proxy_buffering off;
proxy_cache off;
proxy_request_buffering off;
# Max upload size for API
client_max_body_size 100M;
}
# WebSocket proxy

View File

@@ -12,10 +12,13 @@
"vue": "^3.4.15",
"vue-router": "^4.2.5",
"pinia": "^2.1.7",
"axios": "^1.6.5"
"axios": "^1.6.5",
"vuetify": "^3.5.0",
"@mdi/font": "^7.4.47"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.3",
"vite": "^5.0.11"
"vite": "^5.0.11",
"vite-plugin-vuetify": "^2.0.1"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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() {

View File

@@ -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
}
})

View File

@@ -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')

View 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',
},
},
},
},
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,12 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vuetify from 'vite-plugin-vuetify'
export default defineConfig({
plugins: [vue()],
plugins: [
vue(),
vuetify({ autoImport: true })
],
server: {
port: 4000,
proxy: {