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

@@ -14,5 +14,11 @@ S3_BUCKET_NAME=enigfm
S3_REGION=ru-1
# Limits
MAX_FILE_SIZE_MB=10
MAX_FILE_SIZE_MB=100
MAX_STORAGE_GB=90
MAX_ROOM_PARTICIPANTS=50
# Frontend (Vite)
# VITE_API_URL - оставляем пустым для использования относительных путей
# VITE_WS_URL - оставляем пустым для автоопределения
VITE_MAX_FILE_SIZE_MB=100

View File

@@ -1,17 +0,0 @@
# Database
DATABASE_URL=postgresql://postgres:postgres@localhost:4002/enigfm
# JWT
SECRET_KEY=your-secret-key-change-in-production
# S3 (FirstVDS)
S3_ENDPOINT_URL=https://s3.firstvds.ru
S3_ACCESS_KEY=your-access-key
S3_SECRET_KEY=your-secret-key
S3_BUCKET_NAME=enigfm
S3_REGION=ru-1
# Limits
MAX_FILE_SIZE_MB=10
MAX_STORAGE_GB=90
MAX_ROOM_PARTICIPANTS=50

View File

@@ -24,7 +24,7 @@ class Settings(BaseSettings):
max_room_participants: int = 50
class Config:
env_file = ".env"
# env_file не нужен - переменные передаются через docker-compose
extra = "ignore"

View File

@@ -7,7 +7,7 @@ from ..database import get_db
from ..models.user import User
from ..models.room import Room, RoomParticipant
from ..models.track import RoomQueue
from ..schemas.room import RoomCreate, RoomResponse, RoomDetailResponse, QueueAdd, QueueAddMultiple
from ..schemas.room import RoomCreate, RoomResponse, RoomDetailResponse, QueueAdd, QueueAddMultiple, QueueItemResponse
from ..schemas.track import TrackResponse
from ..schemas.user import UserResponse
from ..services.auth import get_current_user
@@ -178,7 +178,7 @@ async def leave_room(
return {"status": "left"}
@router.get("/{room_id}/queue", response_model=list[TrackResponse])
@router.get("/{room_id}/queue", response_model=list[QueueItemResponse])
async def get_queue(room_id: UUID, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(RoomQueue)
@@ -187,7 +187,13 @@ async def get_queue(room_id: UUID, db: AsyncSession = Depends(get_db)):
.order_by(RoomQueue.position)
)
queue_items = result.scalars().all()
return [TrackResponse.model_validate(item.track) for item in queue_items]
return [
QueueItemResponse(
track=TrackResponse.model_validate(item.track),
added_by=item.added_by
)
for item in queue_items
]
@router.post("/{room_id}/queue")
@@ -314,7 +320,19 @@ async def remove_from_queue(
)
queue_item = result.scalar_one_or_none()
if queue_item:
if not queue_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Track not in queue"
)
# Check if user added this track to queue
if queue_item.added_by != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Can only remove tracks you added"
)
await db.delete(queue_item)
# Notify others

View File

@@ -49,3 +49,16 @@ class QueueAdd(BaseModel):
class QueueAddMultiple(BaseModel):
track_ids: list[UUID]
class QueueItemResponse(BaseModel):
track: "TrackResponse"
added_by: UUID
class Config:
from_attributes = True
# Import at the end to avoid circular imports
from .track import TrackResponse
QueueItemResponse.model_rebuild()

View File

