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:
@@ -14,5 +14,11 @@ S3_BUCKET_NAME=enigfm
|
|||||||
S3_REGION=ru-1
|
S3_REGION=ru-1
|
||||||
|
|
||||||
# Limits
|
# Limits
|
||||||
MAX_FILE_SIZE_MB=10
|
MAX_FILE_SIZE_MB=100
|
||||||
MAX_STORAGE_GB=90
|
MAX_STORAGE_GB=90
|
||||||
|
MAX_ROOM_PARTICIPANTS=50
|
||||||
|
|
||||||
|
# Frontend (Vite)
|
||||||
|
# VITE_API_URL - оставляем пустым для использования относительных путей
|
||||||
|
# VITE_WS_URL - оставляем пустым для автоопределения
|
||||||
|
VITE_MAX_FILE_SIZE_MB=100
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -24,7 +24,7 @@ class Settings(BaseSettings):
|
|||||||
max_room_participants: int = 50
|
max_room_participants: int = 50
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
# env_file не нужен - переменные передаются через docker-compose
|
||||||
extra = "ignore"
|
extra = "ignore"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from ..database import get_db
|
|||||||
from ..models.user import User
|
from ..models.user import User
|
||||||
from ..models.room import Room, RoomParticipant
|
from ..models.room import Room, RoomParticipant
|
||||||
from ..models.track import RoomQueue
|
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.track import TrackResponse
|
||||||
from ..schemas.user import UserResponse
|
from ..schemas.user import UserResponse
|
||||||
from ..services.auth import get_current_user
|
from ..services.auth import get_current_user
|
||||||
@@ -178,7 +178,7 @@ async def leave_room(
|
|||||||
return {"status": "left"}
|
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)):
|
async def get_queue(room_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(RoomQueue)
|
select(RoomQueue)
|
||||||
@@ -187,7 +187,13 @@ async def get_queue(room_id: UUID, db: AsyncSession = Depends(get_db)):
|
|||||||
.order_by(RoomQueue.position)
|
.order_by(RoomQueue.position)
|
||||||
)
|
)
|
||||||
queue_items = result.scalars().all()
|
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")
|
@router.post("/{room_id}/queue")
|
||||||
@@ -314,7 +320,19 @@ async def remove_from_queue(
|
|||||||
)
|
)
|
||||||
queue_item = result.scalar_one_or_none()
|
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)
|
await db.delete(queue_item)
|
||||||
|
|
||||||
# Notify others
|
# Notify others
|
||||||
|
|||||||
@@ -49,3 +49,16 @@ class QueueAdd(BaseModel):
|
|||||||
|
|
||||||
class QueueAddMultiple(BaseModel):
|
class QueueAddMultiple(BaseModel):
|
||||||
track_ids: list[UUID]
|
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()
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ services:
|
|||||||
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
|
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
|
||||||
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
||||||
S3_BUCKET_NAME: ${S3_BUCKET_NAME:-enigfm}
|
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:
|
ports:
|
||||||
- "4001:8000"
|
- "4001:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -40,6 +43,8 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_MAX_FILE_SIZE_MB: ${VITE_MAX_FILE_SIZE_MB:-100}
|
||||||
ports:
|
ports:
|
||||||
- "4000:80"
|
- "4000:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
VITE_API_URL=http://localhost:4001
|
|
||||||
VITE_WS_URL=ws://localhost:4001
|
|
||||||
VITE_MAX_FILE_SIZE_MB=10
|
|
||||||
@@ -3,6 +3,12 @@ FROM node:20-alpine as build
|
|||||||
|
|
||||||
WORKDIR /app
|
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 ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ server {
|
|||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
# Max upload size
|
# Max upload size
|
||||||
client_max_body_size 30M;
|
client_max_body_size 100M;
|
||||||
|
|
||||||
# Gzip compression
|
# Gzip compression
|
||||||
gzip on;
|
gzip on;
|
||||||
@@ -27,6 +27,9 @@ server {
|
|||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
proxy_cache off;
|
proxy_cache off;
|
||||||
proxy_request_buffering off;
|
proxy_request_buffering off;
|
||||||
|
|
||||||
|
# Max upload size for API
|
||||||
|
client_max_body_size 100M;
|
||||||
}
|
}
|
||||||
|
|
||||||
# WebSocket proxy
|
# WebSocket proxy
|
||||||
|
|||||||
@@ -12,10 +12,13 @@
|
|||||||
"vue": "^3.4.15",
|
"vue": "^3.4.15",
|
||||||
"vue-router": "^4.2.5",
|
"vue-router": "^4.2.5",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"axios": "^1.6.5"
|
"axios": "^1.6.5",
|
||||||
|
"vuetify": "^3.5.0",
|
||||||
|
"@mdi/font": "^7.4.47"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.0.3",
|
"@vitejs/plugin-vue": "^5.0.3",
|
||||||
"vite": "^5.0.11"
|
"vite": "^5.0.11",
|
||||||
|
"vite-plugin-vuetify": "^2.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,187 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="app">
|
<v-app>
|
||||||
<Header />
|
<v-navigation-drawer
|
||||||
<main class="main-content" :class="{ 'has-mini-player': activeRoomStore.isInRoom }">
|
permanent
|
||||||
<router-view />
|
:width="260"
|
||||||
</main>
|
color="surface"
|
||||||
<MiniPlayer />
|
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>
|
</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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import Header from './components/common/Header.vue'
|
import { useRouter } from 'vue-router'
|
||||||
import MiniPlayer from './components/player/MiniPlayer.vue'
|
import MiniPlayer from './components/player/MiniPlayer.vue'
|
||||||
import { useActiveRoomStore } from './stores/activeRoom'
|
import { useActiveRoomStore } from './stores/activeRoom'
|
||||||
|
import { useAuthStore } from './stores/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const activeRoomStore = useActiveRoomStore()
|
const activeRoomStore = useActiveRoomStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
authStore.logout()
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
#app {
|
.sidebar {
|
||||||
min-height: 100vh;
|
border-right: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content {
|
||||||
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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;
|
flex: 1;
|
||||||
padding: 20px;
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content.has-mini-player {
|
.user-section {
|
||||||
padding-bottom: 100px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,23 +1,38 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="chat card">
|
<div class="chat">
|
||||||
<h3>Чат</h3>
|
|
||||||
<div class="messages" ref="messagesRef">
|
<div class="messages" ref="messagesRef">
|
||||||
<ChatMessage
|
<ChatMessage
|
||||||
v-for="msg in allMessages"
|
v-for="msg in allMessages"
|
||||||
:key="msg.id"
|
:key="msg.id"
|
||||||
:message="msg"
|
: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>
|
||||||
|
</div>
|
||||||
|
<v-divider />
|
||||||
<form @submit.prevent="sendMessage" class="chat-input">
|
<form @submit.prevent="sendMessage" class="chat-input">
|
||||||
<input
|
<v-text-field
|
||||||
type="text"
|
|
||||||
v-model="newMessage"
|
v-model="newMessage"
|
||||||
placeholder="Написать сообщение..."
|
placeholder="Написать сообщение..."
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
hide-details
|
||||||
:disabled="!activeRoomStore.connected"
|
:disabled="!activeRoomStore.connected"
|
||||||
/>
|
>
|
||||||
<button type="submit" class="btn-primary" :disabled="!newMessage.trim()">
|
<template v-slot:append-inner>
|
||||||
Отправить
|
<v-btn
|
||||||
</button>
|
type="submit"
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
:disabled="!newMessage.trim()"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-send</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-text-field>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -74,12 +89,7 @@ function scrollToBottom() {
|
|||||||
.chat {
|
.chat {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 400px;
|
height: 450px;
|
||||||
}
|
|
||||||
|
|
||||||
.chat h3 {
|
|
||||||
margin: 0 0 12px 0;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages {
|
.messages {
|
||||||
@@ -88,20 +98,19 @@ function scrollToBottom() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
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 {
|
.chat-input {
|
||||||
display: flex;
|
padding: 16px;
|
||||||
gap: 8px;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input input {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input button {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,76 +1,136 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="mini-player" v-if="activeRoomStore.isInRoom">
|
<v-footer
|
||||||
<!-- Progress bar at top -->
|
app
|
||||||
<div class="progress-bar-top" @click="handleSeek">
|
fixed
|
||||||
<div class="progress-fill" :style="{ width: progressPercent + '%' }"></div>
|
class="mini-player"
|
||||||
</div>
|
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">
|
<v-container fluid class="mini-player-content pa-0">
|
||||||
<!-- Track info - left side -->
|
<v-row align="center" no-gutters>
|
||||||
<div class="mini-player-info" @click="goToRoom">
|
<!-- Track info - left -->
|
||||||
<div class="track-title" v-if="currentTrack">{{ currentTrack.title }}</div>
|
<v-col cols="12" md="3" class="track-info-section">
|
||||||
<div class="track-title" v-else>Нет трека</div>
|
<div class="track-info" @click="goToRoom">
|
||||||
<div class="track-artist" v-if="currentTrack">{{ currentTrack.artist }}</div>
|
<v-avatar
|
||||||
<div class="room-name">{{ activeRoomStore.roomName }}</div>
|
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>
|
||||||
|
<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 -->
|
<!-- Controls - center -->
|
||||||
<div class="mini-player-controls">
|
<v-col cols="12" md="6" class="controls-section">
|
||||||
<button class="control-btn" @click="handlePrev">
|
<div class="player-controls">
|
||||||
<span>⏮</span>
|
<v-btn
|
||||||
</button>
|
icon
|
||||||
<button class="control-btn play-btn" @click="togglePlay">
|
variant="text"
|
||||||
<span>{{ playerStore.isPlaying ? '⏸' : '▶' }}</span>
|
size="small"
|
||||||
</button>
|
@click="handlePrev"
|
||||||
<button class="control-btn" @click="handleNext">
|
>
|
||||||
<span>⏭</span>
|
<v-icon>mdi-skip-previous</v-icon>
|
||||||
</button>
|
</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>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
<!-- Right side - time, volume, leave -->
|
<!-- Right side - volume, leave -->
|
||||||
<div class="mini-player-right">
|
<v-col cols="12" md="3" class="actions-section">
|
||||||
<span class="time">{{ formatTime(playerStore.position) }} / {{ formatTime(playerStore.duration) }}</span>
|
<div class="player-actions">
|
||||||
|
|
||||||
<div class="volume-control">
|
<div class="volume-control">
|
||||||
<img
|
<v-btn
|
||||||
v-if="playerStore.volume === 0"
|
icon
|
||||||
src="/speaker-disabled-svgrepo-com.svg"
|
variant="text"
|
||||||
class="volume-icon"
|
size="small"
|
||||||
@click="toggleMute"
|
@click="toggleMute"
|
||||||
/>
|
>
|
||||||
<img
|
<v-icon>
|
||||||
v-else-if="playerStore.volume < 50"
|
{{
|
||||||
src="/speaker-1-svgrepo-com.svg"
|
playerStore.volume === 0
|
||||||
class="volume-icon"
|
? 'mdi-volume-mute'
|
||||||
@click="toggleMute"
|
: playerStore.volume < 50
|
||||||
/>
|
? 'mdi-volume-low'
|
||||||
<img
|
: 'mdi-volume-high'
|
||||||
v-else
|
}}
|
||||||
src="/speaker-2-svgrepo-com.svg"
|
</v-icon>
|
||||||
class="volume-icon"
|
</v-btn>
|
||||||
@click="toggleMute"
|
<v-slider
|
||||||
/>
|
:model-value="playerStore.volume"
|
||||||
<div class="volume-popup">
|
@update:model-value="handleVolume"
|
||||||
<span class="volume-value">{{ playerStore.volume }}%</span>
|
:min="0"
|
||||||
<div class="volume-slider-wrapper">
|
:max="100"
|
||||||
<input
|
hide-details
|
||||||
type="range"
|
color="primary"
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
:value="playerStore.volume"
|
|
||||||
@input="handleVolume"
|
|
||||||
class="volume-slider"
|
class="volume-slider"
|
||||||
|
density="compact"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="leave-btn" @click="handleLeave" title="Выйти из комнаты">
|
<v-btn
|
||||||
✕
|
icon
|
||||||
</button>
|
variant="text"
|
||||||
</div>
|
color="error"
|
||||||
</div>
|
@click="handleLeave"
|
||||||
|
title="Выйти из комнаты"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-close</v-icon>
|
||||||
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-container>
|
||||||
|
</v-footer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -131,8 +191,8 @@ function goToRoom() {
|
|||||||
|
|
||||||
let previousVolume = 100
|
let previousVolume = 100
|
||||||
|
|
||||||
function handleVolume(e) {
|
function handleVolume(value) {
|
||||||
activeRoomStore.setVolume(Number(e.target.value))
|
activeRoomStore.setVolume(Number(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -153,245 +213,142 @@ async function handleLeave() {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.mini-player {
|
.mini-player {
|
||||||
position: fixed;
|
background: rgba(21, 25, 50, 0.95) !important;
|
||||||
bottom: 0;
|
backdrop-filter: blur(20px);
|
||||||
left: 0;
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
right: 0;
|
padding: 16px 24px !important;
|
||||||
background: #1a1a2e;
|
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini-player-content {
|
.mini-player-content {
|
||||||
position: relative;
|
max-width: 1400px;
|
||||||
display: flex;
|
margin: 0 auto;
|
||||||
align-items: center;
|
|
||||||
padding: 12px 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini-player-controls {
|
.track-info-section {
|
||||||
position: absolute;
|
display: flex;
|
||||||
left: 50%;
|
align-items: center;
|
||||||
transform: translateX(-50%);
|
}
|
||||||
|
|
||||||
|
.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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar-top {
|
.time-display {
|
||||||
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;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
gap: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
min-width: 90px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.play-btn:hover {
|
.time-separator {
|
||||||
background: #6d28d9;
|
margin: 0 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini-player-right {
|
.actions-section {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #888;
|
|
||||||
min-width: 80px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-control {
|
.volume-control {
|
||||||
position: relative;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
gap: 8px;
|
||||||
|
min-width: 150px;
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-slider {
|
.volume-slider {
|
||||||
-webkit-appearance: none;
|
max-width: 100px;
|
||||||
appearance: none;
|
|
||||||
width: 100px;
|
|
||||||
height: 8px;
|
|
||||||
background: #444;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
transform: rotate(-90deg);
|
|
||||||
transform-origin: center center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-slider::-webkit-slider-runnable-track {
|
.progress-bar {
|
||||||
width: 100%;
|
transition: height 0.2s;
|
||||||
height: 8px;
|
|
||||||
background: #444;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-slider::-webkit-slider-thumb {
|
.progress-bar:hover {
|
||||||
-webkit-appearance: none;
|
height: 6px !important;
|
||||||
appearance: none;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
background: #7c3aed;
|
|
||||||
border-radius: 50%;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-top: -5px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-slider::-moz-range-track {
|
@media (max-width: 960px) {
|
||||||
width: 100%;
|
.controls-section {
|
||||||
height: 8px;
|
order: -1;
|
||||||
background: #444;
|
margin-bottom: 12px;
|
||||||
border-radius: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-slider::-moz-range-thumb {
|
.track-info-section,
|
||||||
width: 18px;
|
.actions-section {
|
||||||
height: 18px;
|
justify-content: center;
|
||||||
background: #7c3aed;
|
|
||||||
border-radius: 50%;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.leave-btn {
|
.time-display {
|
||||||
background: transparent;
|
display: none;
|
||||||
border: none;
|
|
||||||
color: #666;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 8px 12px;
|
|
||||||
font-size: 18px;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.leave-btn:hover {
|
|
||||||
background: #ff4444;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.volume-control {
|
.volume-control {
|
||||||
display: none;
|
min-width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time {
|
.volume-slider {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini-player-content {
|
|
||||||
padding: 10px 16px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="participants card">
|
<div class="participants">
|
||||||
<h3>Участники ({{ participants.length }})</h3>
|
<v-list bg-color="transparent">
|
||||||
<div class="participants-list">
|
<v-list-item
|
||||||
<div
|
|
||||||
v-for="participant in participants"
|
v-for="participant in participants"
|
||||||
:key="participant.id"
|
:key="participant.id"
|
||||||
class="participant"
|
class="participant-item"
|
||||||
>
|
>
|
||||||
<div class="avatar">{{ participant.username.charAt(0).toUpperCase() }}</div>
|
<template v-slot:prepend>
|
||||||
<span class="username">{{ participant.username }}</span>
|
<v-avatar color="primary" size="36">
|
||||||
</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -24,38 +39,26 @@ defineProps({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.participants h3 {
|
.participants {
|
||||||
margin: 0 0 16px 0;
|
min-height: 100px;
|
||||||
font-size: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.participants-list {
|
.participant-item {
|
||||||
display: flex;
|
padding: 8px 0;
|
||||||
flex-direction: column;
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
gap: 8px;
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.participant {
|
.participant-item:last-child {
|
||||||
display: flex;
|
border-bottom: none;
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
.participant-name {
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #6c63ff;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.username {
|
.empty-state {
|
||||||
font-size: 14px;
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,36 +1,67 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="queue">
|
<div class="queue">
|
||||||
<div v-if="queue.length === 0" class="empty-queue">
|
<v-list v-if="queue.length > 0" bg-color="transparent">
|
||||||
Очередь пуста
|
<v-list-item
|
||||||
</div>
|
v-for="(item, index) in queue"
|
||||||
<div
|
:key="item.track.id"
|
||||||
v-for="(track, index) in queue"
|
|
||||||
:key="track.id"
|
|
||||||
class="queue-item"
|
class="queue-item"
|
||||||
|
@click="$emit('play-track', item.track)"
|
||||||
>
|
>
|
||||||
<span class="queue-index">{{ index + 1 }}</span>
|
<template v-slot:prepend>
|
||||||
<div class="queue-track-info" @click="$emit('play-track', track)">
|
<div class="queue-index">{{ index + 1 }}</div>
|
||||||
<span class="queue-track-title">{{ track.title }}</span>
|
</template>
|
||||||
<span class="queue-track-artist">{{ track.artist }}</span>
|
|
||||||
|
<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>
|
</div>
|
||||||
<span class="queue-duration">{{ formatDuration(track.duration) }}</span>
|
</template>
|
||||||
<button class="btn-remove" @click.stop="$emit('remove-track', track)" title="Удалить из очереди">
|
</v-list-item>
|
||||||
✕
|
</v-list>
|
||||||
</button>
|
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
const props = defineProps({
|
||||||
queue: {
|
queue: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
|
},
|
||||||
|
currentUserId: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits(['play-track', 'remove-track'])
|
defineEmits(['play-track', 'remove-track'])
|
||||||
|
|
||||||
|
function canRemove(item) {
|
||||||
|
return props.currentUserId && item.added_by === props.currentUserId
|
||||||
|
}
|
||||||
|
|
||||||
function formatDuration(ms) {
|
function formatDuration(ms) {
|
||||||
const seconds = Math.floor(ms / 1000)
|
const seconds = Math.floor(ms / 1000)
|
||||||
const minutes = Math.floor(seconds / 60)
|
const minutes = Math.floor(seconds / 60)
|
||||||
@@ -41,76 +72,55 @@ function formatDuration(ms) {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.queue {
|
.queue {
|
||||||
max-height: 300px;
|
min-height: 200px;
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-queue {
|
.empty-queue {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #666;
|
padding: 60px 20px;
|
||||||
padding: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-item {
|
.queue-item {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s;
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-item:hover {
|
.queue-item:hover {
|
||||||
background: #2d2d44;
|
background: rgba(255, 255, 255, 0.05) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-index {
|
.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;
|
font-size: 14px;
|
||||||
min-width: 24px;
|
font-weight: 600;
|
||||||
}
|
margin-right: 12px;
|
||||||
|
|
||||||
.queue-track-info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-track-title {
|
.queue-track-title {
|
||||||
display: block;
|
font-size: 15px;
|
||||||
font-size: 14px;
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-track-artist {
|
.queue-track-artist {
|
||||||
display: block;
|
font-size: 13px;
|
||||||
font-size: 12px;
|
opacity: 0.7;
|
||||||
color: #aaa;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-duration {
|
.queue-duration {
|
||||||
color: #aaa;
|
color: rgba(255, 255, 255, 0.5);
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
}
|
min-width: 50px;
|
||||||
|
text-align: right;
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,53 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="room-card card">
|
<v-card
|
||||||
<h3>{{ room.name }}</h3>
|
class="room-card"
|
||||||
<div class="room-info">
|
elevation="2"
|
||||||
<span class="participants">{{ room.participants_count }} участников</span>
|
hover
|
||||||
<span v-if="room.is_playing" class="playing">Играет</span>
|
>
|
||||||
|
<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>
|
</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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -20,45 +62,74 @@ defineProps({
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.room-card {
|
.room-card {
|
||||||
cursor: pointer;
|
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 {
|
.room-card:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-4px);
|
||||||
border-color: #6c63ff;
|
border-color: rgb(var(--v-theme-primary));
|
||||||
|
box-shadow: 0 8px 24px rgba(108, 99, 255, 0.2) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-card h3 {
|
.room-card:hover .card-gradient {
|
||||||
margin: 0 0 12px 0;
|
opacity: 1;
|
||||||
font-size: 18px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-info {
|
.room-card:hover .action-text {
|
||||||
display: flex;
|
color: rgb(var(--v-theme-primary));
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
color: #aaa;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.playing {
|
.card-gradient {
|
||||||
color: #2ed573;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playing::before {
|
.room-actions {
|
||||||
content: '';
|
display: flex;
|
||||||
width: 8px;
|
align-items: center;
|
||||||
height: 8px;
|
color: rgba(255, 255, 255, 0.6);
|
||||||
background: #2ed573;
|
font-size: 14px;
|
||||||
border-radius: 50%;
|
font-weight: 500;
|
||||||
animation: pulse 1s infinite;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
.action-text {
|
||||||
0%, 100% { opacity: 1; }
|
transition: color 0.2s;
|
||||||
50% { opacity: 0.5; }
|
}
|
||||||
|
|
||||||
|
.playing-chip {
|
||||||
|
animation: pulse-glow 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-glow {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="!selectable && !multiSelect"
|
v-if="!selectable && !multiSelect && isOwnTrack"
|
||||||
class="btn-danger delete-btn"
|
class="btn-danger delete-btn"
|
||||||
@click.stop="$emit('delete')"
|
@click.stop="$emit('delete')"
|
||||||
>
|
>
|
||||||
@@ -31,6 +31,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
track: {
|
track: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -51,9 +53,17 @@ const props = defineProps({
|
|||||||
inQueue: {
|
inQueue: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
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'])
|
const emit = defineEmits(['select', 'toggle-select', 'delete'])
|
||||||
|
|
||||||
function handleClick() {
|
function handleClick() {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
:multi-select="multiSelect"
|
:multi-select="multiSelect"
|
||||||
:is-selected="selectedTrackIds.includes(track.id)"
|
:is-selected="selectedTrackIds.includes(track.id)"
|
||||||
:in-queue="queueTrackIds.includes(track.id)"
|
:in-queue="queueTrackIds.includes(track.id)"
|
||||||
|
:current-user-id="currentUserId"
|
||||||
@select="$emit('select', track)"
|
@select="$emit('select', track)"
|
||||||
@toggle-select="$emit('toggle-select', track.id)"
|
@toggle-select="$emit('toggle-select', track.id)"
|
||||||
@delete="$emit('delete', track)"
|
@delete="$emit('delete', track)"
|
||||||
@@ -41,6 +42,10 @@ defineProps({
|
|||||||
selectedTrackIds: {
|
selectedTrackIds: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
|
},
|
||||||
|
currentUserId: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { createApp } from 'vue'
|
|||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
import vuetify from './plugins/vuetify'
|
||||||
import './assets/styles/main.css'
|
import './assets/styles/main.css'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
app.use(vuetify)
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
29
frontend/src/plugins/vuetify.js
Normal file
29
frontend/src/plugins/vuetify.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { createVuetify } from 'vuetify'
|
||||||
|
import * as components from 'vuetify/components'
|
||||||
|
import * as directives from 'vuetify/directives'
|
||||||
|
import 'vuetify/styles'
|
||||||
|
import '@mdi/font/css/materialdesignicons.css'
|
||||||
|
|
||||||
|
export default createVuetify({
|
||||||
|
components,
|
||||||
|
directives,
|
||||||
|
theme: {
|
||||||
|
defaultTheme: 'dark',
|
||||||
|
themes: {
|
||||||
|
dark: {
|
||||||
|
dark: true,
|
||||||
|
colors: {
|
||||||
|
background: '#0a0e27',
|
||||||
|
surface: '#151932',
|
||||||
|
primary: '#6c63ff',
|
||||||
|
secondary: '#ff6584',
|
||||||
|
accent: '#00d9ff',
|
||||||
|
error: '#ff4444',
|
||||||
|
info: '#2196F3',
|
||||||
|
success: '#4CAF50',
|
||||||
|
warning: '#FB8C00',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,17 +1,41 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="home">
|
<div class="home">
|
||||||
<div class="header-section">
|
<div class="header-section">
|
||||||
<h1>Комнаты</h1>
|
<div>
|
||||||
<button v-if="authStore.isAuthenticated" class="btn-primary" @click="showCreateModal = true">
|
<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>
|
||||||
|
|
||||||
<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">
|
<v-card
|
||||||
<p>Пока нет комнат. Создайте первую!</p>
|
v-else-if="roomStore.rooms.length === 0"
|
||||||
</div>
|
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">
|
<div v-else class="rooms-grid">
|
||||||
<RoomCard
|
<RoomCard
|
||||||
@@ -22,17 +46,45 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal v-if="showCreateModal" title="Создать комнату" @close="showCreateModal = false">
|
<v-dialog v-model="showCreateModal" max-width="500">
|
||||||
<form @submit.prevent="createRoom">
|
<v-card>
|
||||||
<div class="form-group">
|
<v-card-title class="text-h5">
|
||||||
<label>Название комнаты</label>
|
<v-icon class="mr-2">mdi-music-circle</v-icon>
|
||||||
<input type="text" v-model="newRoomName" required placeholder="Моя комната" />
|
Создать комнату
|
||||||
</div>
|
</v-card-title>
|
||||||
<button type="submit" class="btn-primary" :disabled="creating">
|
<v-card-text>
|
||||||
{{ creating ? 'Создание...' : 'Создать' }}
|
<v-form @submit.prevent="createRoom">
|
||||||
</button>
|
<v-text-field
|
||||||
</form>
|
v-model="newRoomName"
|
||||||
</Modal>
|
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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -42,7 +94,6 @@ import { useRouter } from 'vue-router'
|
|||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useRoomStore } from '../stores/room'
|
import { useRoomStore } from '../stores/room'
|
||||||
import RoomCard from '../components/room/RoomCard.vue'
|
import RoomCard from '../components/room/RoomCard.vue'
|
||||||
import Modal from '../components/common/Modal.vue'
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
@@ -81,29 +132,59 @@ function goToRoom(roomId) {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.home {
|
.home {
|
||||||
padding-top: 20px;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-section {
|
.header-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
margin-bottom: 24px;
|
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;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rooms-grid {
|
.rooms-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
gap: 16px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading, .empty {
|
.loading-spinner {
|
||||||
text-align: center;
|
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;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,25 +1,76 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="auth-page">
|
<div class="auth-page">
|
||||||
<div class="auth-card card">
|
<v-card class="auth-card" elevation="8">
|
||||||
<h2>Вход</h2>
|
<div class="auth-header">
|
||||||
<form @submit.prevent="handleLogin">
|
<v-avatar size="64" color="primary" class="mb-4">
|
||||||
<div class="form-group">
|
<v-icon size="40">mdi-music-circle</v-icon>
|
||||||
<label>Имя пользователя</label>
|
</v-avatar>
|
||||||
<input type="text" v-model="username" required />
|
<h2 class="auth-title">Добро пожаловать</h2>
|
||||||
|
<p class="auth-subtitle">Войдите в свой аккаунт EnigFM</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label>Пароль</label>
|
<v-card-text class="pt-8">
|
||||||
<input type="password" v-model="password" required />
|
<v-form @submit.prevent="handleLogin">
|
||||||
</div>
|
<v-text-field
|
||||||
<p v-if="error" class="error-message">{{ error }}</p>
|
v-model="username"
|
||||||
<button type="submit" class="btn-primary" :disabled="loading">
|
label="Имя пользователя"
|
||||||
{{ loading ? 'Вход...' : 'Войти' }}
|
prepend-inner-icon="mdi-account"
|
||||||
</button>
|
variant="outlined"
|
||||||
</form>
|
required
|
||||||
<p class="auth-link">
|
:disabled="loading"
|
||||||
Нет аккаунта? <router-link to="/register">Зарегистрироваться</router-link>
|
autofocus
|
||||||
</p>
|
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>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -56,27 +107,30 @@ async function handleLogin() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: calc(100vh - 100px);
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-card {
|
.auth-card {
|
||||||
width: 100%;
|
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 {
|
.auth-header {
|
||||||
margin-bottom: 24px;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
padding: 40px 40px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-card button {
|
.auth-title {
|
||||||
width: 100%;
|
font-size: 28px;
|
||||||
margin-top: 8px;
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-link {
|
.auth-subtitle {
|
||||||
text-align: center;
|
color: rgba(255, 255, 255, 0.6);
|
||||||
margin-top: 16px;
|
font-size: 15px;
|
||||||
color: #aaa;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,25 +1,78 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="auth-page">
|
<div class="auth-page">
|
||||||
<div class="auth-card card">
|
<v-card class="auth-card" elevation="8">
|
||||||
<h2>Регистрация</h2>
|
<div class="auth-header">
|
||||||
<form @submit.prevent="handleRegister">
|
<v-avatar size="64" color="primary" class="mb-4">
|
||||||
<div class="form-group">
|
<v-icon size="40">mdi-music-circle</v-icon>
|
||||||
<label>Имя пользователя</label>
|
</v-avatar>
|
||||||
<input type="text" v-model="username" required />
|
<h2 class="auth-title">Создать аккаунт</h2>
|
||||||
|
<p class="auth-subtitle">Присоединяйтесь к EnigFM</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label>Пароль</label>
|
<v-card-text class="pt-8">
|
||||||
<input type="password" v-model="password" required minlength="6" />
|
<v-form @submit.prevent="handleRegister">
|
||||||
</div>
|
<v-text-field
|
||||||
<p v-if="error" class="error-message">{{ error }}</p>
|
v-model="username"
|
||||||
<button type="submit" class="btn-primary" :disabled="loading">
|
label="Имя пользователя"
|
||||||
{{ loading ? 'Регистрация...' : 'Зарегистрироваться' }}
|
prepend-inner-icon="mdi-account"
|
||||||
</button>
|
variant="outlined"
|
||||||
</form>
|
required
|
||||||
<p class="auth-link">
|
:disabled="loading"
|
||||||
Уже есть аккаунт? <router-link to="/login">Войти</router-link>
|
autofocus
|
||||||
</p>
|
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>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -56,27 +109,30 @@ async function handleRegister() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: calc(100vh - 100px);
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-card {
|
.auth-card {
|
||||||
width: 100%;
|
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 {
|
.auth-header {
|
||||||
margin-bottom: 24px;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
padding: 40px 40px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-card button {
|
.auth-title {
|
||||||
width: 100%;
|
font-size: 28px;
|
||||||
margin-top: 8px;
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-link {
|
.auth-subtitle {
|
||||||
text-align: center;
|
color: rgba(255, 255, 255, 0.6);
|
||||||
margin-top: 16px;
|
font-size: 15px;
|
||||||
color: #aaa;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,78 +1,189 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="room-page" v-if="room">
|
<div class="room-page" v-if="room">
|
||||||
<div class="room-header">
|
<div class="room-header">
|
||||||
<h1>{{ room.name }}</h1>
|
<div>
|
||||||
</div>
|
<h1 class="page-title">{{ room.name }}</h1>
|
||||||
|
<p class="page-subtitle">Комната для совместного прослушивания музыки</p>
|
||||||
<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>
|
</div>
|
||||||
</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" />
|
<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" />
|
<ChatWindow :room-id="roomId" />
|
||||||
</div>
|
</v-card-text>
|
||||||
</div>
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
<Modal v-if="showAddTrack" title="Добавить в очередь" @close="closeAddTrackModal">
|
<v-dialog v-model="showAddTrack" max-width="900" scrollable>
|
||||||
<div class="filters-section">
|
<v-card>
|
||||||
<div class="search-filters">
|
<v-card-title class="text-h5">
|
||||||
<input
|
<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"
|
v-model="searchTitle"
|
||||||
type="text"
|
label="Поиск по названию"
|
||||||
placeholder="Поиск по названию..."
|
prepend-inner-icon="mdi-magnify"
|
||||||
class="search-input"
|
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"
|
v-model="searchArtist"
|
||||||
type="text"
|
label="Поиск по артисту"
|
||||||
placeholder="Поиск по артисту..."
|
prepend-inner-icon="mdi-account-music"
|
||||||
class="search-input"
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
clearable
|
||||||
|
bg-color="surface"
|
||||||
/>
|
/>
|
||||||
</div>
|
</v-col>
|
||||||
<div class="checkbox-filters">
|
</v-row>
|
||||||
<label class="checkbox-label">
|
|
||||||
<input type="checkbox" v-model="filterMyTracks" />
|
<!-- Фильтры и действия -->
|
||||||
<span>Мои треки</span>
|
<div class="filters-bar mb-4">
|
||||||
</label>
|
<div class="filters-chips">
|
||||||
<label class="checkbox-label">
|
<v-chip
|
||||||
<input type="checkbox" v-model="filterNotInQueue" />
|
:color="filterMyTracks ? 'primary' : 'default'"
|
||||||
<span>Не добавленные в комнату</span>
|
:variant="filterMyTracks ? 'flat' : 'outlined'"
|
||||||
</label>
|
@click="filterMyTracks = !filterMyTracks"
|
||||||
</div>
|
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>
|
||||||
|
|
||||||
<div class="add-track-controls">
|
<div class="filters-actions">
|
||||||
<div class="selection-info">
|
<v-btn
|
||||||
<span>Найдено: {{ filteredTracks.length }}</span>
|
|
||||||
<span v-if="selectedTracks.length > 0" class="selected-count">
|
|
||||||
Выбрано: {{ selectedTracks.length }}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
v-if="selectedTracks.length > 0"
|
v-if="selectedTracks.length > 0"
|
||||||
class="btn-text"
|
variant="text"
|
||||||
|
size="small"
|
||||||
@click="clearSelection"
|
@click="clearSelection"
|
||||||
|
prepend-icon="mdi-close"
|
||||||
>
|
>
|
||||||
Очистить
|
Очистить
|
||||||
</button>
|
</v-btn>
|
||||||
</div>
|
|
||||||
<button
|
<v-btn
|
||||||
class="btn-primary"
|
color="primary"
|
||||||
:disabled="selectedTracks.length === 0"
|
:disabled="selectedTracks.length === 0"
|
||||||
@click="addSelectedTracks"
|
@click="addSelectedTracks"
|
||||||
|
prepend-icon="mdi-playlist-plus"
|
||||||
|
size="large"
|
||||||
>
|
>
|
||||||
Добавить выбранные ({{ selectedTracks.length }})
|
Добавить ({{ selectedTracks.length }})
|
||||||
</button>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="addTrackError" class="error-message">{{ addTrackError }}</p>
|
</div>
|
||||||
<p v-if="addTrackSuccess" class="success-message">{{ addTrackSuccess }}</p>
|
|
||||||
|
<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
|
<TrackList
|
||||||
:tracks="filteredTracks"
|
:tracks="filteredTracks"
|
||||||
:queue-track-ids="queueTrackIds"
|
:queue-track-ids="queueTrackIds"
|
||||||
@@ -80,9 +191,24 @@
|
|||||||
multi-select
|
multi-select
|
||||||
@toggle-select="toggleTrackSelection"
|
@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>
|
||||||
<div v-else class="loading">Загрузка...</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -118,7 +244,7 @@ const filterMyTracks = ref(false)
|
|||||||
const filterNotInQueue = ref(false)
|
const filterNotInQueue = ref(false)
|
||||||
|
|
||||||
const queueTrackIds = computed(() => {
|
const queueTrackIds = computed(() => {
|
||||||
return roomStore.queue.map(track => track.id)
|
return roomStore.queue.map(item => item.track.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredTracks = computed(() => {
|
const filteredTracks = computed(() => {
|
||||||
@@ -232,166 +358,95 @@ async function removeFromQueue(track) {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.room-page {
|
.room-page {
|
||||||
padding-top: 20px;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-header {
|
.room-header {
|
||||||
display: flex;
|
margin-bottom: 32px;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-layout {
|
.queue-card,
|
||||||
display: grid;
|
.participants-card,
|
||||||
grid-template-columns: 1fr 350px;
|
.chat-card {
|
||||||
gap: 20px;
|
background: rgba(255, 255, 255, 0.02) !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-section {
|
.loading-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
justify-content: center;
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-section {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 16px;
|
min-height: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-header h3 {
|
.filters-bar {
|
||||||
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 {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
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;
|
justify-content: space-between;
|
||||||
align-items: center;
|
padding: 16px;
|
||||||
margin-bottom: 16px;
|
background: rgba(255, 255, 255, 0.02);
|
||||||
padding-bottom: 12px;
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
border-bottom: 1px solid #333;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selection-info {
|
.filters-chips {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
color: #aaa;
|
|
||||||
font-size: 14px;
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selected-count {
|
.filter-chip {
|
||||||
color: var(--color-primary, #1db954);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-text {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-primary, #1db954);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 14px;
|
transition: all 0.2s;
|
||||||
padding: 4px 8px;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-text:hover {
|
.filter-chip:hover {
|
||||||
opacity: 0.8;
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(108, 99, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.success-message {
|
.filters-actions {
|
||||||
color: #4caf50;
|
display: flex;
|
||||||
font-size: 14px;
|
gap: 12px;
|
||||||
text-align: center;
|
align-items: center;
|
||||||
margin: 8px 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 768px) {
|
||||||
.room-layout {
|
.filters-bar {
|
||||||
grid-template-columns: 1fr;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,63 +1,179 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="tracks-page">
|
<div class="tracks-page">
|
||||||
<div class="header-section">
|
<div class="header-section">
|
||||||
<h1>Библиотека треков</h1>
|
<div>
|
||||||
<button class="btn-primary" @click="showUpload = true">Загрузить трек</button>
|
<h1 class="page-title">Моя библиотека</h1>
|
||||||
|
<p class="page-subtitle">Управляйте своей музыкальной коллекцией</p>
|
||||||
</div>
|
</div>
|
||||||
|
<v-btn
|
||||||
<div class="filter-tabs">
|
color="primary"
|
||||||
<button
|
size="large"
|
||||||
:class="['filter-tab', { active: !showMyOnly }]"
|
prepend-icon="mdi-upload"
|
||||||
@click="setFilter(false)"
|
@click="showUpload = true"
|
||||||
|
elevation="2"
|
||||||
>
|
>
|
||||||
Все треки
|
Загрузить трек
|
||||||
</button>
|
</v-btn>
|
||||||
<button
|
</div>
|
||||||
:class="['filter-tab', { active: showMyOnly }]"
|
|
||||||
@click="setFilter(true)"
|
<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"
|
||||||
>
|
>
|
||||||
Мои треки
|
<v-card-text class="text-center">
|
||||||
</button>
|
<v-icon size="80" color="warning" class="mb-4">mdi-file-search</v-icon>
|
||||||
</div>
|
<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">
|
<v-card v-else class="tracks-list-card" elevation="0">
|
||||||
<p>Нет треков. Загрузите первый!</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="tracks-list card">
|
|
||||||
<TrackList
|
<TrackList
|
||||||
:tracks="tracksStore.tracks"
|
:tracks="filteredTracks"
|
||||||
|
:current-user-id="authStore.user?.id"
|
||||||
@delete="handleDelete"
|
@delete="handleDelete"
|
||||||
/>
|
/>
|
||||||
</div>
|
</v-card>
|
||||||
|
|
||||||
<Modal v-if="showUpload" title="Загрузить трек" @close="showUpload = false">
|
<v-dialog v-model="showUpload" max-width="600">
|
||||||
<UploadTrack @uploaded="showUpload = false" />
|
<v-card>
|
||||||
</Modal>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useTracksStore } from '../stores/tracks'
|
import { useTracksStore } from '../stores/tracks'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
import TrackList from '../components/tracks/TrackList.vue'
|
import TrackList from '../components/tracks/TrackList.vue'
|
||||||
import UploadTrack from '../components/tracks/UploadTrack.vue'
|
import UploadTrack from '../components/tracks/UploadTrack.vue'
|
||||||
import Modal from '../components/common/Modal.vue'
|
|
||||||
|
|
||||||
const tracksStore = useTracksStore()
|
const tracksStore = useTracksStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const showUpload = ref(false)
|
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(() => {
|
onMounted(() => {
|
||||||
tracksStore.fetchTracks()
|
tracksStore.fetchTracks()
|
||||||
})
|
})
|
||||||
|
|
||||||
function setFilter(myOnly) {
|
function handleUploadComplete() {
|
||||||
showMyOnly.value = myOnly
|
showUpload.value = false
|
||||||
tracksStore.fetchTracks(myOnly)
|
tracksStore.fetchTracks()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(track) {
|
async function handleDelete(track) {
|
||||||
@@ -69,52 +185,71 @@ async function handleDelete(track) {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.tracks-page {
|
.tracks-page {
|
||||||
padding-top: 20px;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-section {
|
.header-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
margin-bottom: 24px;
|
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;
|
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;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading, .empty {
|
.loading-spinner {
|
||||||
text-align: center;
|
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;
|
padding: 40px;
|
||||||
color: #aaa;
|
margin-top: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-tabs {
|
@media (max-width: 600px) {
|
||||||
display: flex;
|
.header-section {
|
||||||
gap: 8px;
|
flex-direction: column;
|
||||||
margin-bottom: 16px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import vuetify from 'vite-plugin-vuetify'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
vuetify({ autoImport: true })
|
||||||
|
],
|
||||||
server: {
|
server: {
|
||||||
port: 4000,
|
port: 4000,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
Reference in New Issue
Block a user