@@ -28,7 +28,10 @@ services:
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_BUCKET_NAME: ${S3_BUCKET_NAME:-enigfm}
S3_REGION: ${S3_REGION:-ru-1}
S3_REGION: ${S3_REGION:-default}
MAX_FILE_SIZE_MB: ${MAX_FILE_SIZE_MB:-10}
MAX_STORAGE_GB: ${MAX_STORAGE_GB:-90}
MAX_ROOM_PARTICIPANTS: ${MAX_ROOM_PARTICIPANTS:-50}
ports:
- "4001:8000"
depends_on:
@@ -40,6 +43,8 @@ services:
build:
context: ./frontend
dockerfile: Dockerfile
args:
VITE_MAX_FILE_SIZE_MB: ${VITE_MAX_FILE_SIZE_MB:-100}
ports:
- "4000:80"
depends_on:

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>
<MiniPlayer />
<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 />
</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>
<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>
</v-col>
<!-- 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>
<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 - time, volume, leave -->
<div class="mini-player-right">
<span class="time">{{ formatTime(playerStore.position) }} / {{ formatTime(playerStore.duration) }}</span>
<!-- Right side - volume, leave -->
<v-col cols="12" md="3" class="actions-section">
<div class="player-actions">
<div class="volume-control">
<img
v-if="playerStore.volume === 0"
src="/speaker-disabled-svgrepo-com.svg"
class="volume-icon"
<v-btn
icon
variant="text"
size="small"
@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"
>
<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>
</div>
</div>
<button class="leave-btn" @click="handleLeave" title="Выйти из комнаты">
</button>
</div>
</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"
<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)"
>
<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>
<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>
<span class="queue-duration">{{ formatDuration(track.duration) }}</span>
<button class="btn-remove" @click.stop="$emit('remove-track', track)" title="Удалить из очереди">
</button>
</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>
<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>
<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>
<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>
</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>
<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>
<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>
</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,78 +1,189 @@
<template>
<div class="room-page" v-if="room">
<div class="room-header">
<h1>{{ room.name }}</h1>
</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>
</div>
<Queue :queue="roomStore.queue" @play-track="playTrack" @remove-track="removeFromQueue" />
<div>
<h1 class="page-title">{{ room.name }}</h1>
<p class="page-subtitle">Комната для совместного прослушивания музыки</p>
</div>
</div>
<div class="side-section">
<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" />
</div>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<Modal v-if="showAddTrack" title="Добавить в очередь" @close="closeAddTrackModal">
<div class="filters-section">
<div class="search-filters">
<input
<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"
type="text"
placeholder="Поиск по названию..."
class="search-input"
label="Поиск по названию"
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="compact"
hide-details
clearable
bg-color="surface"
/>
<input
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="searchArtist"
type="text"
placeholder="Поиск по артисту..."
class="search-input"
label="Поиск по артисту"
prepend-inner-icon="mdi-account-music"
variant="outlined"
density="compact"
hide-details
clearable
bg-color="surface"
/>
</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>
</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="add-track-controls">
<div class="selection-info">
<span>Найдено: {{ filteredTracks.length }}</span>
<span v-if="selectedTracks.length > 0" class="selected-count">
Выбрано: {{ selectedTracks.length }}
</span>
<button
<div class="filters-actions">
<v-btn
v-if="selectedTracks.length > 0"
class="btn-text"
variant="text"
size="small"
@click="clearSelection"
prepend-icon="mdi-close"
>
Очистить
</button>
</div>
<button
class="btn-primary"
</v-btn>
<v-btn
color="primary"
:disabled="selectedTracks.length === 0"
@click="addSelectedTracks"
prepend-icon="mdi-playlist-plus"
size="large"
>
Добавить выбранные ({{ selectedTracks.length }})
</button>
Добавить ({{ selectedTracks.length }})
</v-btn>
</div>
<p v-if="addTrackError" class="error-message">{{ addTrackError }}</p>
<p v-if="addTrackSuccess" class="success-message">{{ addTrackSuccess }}</p>
</div>
<v-alert
v-if="addTrackError"
type="error"
variant="tonal"
closable
class="mb-4"
@click:close="addTrackError = ''"
>
{{ 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"
@@ -80,9 +191,24 @@
multi-select
@toggle-select="toggleTrackSelection"
/>
</Modal>
</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>
<h1 class="page-title">Моя библиотека</h1>
<p class="page-subtitle">Управляйте своей музыкальной коллекцией</p>
</div>
<div class="filter-tabs">
<button
:class="['filter-tab', { active: !showMyOnly }]"
@click="setFilter(false)"
<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)"
Загрузить трек
</v-btn>
</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>
<v-progress-circular
v-if="tracksStore.loading"
indeterminate
color="primary"
size="64"
class="loading-spinner"
/>
<v-card
v-else-if="filteredTracks.length === 0 && tracksStore.tracks.length > 0"
class="empty-state"
elevation="0"
>
Мои треки
</button>
</div>
<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>
<div v-if="tracksStore.loading" class="loading">Загрузка...</div>
<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>
<div v-else-if="tracksStore.tracks.length === 0" class="empty">
<p>Нет треков. Загрузите первый!</p>
</div>
<div v-else class="tracks-list 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;
@media (max-width: 600px) {
.header-section {
flex-direction: column;
align-items: stretch;
}
.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;
}
</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: {