initial commit
This commit is contained in:
63
README.md
Normal file
63
README.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Music Platform
|
||||
|
||||
Музыкальная платформа с функционалом, похожим на Яндекс.Музыку/Spotify.
|
||||
|
||||
## Возможности
|
||||
|
||||
- Регистрация и авторизация пользователей
|
||||
- Загрузка своих песен с обложками
|
||||
- Скачивание песен
|
||||
- Создание и управление плейлистами
|
||||
- Публичные и приватные плейлисты
|
||||
- Комнаты для совместного прослушивания с чатом
|
||||
- Dynamic Island плеер (как на iPhone)
|
||||
|
||||
## Технологии
|
||||
|
||||
- Frontend: React + Vite
|
||||
- Backend: Python FastAPI
|
||||
- База данных: SQLite
|
||||
- WebSocket для real-time функций
|
||||
|
||||
## Установка и запуск
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
python init_db.py
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
Backend будет доступен на http://localhost:8000
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Frontend будет доступен на http://localhost:5173
|
||||
|
||||
## Использование
|
||||
|
||||
1. Зарегистрируйтесь на сайте
|
||||
2. Загрузите свои песни через раздел "Загрузить"
|
||||
3. Создавайте плейлисты и добавляйте в них песни
|
||||
4. Создавайте комнаты для совместного прослушивания с друзьями
|
||||
5. Используйте Dynamic Island плеер для управления воспроизведением
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `POST /api/auth/register` - Регистрация
|
||||
- `POST /api/auth/login` - Вход
|
||||
- `POST /api/music/upload` - Загрузка песни
|
||||
- `GET /api/music/songs` - Получить все песни
|
||||
- `GET /api/music/download/{song_id}` - Скачать песню
|
||||
- `POST /api/playlists/create` - Создать плейлист
|
||||
- `GET /api/playlists/my-playlists` - Мои плейлисты
|
||||
- `POST /api/rooms/create` - Создать комнату
|
||||
- `WS /api/rooms/ws/{room_code}` - WebSocket для комнаты
|
||||
41
backend/add_owner_role.py
Normal file
41
backend/add_owner_role.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
Скрипт для добавления роли создателя (owner)
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
def migrate():
|
||||
conn = sqlite3.connect('music_platform.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
# Добавляем поле is_owner
|
||||
cursor.execute("ALTER TABLE users ADD COLUMN is_owner BOOLEAN DEFAULT 0")
|
||||
print("✓ Добавлено поле is_owner")
|
||||
except sqlite3.OperationalError as e:
|
||||
if "duplicate column name" in str(e):
|
||||
print("✓ Поле is_owner уже существует")
|
||||
else:
|
||||
print(f"✗ Ошибка при добавлении is_owner: {e}")
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Показываем пользователя Leuteg
|
||||
cursor.execute("SELECT id, username, is_admin, is_owner FROM users WHERE username = 'Leuteg'")
|
||||
user = cursor.fetchone()
|
||||
|
||||
if user:
|
||||
print(f"\nПользователь найден:")
|
||||
print(f"ID: {user[0]}, Username: {user[1]}, Admin: {user[2]}, Owner: {user[3]}")
|
||||
|
||||
# Делаем Leuteg создателем
|
||||
cursor.execute("UPDATE users SET is_owner = 1, is_admin = 1 WHERE username = 'Leuteg'")
|
||||
conn.commit()
|
||||
print(f"\n✓ Пользователь 'Leuteg' теперь СОЗДАТЕЛЬ с полными правами!")
|
||||
else:
|
||||
print("\n✗ Пользователь 'Leuteg' не найден")
|
||||
|
||||
conn.close()
|
||||
print("\n✓ Миграция завершена!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
55
backend/app/auth.py
Normal file
55
backend/app/auth.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from datetime import datetime, timedelta
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models.models import User
|
||||
|
||||
SECRET_KEY = "your-secret-key-change-in-production-make-it-very-long-and-secure"
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 1440 # 24 часа
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login")
|
||||
|
||||
def verify_password(plain_password, hashed_password):
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
def get_password_hash(password):
|
||||
return pwd_context.hash(password)
|
||||
|
||||
def create_access_token(data: dict):
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
except JWTError as e:
|
||||
print(f"JWT Error: {e}")
|
||||
raise credentials_exception
|
||||
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
|
||||
if user.is_banned:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Ваш аккаунт заблокирован"
|
||||
)
|
||||
|
||||
return user
|
||||
16
backend/app/database.py
Normal file
16
backend/app/database.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite:///./music_platform.db"
|
||||
|
||||
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
31
backend/app/main.py
Normal file
31
backend/app/main.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import os
|
||||
|
||||
app = FastAPI(title="Music Platform API")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
os.makedirs("uploads/music", exist_ok=True)
|
||||
os.makedirs("uploads/covers", exist_ok=True)
|
||||
|
||||
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||
|
||||
from app.routes import auth, music, playlists, rooms, admin
|
||||
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(music.router, prefix="/api/music", tags=["music"])
|
||||
app.include_router(playlists.router, prefix="/api/playlists", tags=["playlists"])
|
||||
app.include_router(rooms.router, prefix="/api/rooms", tags=["rooms"])
|
||||
app.include_router(admin.router)
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {"message": "Music Platform API"}
|
||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
62
backend/app/models/models.py
Normal file
62
backend/app/models/models.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from sqlalchemy import Column, Integer, String, ForeignKey, Table, DateTime, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from app.database import Base
|
||||
|
||||
playlist_songs = Table('playlist_songs', Base.metadata,
|
||||
Column('playlist_id', Integer, ForeignKey('playlists.id')),
|
||||
Column('song_id', Integer, ForeignKey('songs.id'))
|
||||
)
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String, unique=True, index=True)
|
||||
email = Column(String, unique=True, index=True)
|
||||
hashed_password = Column(String)
|
||||
is_admin = Column(Boolean, default=False)
|
||||
is_owner = Column(Boolean, default=False)
|
||||
is_banned = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
songs = relationship("Song", back_populates="owner")
|
||||
playlists = relationship("Playlist", back_populates="owner")
|
||||
rooms = relationship("Room", back_populates="creator")
|
||||
|
||||
class Song(Base):
|
||||
__tablename__ = "songs"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
title = Column(String, index=True)
|
||||
artist = Column(String)
|
||||
file_path = Column(String)
|
||||
cover_path = Column(String, nullable=True)
|
||||
duration = Column(Integer)
|
||||
owner_id = Column(Integer, ForeignKey("users.id"))
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
owner = relationship("User", back_populates="songs")
|
||||
playlists = relationship("Playlist", secondary=playlist_songs, back_populates="songs")
|
||||
|
||||
class Playlist(Base):
|
||||
__tablename__ = "playlists"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String)
|
||||
description = Column(String, nullable=True)
|
||||
is_public = Column(Boolean, default=False)
|
||||
owner_id = Column(Integer, ForeignKey("users.id"))
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
owner = relationship("User", back_populates="playlists")
|
||||
songs = relationship("Song", secondary=playlist_songs, back_populates="playlists")
|
||||
|
||||
class Room(Base):
|
||||
__tablename__ = "rooms"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String)
|
||||
code = Column(String, unique=True, index=True)
|
||||
creator_id = Column(Integer, ForeignKey("users.id"))
|
||||
current_song_id = Column(Integer, ForeignKey("songs.id"), nullable=True)
|
||||
is_playing = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
creator = relationship("User", back_populates="rooms")
|
||||
0
backend/app/routes/__init__.py
Normal file
0
backend/app/routes/__init__.py
Normal file
174
backend/app/routes/admin.py
Normal file
174
backend/app/routes/admin.py
Normal file
@@ -0,0 +1,174 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models.models import User, Song
|
||||
from app.auth import get_current_user
|
||||
from pydantic import BaseModel
|
||||
import os
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
def get_admin_user(current_user: User = Depends(get_current_user)):
|
||||
if not current_user.is_admin and not current_user.is_owner:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Доступ запрещен. Требуются права администратора"
|
||||
)
|
||||
return current_user
|
||||
|
||||
def get_owner_user(current_user: User = Depends(get_current_user)):
|
||||
if not current_user.is_owner:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Доступ запрещен. Требуются права создателя"
|
||||
)
|
||||
return current_user
|
||||
|
||||
class UpdateSongRequest(BaseModel):
|
||||
title: str
|
||||
artist: str
|
||||
|
||||
class BanUserRequest(BaseModel):
|
||||
user_id: int
|
||||
is_banned: bool
|
||||
|
||||
class PromoteUserRequest(BaseModel):
|
||||
user_id: int
|
||||
is_admin: bool
|
||||
|
||||
@router.get("/users")
|
||||
def get_all_users(
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(get_admin_user)
|
||||
):
|
||||
users = db.query(User).all()
|
||||
return [{
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"is_admin": user.is_admin,
|
||||
"is_owner": user.is_owner,
|
||||
"is_banned": user.is_banned,
|
||||
"created_at": user.created_at
|
||||
} for user in users]
|
||||
|
||||
@router.get("/songs")
|
||||
def get_all_songs(
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(get_admin_user)
|
||||
):
|
||||
songs = db.query(Song).all()
|
||||
return [{
|
||||
"id": song.id,
|
||||
"title": song.title,
|
||||
"artist": song.artist,
|
||||
"file_path": song.file_path,
|
||||
"cover_path": song.cover_path,
|
||||
"owner_id": song.owner_id,
|
||||
"owner_username": song.owner.username if song.owner else None,
|
||||
"created_at": song.created_at
|
||||
} for song in songs]
|
||||
|
||||
@router.put("/songs/{song_id}")
|
||||
def update_song(
|
||||
song_id: int,
|
||||
request: UpdateSongRequest,
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(get_admin_user)
|
||||
):
|
||||
song = db.query(Song).filter(Song.id == song_id).first()
|
||||
if not song:
|
||||
raise HTTPException(status_code=404, detail="Песня не найдена")
|
||||
|
||||
song.title = request.title
|
||||
song.artist = request.artist
|
||||
db.commit()
|
||||
db.refresh(song)
|
||||
|
||||
return {"message": "Песня обновлена", "song": {
|
||||
"id": song.id,
|
||||
"title": song.title,
|
||||
"artist": song.artist
|
||||
}}
|
||||
|
||||
@router.delete("/songs/{song_id}")
|
||||
def delete_song(
|
||||
song_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(get_admin_user)
|
||||
):
|
||||
song = db.query(Song).filter(Song.id == song_id).first()
|
||||
if not song:
|
||||
raise HTTPException(status_code=404, detail="Песня не найдена")
|
||||
|
||||
# Удаляем файлы
|
||||
if song.file_path and os.path.exists(song.file_path):
|
||||
os.remove(song.file_path)
|
||||
if song.cover_path and os.path.exists(song.cover_path):
|
||||
os.remove(song.cover_path)
|
||||
|
||||
db.delete(song)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Песня удалена"}
|
||||
|
||||
@router.post("/users/ban")
|
||||
def ban_user(
|
||||
request: BanUserRequest,
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(get_admin_user)
|
||||
):
|
||||
user = db.query(User).filter(User.id == request.user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
|
||||
if user.is_admin or user.is_owner:
|
||||
raise HTTPException(status_code=400, detail="Нельзя забанить администратора или создателя")
|
||||
|
||||
user.is_banned = request.is_banned
|
||||
db.commit()
|
||||
|
||||
return {"message": f"Пользователь {'забанен' if request.is_banned else 'разбанен'}"}
|
||||
|
||||
@router.post("/users/promote")
|
||||
def promote_user(
|
||||
request: PromoteUserRequest,
|
||||
db: Session = Depends(get_db),
|
||||
owner: User = Depends(get_owner_user)
|
||||
):
|
||||
user = db.query(User).filter(User.id == request.user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
|
||||
if user.is_owner:
|
||||
raise HTTPException(status_code=400, detail="Нельзя изменить права создателя")
|
||||
|
||||
user.is_admin = request.is_admin
|
||||
db.commit()
|
||||
|
||||
return {"message": f"Пользователь {'повышен до администратора' if request.is_admin else 'понижен до пользователя'}"}
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
def delete_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(get_admin_user)
|
||||
):
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
|
||||
if user.is_admin or user.is_owner:
|
||||
raise HTTPException(status_code=400, detail="Нельзя удалить администратора или создателя")
|
||||
|
||||
# Удаляем все песни пользователя
|
||||
for song in user.songs:
|
||||
if song.file_path and os.path.exists(song.file_path):
|
||||
os.remove(song.file_path)
|
||||
if song.cover_path and os.path.exists(song.cover_path):
|
||||
os.remove(song.cover_path)
|
||||
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Пользователь удален"}
|
||||
57
backend/app/routes/auth.py
Normal file
57
backend/app/routes/auth.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from app.database import get_db
|
||||
from app.models.models import User
|
||||
from app.auth import get_password_hash, verify_password, create_access_token, get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
email: str
|
||||
password: str
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
@router.post("/register")
|
||||
def register(user: UserCreate, db: Session = Depends(get_db)):
|
||||
if db.query(User).filter(User.username == user.username).first():
|
||||
raise HTTPException(status_code=400, detail="Username already exists")
|
||||
if db.query(User).filter(User.email == user.email).first():
|
||||
raise HTTPException(status_code=400, detail="Email already exists")
|
||||
|
||||
db_user = User(
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
hashed_password=get_password_hash(user.password)
|
||||
)
|
||||
db.add(db_user)
|
||||
db.commit()
|
||||
db.refresh(db_user)
|
||||
return {"message": "User created successfully"}
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.username == form_data.username).first()
|
||||
if not user or not verify_password(form_data.password, user.hashed_password):
|
||||
raise HTTPException(status_code=401, detail="Incorrect username or password")
|
||||
|
||||
if user.is_banned:
|
||||
raise HTTPException(status_code=403, detail="Ваш аккаунт заблокирован")
|
||||
|
||||
access_token = create_access_token(data={"sub": user.username})
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
@router.get("/me")
|
||||
def get_current_user_info(current_user: User = Depends(get_current_user)):
|
||||
return {
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"email": current_user.email,
|
||||
"is_admin": current_user.is_admin,
|
||||
"is_owner": current_user.is_owner
|
||||
}
|
||||
72
backend/app/routes/music.py
Normal file
72
backend/app/routes/music.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from pydantic import BaseModel
|
||||
import shutil
|
||||
import os
|
||||
from app.database import get_db
|
||||
from app.models.models import Song, User
|
||||
from app.auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class SongResponse(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
artist: str
|
||||
file_path: str
|
||||
cover_path: str | None
|
||||
duration: int
|
||||
owner_id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_song(
|
||||
title: str = Form(...),
|
||||
artist: str = Form(...),
|
||||
duration: int = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
cover: UploadFile = File(None),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
file_path = f"uploads/music/{current_user.id}_{file.filename}"
|
||||
with open(file_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
cover_path = None
|
||||
if cover:
|
||||
cover_path = f"uploads/covers/{current_user.id}_{cover.filename}"
|
||||
with open(cover_path, "wb") as buffer:
|
||||
shutil.copyfileobj(cover.file, buffer)
|
||||
|
||||
song = Song(
|
||||
title=title,
|
||||
artist=artist,
|
||||
file_path=file_path,
|
||||
cover_path=cover_path,
|
||||
duration=duration,
|
||||
owner_id=current_user.id
|
||||
)
|
||||
db.add(song)
|
||||
db.commit()
|
||||
db.refresh(song)
|
||||
return song
|
||||
|
||||
@router.get("/songs", response_model=List[SongResponse])
|
||||
def get_songs(db: Session = Depends(get_db)):
|
||||
return db.query(Song).all()
|
||||
|
||||
@router.get("/my-songs", response_model=List[SongResponse])
|
||||
def get_my_songs(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
return db.query(Song).filter(Song.owner_id == current_user.id).all()
|
||||
|
||||
@router.get("/download/{song_id}")
|
||||
def download_song(song_id: int, db: Session = Depends(get_db)):
|
||||
song = db.query(Song).filter(Song.id == song_id).first()
|
||||
if not song:
|
||||
raise HTTPException(status_code=404, detail="Song not found")
|
||||
return FileResponse(song.file_path, filename=f"{song.title}.mp3")
|
||||
112
backend/app/routes/playlists.py
Normal file
112
backend/app/routes/playlists.py
Normal file
@@ -0,0 +1,112 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from pydantic import BaseModel
|
||||
from app.database import get_db
|
||||
from app.models.models import Playlist, Song, User
|
||||
from app.auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class PlaylistCreate(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
is_public: bool = False
|
||||
|
||||
class PlaylistResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: str | None
|
||||
is_public: bool
|
||||
owner_id: int
|
||||
songs: List[dict]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@router.post("/create")
|
||||
def create_playlist(
|
||||
playlist: PlaylistCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
db_playlist = Playlist(
|
||||
name=playlist.name,
|
||||
description=playlist.description,
|
||||
is_public=playlist.is_public,
|
||||
owner_id=current_user.id
|
||||
)
|
||||
db.add(db_playlist)
|
||||
db.commit()
|
||||
db.refresh(db_playlist)
|
||||
return db_playlist
|
||||
|
||||
@router.get("/my-playlists")
|
||||
def get_my_playlists(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
return db.query(Playlist).filter(Playlist.owner_id == current_user.id).all()
|
||||
|
||||
@router.get("/public")
|
||||
def get_public_playlists(db: Session = Depends(get_db)):
|
||||
return db.query(Playlist).filter(Playlist.is_public == True).all()
|
||||
|
||||
@router.post("/{playlist_id}/add-song/{song_id}")
|
||||
def add_song_to_playlist(
|
||||
playlist_id: int,
|
||||
song_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
playlist = db.query(Playlist).filter(Playlist.id == playlist_id).first()
|
||||
if not playlist or playlist.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
|
||||
song = db.query(Song).filter(Song.id == song_id).first()
|
||||
if not song:
|
||||
raise HTTPException(status_code=404, detail="Song not found")
|
||||
|
||||
playlist.songs.append(song)
|
||||
db.commit()
|
||||
return {"message": "Song added to playlist"}
|
||||
|
||||
@router.get("/{playlist_id}")
|
||||
def get_playlist(playlist_id: int, db: Session = Depends(get_db)):
|
||||
playlist = db.query(Playlist).filter(Playlist.id == playlist_id).first()
|
||||
if not playlist:
|
||||
raise HTTPException(status_code=404, detail="Playlist not found")
|
||||
|
||||
songs_data = [{
|
||||
"id": song.id,
|
||||
"title": song.title,
|
||||
"artist": song.artist,
|
||||
"file_path": song.file_path,
|
||||
"cover_path": song.cover_path,
|
||||
"duration": song.duration,
|
||||
"owner_id": song.owner_id
|
||||
} for song in playlist.songs]
|
||||
|
||||
return {
|
||||
"id": playlist.id,
|
||||
"name": playlist.name,
|
||||
"description": playlist.description,
|
||||
"is_public": playlist.is_public,
|
||||
"owner_id": playlist.owner_id,
|
||||
"songs": songs_data
|
||||
}
|
||||
|
||||
@router.delete("/{playlist_id}/remove-song/{song_id}")
|
||||
def remove_song_from_playlist(
|
||||
playlist_id: int,
|
||||
song_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
playlist = db.query(Playlist).filter(Playlist.id == playlist_id).first()
|
||||
if not playlist or playlist.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
|
||||
song = db.query(Song).filter(Song.id == song_id).first()
|
||||
if song in playlist.songs:
|
||||
playlist.songs.remove(song)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Song removed from playlist"}
|
||||
128
backend/app/routes/rooms.py
Normal file
128
backend/app/routes/rooms.py
Normal file
@@ -0,0 +1,128 @@
|
||||
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Dict, List
|
||||
from pydantic import BaseModel
|
||||
import random
|
||||
import string
|
||||
from app.database import get_db
|
||||
from app.models.models import Room, User, Song, Playlist
|
||||
from app.auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class RoomCreate(BaseModel):
|
||||
name: str
|
||||
|
||||
class ConnectionManager:
|
||||
def __init__(self):
|
||||
self.active_connections: Dict[str, List[dict]] = {}
|
||||
|
||||
async def connect(self, websocket: WebSocket, room_code: str, username: str):
|
||||
await websocket.accept()
|
||||
if room_code not in self.active_connections:
|
||||
self.active_connections[room_code] = []
|
||||
|
||||
# Проверяем, не подключен ли уже этот пользователь
|
||||
existing = [conn for conn in self.active_connections[room_code] if conn["username"] == username]
|
||||
if not existing:
|
||||
self.active_connections[room_code].append({"ws": websocket, "username": username})
|
||||
|
||||
await self.broadcast({"type": "user_count", "count": len(self.active_connections[room_code])}, room_code)
|
||||
|
||||
def disconnect(self, websocket: WebSocket, room_code: str):
|
||||
if room_code in self.active_connections:
|
||||
self.active_connections[room_code] = [
|
||||
conn for conn in self.active_connections[room_code] if conn["ws"] != websocket
|
||||
]
|
||||
return len(self.active_connections[room_code])
|
||||
return 0
|
||||
|
||||
async def broadcast(self, message: dict, room_code: str):
|
||||
if room_code in self.active_connections:
|
||||
disconnected = []
|
||||
for connection in self.active_connections[room_code]:
|
||||
try:
|
||||
await connection["ws"].send_json(message)
|
||||
except:
|
||||
disconnected.append(connection)
|
||||
|
||||
# Удаляем отключенные соединения
|
||||
for conn in disconnected:
|
||||
if conn in self.active_connections[room_code]:
|
||||
self.active_connections[room_code].remove(conn)
|
||||
|
||||
def get_user_count(self, room_code: str):
|
||||
if room_code in self.active_connections:
|
||||
return len(self.active_connections[room_code])
|
||||
return 0
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
def generate_room_code():
|
||||
return ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
|
||||
|
||||
@router.post("/create")
|
||||
def create_room(
|
||||
room: RoomCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
code = generate_room_code()
|
||||
db_room = Room(name=room.name, code=code, creator_id=current_user.id)
|
||||
db.add(db_room)
|
||||
db.commit()
|
||||
db.refresh(db_room)
|
||||
return db_room
|
||||
|
||||
@router.get("/{room_code}")
|
||||
def get_room(room_code: str, db: Session = Depends(get_db)):
|
||||
room = db.query(Room).filter(Room.code == room_code).first()
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
user_count = manager.get_user_count(room_code)
|
||||
return {**room.__dict__, "user_count": user_count}
|
||||
|
||||
@router.post("/{room_code}/add-song/{song_id}")
|
||||
def add_song_to_room(
|
||||
room_code: str,
|
||||
song_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
room = db.query(Room).filter(Room.code == room_code).first()
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
|
||||
song = db.query(Song).filter(Song.id == song_id).first()
|
||||
if not song:
|
||||
raise HTTPException(status_code=404, detail="Song not found")
|
||||
|
||||
return {"message": "Song added to queue"}
|
||||
|
||||
@router.post("/{room_code}/add-playlist/{playlist_id}")
|
||||
def add_playlist_to_room(
|
||||
room_code: str,
|
||||
playlist_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
room = db.query(Room).filter(Room.code == room_code).first()
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
|
||||
playlist = db.query(Playlist).filter(Playlist.id == playlist_id).first()
|
||||
if not playlist:
|
||||
raise HTTPException(status_code=404, detail="Playlist not found")
|
||||
|
||||
return {"message": "Playlist added to queue", "songs": [{"id": s.id, "title": s.title, "artist": s.artist} for s in playlist.songs]}
|
||||
|
||||
@router.websocket("/ws/{room_code}")
|
||||
async def websocket_endpoint(websocket: WebSocket, room_code: str, username: str = "User"):
|
||||
await manager.connect(websocket, room_code, username)
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
await manager.broadcast(data, room_code)
|
||||
except WebSocketDisconnect:
|
||||
count = manager.disconnect(websocket, room_code)
|
||||
await manager.broadcast({"type": "user_count", "count": count}, room_code)
|
||||
5
backend/init_db.py
Normal file
5
backend/init_db.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from app.database import engine, Base
|
||||
from app.models.models import User, Song, Playlist, Room
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
print("Database initialized successfully!")
|
||||
52
backend/migrate_db.py
Normal file
52
backend/migrate_db.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
Скрипт для миграции базы данных - добавляет поля is_admin и is_banned
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
def migrate():
|
||||
conn = sqlite3.connect('music_platform.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
# Добавляем поле is_admin
|
||||
cursor.execute("ALTER TABLE users ADD COLUMN is_admin BOOLEAN DEFAULT 0")
|
||||
print("✓ Добавлено поле is_admin")
|
||||
except sqlite3.OperationalError as e:
|
||||
if "duplicate column name" in str(e):
|
||||
print("✓ Поле is_admin уже существует")
|
||||
else:
|
||||
print(f"✗ Ошибка при добавлении is_admin: {e}")
|
||||
|
||||
try:
|
||||
# Добавляем поле is_banned
|
||||
cursor.execute("ALTER TABLE users ADD COLUMN is_banned BOOLEAN DEFAULT 0")
|
||||
print("✓ Добавлено поле is_banned")
|
||||
except sqlite3.OperationalError as e:
|
||||
if "duplicate column name" in str(e):
|
||||
print("✓ Поле is_banned уже существует")
|
||||
else:
|
||||
print(f"✗ Ошибка при добавлении is_banned: {e}")
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Показываем всех пользователей
|
||||
cursor.execute("SELECT id, username, is_admin, is_banned FROM users")
|
||||
users = cursor.fetchall()
|
||||
|
||||
print("\nТекущие пользователи:")
|
||||
for user in users:
|
||||
print(f"ID: {user[0]}, Username: {user[1]}, Admin: {user[2]}, Banned: {user[3]}")
|
||||
|
||||
# Предлагаем сделать первого пользователя админом
|
||||
if users:
|
||||
make_admin = input(f"\nСделать пользователя '{users[0][1]}' администратором? (y/n): ")
|
||||
if make_admin.lower() == 'y':
|
||||
cursor.execute("UPDATE users SET is_admin = 1 WHERE id = ?", (users[0][0],))
|
||||
conn.commit()
|
||||
print(f"✓ Пользователь '{users[0][1]}' теперь администратор")
|
||||
|
||||
conn.close()
|
||||
print("\n✓ Миграция завершена!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
10
backend/requirements.txt
Normal file
10
backend/requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
fastapi==0.109.0
|
||||
uvicorn==0.27.0
|
||||
sqlalchemy==2.0.25
|
||||
python-multipart==0.0.6
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
websockets==12.0
|
||||
aiofiles==23.2.1
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Music Platform</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2052
frontend/package-lock.json
generated
Normal file
2052
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
frontend/package.json
Normal file
24
frontend/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "music-platform-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.1",
|
||||
"axios": "^1.6.5",
|
||||
"lucide-react": "^0.309.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"vite": "^5.0.11"
|
||||
}
|
||||
}
|
||||
61
frontend/src/App.jsx
Normal file
61
frontend/src/App.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useState, useEffect, createContext } from 'react'
|
||||
import Login from './pages/Login'
|
||||
import Register from './pages/Register'
|
||||
import Home from './pages/Home'
|
||||
import Upload from './pages/Upload'
|
||||
import Playlists from './pages/Playlists'
|
||||
import Rooms from './pages/Rooms'
|
||||
import Room from './pages/Room'
|
||||
import Admin from './pages/Admin'
|
||||
import DynamicPlayer from './components/DynamicPlayer'
|
||||
import Layout from './components/Layout'
|
||||
|
||||
// Создаем контекст для управления аудио
|
||||
export const AudioContext = createContext()
|
||||
|
||||
function App() {
|
||||
const [token, setToken] = useState(localStorage.getItem('token'))
|
||||
const [currentSong, setCurrentSong] = useState(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [isInRoom, setIsInRoom] = useState(false)
|
||||
const [playlist, setPlaylist] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
localStorage.setItem('token', token)
|
||||
} else {
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
}, [token])
|
||||
|
||||
// Функция для остановки глобального плеера
|
||||
const stopGlobalPlayer = () => {
|
||||
setIsPlaying(false)
|
||||
}
|
||||
|
||||
const handleSongChange = (song) => {
|
||||
setCurrentSong(song)
|
||||
setIsPlaying(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<AudioContext.Provider value={{ stopGlobalPlayer, isInRoom, setIsInRoom }}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={!token ? <Login setToken={setToken} /> : <Navigate to="/" />} />
|
||||
<Route path="/register" element={!token ? <Register /> : <Navigate to="/" />} />
|
||||
<Route path="/" element={token ? <Layout><Home setCurrentSong={setCurrentSong} setIsPlaying={setIsPlaying} setPlaylist={setPlaylist} /></Layout> : <Navigate to="/login" />} />
|
||||
<Route path="/upload" element={token ? <Layout><Upload /></Layout> : <Navigate to="/login" />} />
|
||||
<Route path="/playlists" element={token ? <Layout><Playlists setCurrentSong={setCurrentSong} setPlaylist={setPlaylist} /></Layout> : <Navigate to="/login" />} />
|
||||
<Route path="/rooms" element={token ? <Layout><Rooms /></Layout> : <Navigate to="/login" />} />
|
||||
<Route path="/room/:code" element={token ? <Room /> : <Navigate to="/login" />} />
|
||||
<Route path="/admin" element={token ? <Layout><Admin /></Layout> : <Navigate to="/login" />} />
|
||||
</Routes>
|
||||
{currentSong && !isInRoom && <DynamicPlayer song={currentSong} isPlaying={isPlaying} setIsPlaying={setIsPlaying} playlist={playlist} onSongChange={handleSongChange} />}
|
||||
</BrowserRouter>
|
||||
</AudioContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
452
frontend/src/components/DynamicPlayer.css
Normal file
452
frontend/src/components/DynamicPlayer.css
Normal file
@@ -0,0 +1,452 @@
|
||||
.dynamic-player {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 40px;
|
||||
padding: 12px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||
animation: slideDown 0.5s ease;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.dynamic-player.expanded {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
max-width: 450px;
|
||||
width: 90%;
|
||||
height: 600px;
|
||||
border-radius: 30px;
|
||||
padding: 40px 30px;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
animation: expandPlayer 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
transform: translateX(-50%) translateY(-100px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes expandPlayer {
|
||||
from {
|
||||
top: 20px;
|
||||
transform: translateX(-50%);
|
||||
height: 74px;
|
||||
}
|
||||
to {
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
height: 600px;
|
||||
}
|
||||
}
|
||||
|
||||
.player-mini-cover {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.player-mini-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.mini-default-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.player-info-mini {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.player-info-mini h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.player-info-mini p {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.player-controls-mini {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.control-btn.play-btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.control-btn.play-btn:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
.player-progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0 0 40px 40px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.dynamic-player {
|
||||
padding: 10px 16px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.player-mini-cover {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
}
|
||||
}
|
||||
|
||||
.player-expanded-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.collapse-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.expanded-cover {
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
margin: 60px 0 30px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.expanded-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.expanded-default-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 120px;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.expanded-info {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.expanded-info h2 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.expanded-info p {
|
||||
font-size: 16px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.expanded-progress-container {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.expanded-progress-track {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.expanded-progress-track:hover .expanded-progress-bar::after {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) scale(1.2);
|
||||
}
|
||||
|
||||
.expanded-progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 3px;
|
||||
transition: width 0.1s linear;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.expanded-progress-bar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -7px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.expanded-progress-track:active .expanded-progress-bar::after {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) scale(1.3);
|
||||
}
|
||||
|
||||
.expanded-progress-time {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.expanded-controls {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.control-btn-large {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.control-btn-large:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.play-btn-large {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.play-btn-large:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
.show-player-btn {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 30px;
|
||||
padding: 12px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
z-index: 1000;
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||
animation: slideDown 0.5s ease;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.show-player-btn:hover {
|
||||
background: rgba(0, 0, 0, 1);
|
||||
transform: translateX(-50%) translateY(-2px);
|
||||
}
|
||||
|
||||
.show-player-btn span {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.volume-control {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 5px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.volume-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
transition: all 0.3s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.volume-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.volume-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.volume-slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.3);
|
||||
box-shadow: 0 4px 10px rgba(102, 126, 234, 0.6);
|
||||
}
|
||||
|
||||
.volume-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.volume-slider::-moz-range-thumb:hover {
|
||||
transform: scale(1.3);
|
||||
box-shadow: 0 4px 10px rgba(102, 126, 234, 0.6);
|
||||
}
|
||||
|
||||
.volume-slider::-webkit-slider-runnable-track {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.volume-slider::-moz-range-track {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.volume-percentage {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-weight: 500;
|
||||
min-width: 42px;
|
||||
text-align: right;
|
||||
}
|
||||
273
frontend/src/components/DynamicPlayer.jsx
Normal file
273
frontend/src/components/DynamicPlayer.jsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Play, Pause, SkipForward, SkipBack, ChevronDown, ChevronUp, Volume2, VolumeX } from 'lucide-react'
|
||||
import './DynamicPlayer.css'
|
||||
|
||||
function DynamicPlayer({ song, isPlaying, setIsPlaying, playlist = [], onSongChange }) {
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [isHidden, setIsHidden] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [volume, setVolume] = useState(1)
|
||||
const [isMuted, setIsMuted] = useState(false)
|
||||
const audioRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (audioRef.current && song) {
|
||||
audioRef.current.src = `http://localhost:8000/${song.file_path}`
|
||||
audioRef.current.volume = volume
|
||||
audioRef.current.load()
|
||||
if (isPlaying) {
|
||||
audioRef.current.play().catch(err => console.error('Play error:', err))
|
||||
}
|
||||
}
|
||||
}, [song])
|
||||
|
||||
useEffect(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.volume = isMuted ? 0 : volume
|
||||
}
|
||||
}, [volume, isMuted])
|
||||
|
||||
useEffect(() => {
|
||||
if (audioRef.current && audioRef.current.src) {
|
||||
if (isPlaying) {
|
||||
audioRef.current.play().catch(err => console.error('Play error:', err))
|
||||
} else {
|
||||
audioRef.current.pause()
|
||||
}
|
||||
}
|
||||
}, [isPlaying])
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (audioRef.current && audioRef.current.duration) {
|
||||
const percent = (audioRef.current.currentTime / audioRef.current.duration) * 100
|
||||
setProgress(isNaN(percent) ? 0 : percent)
|
||||
setCurrentTime(audioRef.current.currentTime)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
if (audioRef.current) {
|
||||
setDuration(audioRef.current.duration)
|
||||
}
|
||||
}
|
||||
|
||||
const togglePlay = (e) => {
|
||||
e.stopPropagation()
|
||||
setIsPlaying(!isPlaying)
|
||||
}
|
||||
|
||||
const formatTime = (time) => {
|
||||
if (isNaN(time) || !isFinite(time)) return '0:00'
|
||||
const minutes = Math.floor(time / 60)
|
||||
const seconds = Math.floor(time % 60)
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const handleProgressClick = (e) => {
|
||||
if (audioRef.current && audioRef.current.duration) {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const percent = (e.clientX - rect.left) / rect.width
|
||||
const newTime = percent * audioRef.current.duration
|
||||
audioRef.current.currentTime = newTime
|
||||
setProgress(percent * 100)
|
||||
setCurrentTime(newTime)
|
||||
}
|
||||
}
|
||||
|
||||
const handleProgressMouseDown = (e) => {
|
||||
setIsDragging(true)
|
||||
handleProgressClick(e)
|
||||
}
|
||||
|
||||
const handleProgressMouseMove = (e) => {
|
||||
if (isDragging && audioRef.current && audioRef.current.duration) {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
const newTime = percent * audioRef.current.duration
|
||||
audioRef.current.currentTime = newTime
|
||||
setProgress(percent * 100)
|
||||
setCurrentTime(newTime)
|
||||
}
|
||||
}
|
||||
|
||||
const handleProgressMouseUp = () => {
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
document.addEventListener('mouseup', handleProgressMouseUp)
|
||||
return () => {
|
||||
document.removeEventListener('mouseup', handleProgressMouseUp)
|
||||
}
|
||||
}
|
||||
}, [isDragging])
|
||||
|
||||
const handleHide = (e) => {
|
||||
e.stopPropagation()
|
||||
setIsHidden(true)
|
||||
// Не останавливаем воспроизведение при скрытии
|
||||
}
|
||||
|
||||
const handleShow = () => {
|
||||
setIsHidden(false)
|
||||
}
|
||||
|
||||
const handleVolumeChange = (e) => {
|
||||
const newVolume = parseFloat(e.target.value)
|
||||
setVolume(newVolume)
|
||||
if (newVolume > 0) {
|
||||
setIsMuted(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMute = () => {
|
||||
setIsMuted(!isMuted)
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (playlist.length === 0 || !song) return
|
||||
const currentIndex = playlist.findIndex(s => s.id === song.id)
|
||||
const nextIndex = (currentIndex + 1) % playlist.length
|
||||
if (onSongChange) {
|
||||
onSongChange(playlist[nextIndex])
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (playlist.length === 0 || !song) return
|
||||
const currentIndex = playlist.findIndex(s => s.id === song.id)
|
||||
const prevIndex = currentIndex === 0 ? playlist.length - 1 : currentIndex - 1
|
||||
if (onSongChange) {
|
||||
onSongChange(playlist[prevIndex])
|
||||
}
|
||||
}
|
||||
|
||||
if (!song) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Аудио элемент всегда рендерится, даже когда плеер скрыт */}
|
||||
<audio
|
||||
ref={audioRef}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
/>
|
||||
|
||||
{!isHidden && (
|
||||
<div className={`dynamic-player ${isExpanded ? 'expanded' : ''}`}>
|
||||
{!isExpanded ? (
|
||||
<>
|
||||
<div className="player-mini-cover" onClick={() => setIsExpanded(true)}>
|
||||
{song.cover_path ? (
|
||||
<img src={`http://localhost:8000/${song.cover_path}`} alt={song.title} />
|
||||
) : (
|
||||
<div className="mini-default-cover">♪</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="player-info-mini" onClick={() => setIsExpanded(true)}>
|
||||
<h4>{song.title}</h4>
|
||||
<p>{song.artist}</p>
|
||||
</div>
|
||||
|
||||
<div className="player-controls-mini">
|
||||
<button className="control-btn" onClick={handlePrevious}>
|
||||
<SkipBack size={18} />
|
||||
</button>
|
||||
<button className="control-btn play-btn" onClick={togglePlay}>
|
||||
{isPlaying ? <Pause size={20} /> : <Play size={20} />}
|
||||
</button>
|
||||
<button className="control-btn" onClick={handleNext}>
|
||||
<SkipForward size={18} />
|
||||
</button>
|
||||
<button className="control-btn" onClick={handleHide}>
|
||||
<ChevronUp size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="player-progress">
|
||||
<div className="progress-bar" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="player-expanded-content">
|
||||
<button className="collapse-btn" onClick={() => setIsExpanded(false)}>
|
||||
<ChevronDown size={24} />
|
||||
</button>
|
||||
|
||||
<div className="expanded-cover">
|
||||
{song.cover_path ? (
|
||||
<img src={`http://localhost:8000/${song.cover_path}`} alt={song.title} />
|
||||
) : (
|
||||
<div className="expanded-default-cover">♪</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="expanded-info">
|
||||
<h2>{song.title}</h2>
|
||||
<p>{song.artist}</p>
|
||||
</div>
|
||||
|
||||
<div className="expanded-progress-container">
|
||||
<div
|
||||
className="expanded-progress-track"
|
||||
onClick={handleProgressClick}
|
||||
onMouseDown={handleProgressMouseDown}
|
||||
onMouseMove={handleProgressMouseMove}
|
||||
>
|
||||
<div className="expanded-progress-bar" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="expanded-progress-time">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="expanded-controls">
|
||||
<button className="control-btn-large" onClick={handlePrevious}>
|
||||
<SkipBack size={28} />
|
||||
</button>
|
||||
<button className="control-btn-large play-btn-large" onClick={togglePlay}>
|
||||
{isPlaying ? <Pause size={36} /> : <Play size={36} />}
|
||||
</button>
|
||||
<button className="control-btn-large" onClick={handleNext}>
|
||||
<SkipForward size={28} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="volume-control">
|
||||
<button className="volume-btn" onClick={toggleMute}>
|
||||
{isMuted || volume === 0 ? <VolumeX size={20} /> : <Volume2 size={20} />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={isMuted ? 0 : volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="volume-slider"
|
||||
/>
|
||||
<span className="volume-percentage">{Math.round((isMuted ? 0 : volume) * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isHidden && (
|
||||
<button className="show-player-btn" onClick={handleShow}>
|
||||
<ChevronDown size={20} />
|
||||
<span>Показать плеер</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DynamicPlayer
|
||||
19
frontend/src/components/Layout.css
Normal file
19
frontend/src/components/Layout.css
Normal file
@@ -0,0 +1,19 @@
|
||||
.layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-left: 260px;
|
||||
padding: 40px;
|
||||
padding-bottom: 120px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
margin-left: 80px;
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
15
frontend/src/components/Layout.jsx
Normal file
15
frontend/src/components/Layout.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import Sidebar from './Sidebar'
|
||||
import './Layout.css'
|
||||
|
||||
function Layout({ children }) {
|
||||
return (
|
||||
<div className="layout">
|
||||
<Sidebar />
|
||||
<main className="main-content">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Layout
|
||||
140
frontend/src/components/Sidebar.css
Normal file
140
frontend/src/components/Sidebar.css
Normal file
@@ -0,0 +1,140 @@
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 260px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(20px);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 30px 20px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 40px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.sidebar-header h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.nav-item span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
margin-top: auto;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background: rgba(255, 77, 77, 0.2);
|
||||
color: #ff4d4d;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 80px;
|
||||
padding: 20px 10px;
|
||||
}
|
||||
|
||||
.sidebar-header h2,
|
||||
.nav-item span,
|
||||
.logout-btn span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-item,
|
||||
.logout-btn {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-item.admin-link {
|
||||
border: 2px solid rgba(255, 204, 0, 0.3);
|
||||
background: rgba(255, 204, 0, 0.05);
|
||||
}
|
||||
|
||||
.nav-item.admin-link:hover {
|
||||
background: rgba(255, 204, 0, 0.15);
|
||||
border-color: rgba(255, 204, 0, 0.5);
|
||||
}
|
||||
|
||||
.nav-item.admin-link.active {
|
||||
background: linear-gradient(135deg, #ffcc00 0%, #ff9500 100%);
|
||||
border-color: transparent;
|
||||
box-shadow: 0 4px 15px rgba(255, 204, 0, 0.4);
|
||||
}
|
||||
74
frontend/src/components/Sidebar.jsx
Normal file
74
frontend/src/components/Sidebar.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { Home, Upload, ListMusic, Users, LogOut, Music, Shield } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
import './Sidebar.css'
|
||||
|
||||
function Sidebar() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
checkAdmin()
|
||||
}, [])
|
||||
|
||||
const checkAdmin = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await axios.get('http://localhost:8000/api/auth/me', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
setIsAdmin(response.data.is_admin || response.data.is_owner)
|
||||
} catch (error) {
|
||||
console.error('Ошибка проверки админа:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ icon: Home, label: 'Главная', path: '/' },
|
||||
{ icon: Upload, label: 'Загрузить', path: '/upload' },
|
||||
{ icon: ListMusic, label: 'Плейлисты', path: '/playlists' },
|
||||
{ icon: Users, label: 'Комнаты', path: '/rooms' },
|
||||
]
|
||||
|
||||
if (isAdmin) {
|
||||
menuItems.push({ icon: Shield, label: 'Админка', path: '/admin' })
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token')
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<Music size={32} className="logo-icon" />
|
||||
<h2>Music Platform</h2>
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
{menuItems.map((item) => (
|
||||
<button
|
||||
key={item.path}
|
||||
onClick={() => navigate(item.path)}
|
||||
className={`nav-item ${location.pathname === item.path ? 'active' : ''} ${item.path === '/admin' ? 'admin-link' : ''}`}
|
||||
>
|
||||
<item.icon size={22} />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<button onClick={logout} className="logout-btn">
|
||||
<LogOut size={22} />
|
||||
<span>Выход</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Sidebar
|
||||
67
frontend/src/index.css
Normal file
67
frontend/src/index.css
Normal file
@@ -0,0 +1,67 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Кастомный скроллбар для Webkit (Chrome, Safari, Edge) */
|
||||
::-webkit-scrollbar {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border-radius: 8px;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 8px;
|
||||
border: 3px solid rgba(0, 0, 0, 0.4);
|
||||
box-shadow: inset 0 0 6px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, #7c8ef5 0%, #8a5bb5 100%);
|
||||
box-shadow: inset 0 0 8px rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:active {
|
||||
background: linear-gradient(180deg, #5568d3 0%, #6a3d8f 100%);
|
||||
}
|
||||
|
||||
/* Для Firefox */
|
||||
html {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #764ba2 rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
292
frontend/src/pages/Admin.css
Normal file
292
frontend/src/pages/Admin.css
Normal file
@@ -0,0 +1,292 @@
|
||||
.admin-page {
|
||||
min-height: 100vh;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
font-size: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.admin-tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 2px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.admin-tabs button {
|
||||
padding: 15px 30px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-tabs button:hover {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.admin-tabs button.active {
|
||||
color: #fff;
|
||||
border-bottom-color: #667eea;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
padding: 20px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.admin-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 800px;
|
||||
}
|
||||
|
||||
.admin-table thead {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
padding: 15px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.admin-table td {
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.admin-table tbody tr {
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.admin-table tbody tr:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.admin-table tbody tr.banned-row {
|
||||
background: rgba(255, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.table-cover {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.table-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.table-default-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.edit-btn, .delete-btn, .ban-btn, .unban-btn, .save-btn, .cancel-btn, .promote-btn, .demote-btn {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.3s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background: rgba(102, 126, 234, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: rgba(255, 59, 48, 0.2);
|
||||
color: #ff3b30;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: rgba(255, 59, 48, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.ban-btn {
|
||||
background: rgba(255, 149, 0, 0.2);
|
||||
color: #ff9500;
|
||||
}
|
||||
|
||||
.ban-btn:hover {
|
||||
background: rgba(255, 149, 0, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.unban-btn {
|
||||
background: rgba(52, 199, 89, 0.2);
|
||||
color: #34c759;
|
||||
}
|
||||
|
||||
.unban-btn:hover {
|
||||
background: rgba(52, 199, 89, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
background: rgba(52, 199, 89, 0.2);
|
||||
color: #34c759;
|
||||
}
|
||||
|
||||
.save-btn:hover {
|
||||
background: rgba(52, 199, 89, 0.3);
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.edit-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.edit-input:focus {
|
||||
border-color: #667eea;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.admin-badge, .user-badge, .banned-badge, .active-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.admin-badge {
|
||||
background: rgba(255, 204, 0, 0.2);
|
||||
color: #ffcc00;
|
||||
}
|
||||
|
||||
.user-badge {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.banned-badge {
|
||||
background: rgba(255, 59, 48, 0.2);
|
||||
color: #ff3b30;
|
||||
}
|
||||
|
||||
.active-badge {
|
||||
background: rgba(52, 199, 89, 0.2);
|
||||
color: #34c759;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.admin-tabs button {
|
||||
padding: 12px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.promote-btn {
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.promote-btn:hover {
|
||||
background: rgba(102, 126, 234, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.demote-btn {
|
||||
background: rgba(255, 149, 0, 0.2);
|
||||
color: #ff9500;
|
||||
}
|
||||
|
||||
.demote-btn:hover {
|
||||
background: rgba(255, 149, 0, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.owner-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
338
frontend/src/pages/Admin.jsx
Normal file
338
frontend/src/pages/Admin.jsx
Normal file
@@ -0,0 +1,338 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
import { Trash2, Edit2, Ban, UserX, Shield } from 'lucide-react'
|
||||
import './Admin.css'
|
||||
|
||||
function Admin() {
|
||||
const [activeTab, setActiveTab] = useState('songs')
|
||||
const [songs, setSongs] = useState([])
|
||||
const [users, setUsers] = useState([])
|
||||
const [editingSong, setEditingSong] = useState(null)
|
||||
const [editTitle, setEditTitle] = useState('')
|
||||
const [editArtist, setEditArtist] = useState('')
|
||||
const [isOwner, setIsOwner] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
checkOwner()
|
||||
fetchSongs()
|
||||
fetchUsers()
|
||||
}, [])
|
||||
|
||||
const checkOwner = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await axios.get('http://localhost:8000/api/auth/me', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
setIsOwner(response.data.is_owner)
|
||||
} catch (error) {
|
||||
console.error('Ошибка проверки прав:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSongs = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await axios.get('http://localhost:8000/api/admin/songs', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
setSongs(response.data)
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки песен:', error)
|
||||
if (error.response?.status === 403) {
|
||||
alert('У вас нет прав администратора')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await axios.get('http://localhost:8000/api/admin/users', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
setUsers(response.data)
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки пользователей:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteSong = async (songId) => {
|
||||
if (!confirm('Вы уверены, что хотите удалить эту песню?')) return
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
await axios.delete(`http://localhost:8000/api/admin/songs/${songId}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
alert('Песня удалена')
|
||||
fetchSongs()
|
||||
} catch (error) {
|
||||
alert('Ошибка удаления песни')
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditSong = (song) => {
|
||||
setEditingSong(song.id)
|
||||
setEditTitle(song.title)
|
||||
setEditArtist(song.artist)
|
||||
}
|
||||
|
||||
const handleSaveSong = async (songId) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
await axios.put(`http://localhost:8000/api/admin/songs/${songId}`, {
|
||||
title: editTitle,
|
||||
artist: editArtist
|
||||
}, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
alert('Песня обновлена')
|
||||
setEditingSong(null)
|
||||
fetchSongs()
|
||||
} catch (error) {
|
||||
alert('Ошибка обновления песни')
|
||||
}
|
||||
}
|
||||
|
||||
const handleBanUser = async (userId, isBanned) => {
|
||||
const action = isBanned ? 'разбанить' : 'забанить'
|
||||
if (!confirm(`Вы уверены, что хотите ${action} этого пользователя?`)) return
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
await axios.post('http://localhost:8000/api/admin/users/ban', {
|
||||
user_id: userId,
|
||||
is_banned: !isBanned
|
||||
}, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
alert(`Пользователь ${!isBanned ? 'забанен' : 'разбанен'}`)
|
||||
fetchUsers()
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteUser = async (userId) => {
|
||||
if (!confirm('Вы уверены, что хотите удалить этого пользователя? Все его данные будут удалены!')) return
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
await axios.delete(`http://localhost:8000/api/admin/users/${userId}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
alert('Пользователь удален')
|
||||
fetchUsers()
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка удаления')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePromoteUser = async (userId, isAdmin) => {
|
||||
const action = isAdmin ? 'понизить' : 'повысить до администратора'
|
||||
if (!confirm(`Вы уверены, что хотите ${action} этого пользователя?`)) return
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
await axios.post('http://localhost:8000/api/admin/users/promote', {
|
||||
user_id: userId,
|
||||
is_admin: !isAdmin
|
||||
}, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
alert(`Пользователь ${!isAdmin ? 'повышен до администратора' : 'понижен до пользователя'}`)
|
||||
fetchUsers()
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-header">
|
||||
<h1><Shield size={32} /> Панель администратора</h1>
|
||||
</div>
|
||||
|
||||
<div className="admin-tabs">
|
||||
<button
|
||||
className={activeTab === 'songs' ? 'active' : ''}
|
||||
onClick={() => setActiveTab('songs')}
|
||||
>
|
||||
Песни ({songs.length})
|
||||
</button>
|
||||
<button
|
||||
className={activeTab === 'users' ? 'active' : ''}
|
||||
onClick={() => setActiveTab('users')}
|
||||
>
|
||||
Пользователи ({users.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'songs' && (
|
||||
<div className="admin-content">
|
||||
<div className="admin-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Обложка</th>
|
||||
<th>Название</th>
|
||||
<th>Исполнитель</th>
|
||||
<th>Владелец</th>
|
||||
<th>Дата</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{songs.map(song => (
|
||||
<tr key={song.id}>
|
||||
<td>{song.id}</td>
|
||||
<td>
|
||||
<div className="table-cover">
|
||||
{song.cover_path ? (
|
||||
<img src={`http://localhost:8000/${song.cover_path}`} alt={song.title} />
|
||||
) : (
|
||||
<div className="table-default-cover">♪</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{editingSong === song.id ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
className="edit-input"
|
||||
/>
|
||||
) : (
|
||||
song.title
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{editingSong === song.id ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editArtist}
|
||||
onChange={(e) => setEditArtist(e.target.value)}
|
||||
className="edit-input"
|
||||
/>
|
||||
) : (
|
||||
song.artist
|
||||
)}
|
||||
</td>
|
||||
<td>{song.owner_username}</td>
|
||||
<td>{new Date(song.created_at).toLocaleDateString()}</td>
|
||||
<td>
|
||||
<div className="action-buttons">
|
||||
{editingSong === song.id ? (
|
||||
<>
|
||||
<button onClick={() => handleSaveSong(song.id)} className="save-btn">
|
||||
Сохранить
|
||||
</button>
|
||||
<button onClick={() => setEditingSong(null)} className="cancel-btn">
|
||||
Отмена
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button onClick={() => handleEditSong(song)} className="edit-btn">
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
<button onClick={() => handleDeleteSong(song.id)} className="delete-btn">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'users' && (
|
||||
<div className="admin-content">
|
||||
<div className="admin-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Имя пользователя</th>
|
||||
<th>Email</th>
|
||||
<th>Роль</th>
|
||||
<th>Статус</th>
|
||||
<th>Дата регистрации</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(user => (
|
||||
<tr key={user.id} className={user.is_banned ? 'banned-row' : ''}>
|
||||
<td>{user.id}</td>
|
||||
<td>{user.username}</td>
|
||||
<td>{user.email}</td>
|
||||
<td>
|
||||
{user.is_owner ? (
|
||||
<span className="owner-badge">Создатель</span>
|
||||
) : user.is_admin ? (
|
||||
<span className="admin-badge">Админ</span>
|
||||
) : (
|
||||
<span className="user-badge">Пользователь</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{user.is_banned ? (
|
||||
<span className="banned-badge">Забанен</span>
|
||||
) : (
|
||||
<span className="active-badge">Активен</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{new Date(user.created_at).toLocaleDateString()}</td>
|
||||
<td>
|
||||
<div className="action-buttons">
|
||||
{!user.is_owner && (
|
||||
<>
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={() => handlePromoteUser(user.id, user.is_admin)}
|
||||
className={user.is_admin ? 'demote-btn' : 'promote-btn'}
|
||||
>
|
||||
<Shield size={16} />
|
||||
{user.is_admin ? 'Понизить' : 'Сделать админом'}
|
||||
</button>
|
||||
)}
|
||||
{!user.is_admin && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleBanUser(user.id, user.is_banned)}
|
||||
className={user.is_banned ? 'unban-btn' : 'ban-btn'}
|
||||
>
|
||||
<Ban size={16} />
|
||||
{user.is_banned ? 'Разбанить' : 'Забанить'}
|
||||
</button>
|
||||
<button onClick={() => handleDeleteUser(user.id)} className="delete-btn">
|
||||
<UserX size={16} />
|
||||
Удалить
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Admin
|
||||
68
frontend/src/pages/Auth.css
Normal file
68
frontend/src/pages/Auth.css
Normal file
@@ -0,0 +1,68 @@
|
||||
.auth-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.auth-card h1 {
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.auth-card form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.auth-card input {
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.auth-card input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.auth-card button {
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.auth-card button:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.auth-card p {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.auth-card a {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
text-decoration: underline;
|
||||
}
|
||||
176
frontend/src/pages/Home.css
Normal file
176
frontend/src/pages/Home.css
Normal file
@@ -0,0 +1,176 @@
|
||||
.home-page {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 40px;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 12px 20px;
|
||||
border-radius: 30px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
min-width: 300px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.search-bar:focus-within {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 20px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.search-bar input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.songs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.song-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
transition: all 0.3s;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.song-card:hover {
|
||||
transform: translateY(-8px);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.song-cover {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.song-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.default-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 64px;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.play-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.song-card:hover .play-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.play-overlay:hover {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.song-info {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.song-info h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.song-info p {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.song-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.download-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.songs-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
97
frontend/src/pages/Home.jsx
Normal file
97
frontend/src/pages/Home.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { Play, Download, Search } from 'lucide-react'
|
||||
import './Home.css'
|
||||
|
||||
function Home({ setCurrentSong, setIsPlaying, setPlaylist }) {
|
||||
const [songs, setSongs] = useState([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
fetchSongs()
|
||||
}, [])
|
||||
|
||||
const fetchSongs = async () => {
|
||||
try {
|
||||
const response = await axios.get('http://localhost:8000/api/music/songs')
|
||||
setSongs(response.data)
|
||||
setPlaylist(response.data)
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки песен:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const playSong = (song) => {
|
||||
setCurrentSong(song)
|
||||
setIsPlaying(true)
|
||||
}
|
||||
|
||||
const downloadSong = async (songId, title) => {
|
||||
try {
|
||||
const response = await axios.get(`http://localhost:8000/api/music/download/${songId}`, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]))
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.setAttribute('download', `${title}.mp3`)
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
} catch (error) {
|
||||
console.error('Ошибка скачивания:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredSongs = songs.filter(song =>
|
||||
song.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
song.artist.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="home-page">
|
||||
<div className="page-header">
|
||||
<h1>Все песни</h1>
|
||||
<div className="search-bar">
|
||||
<Search size={20} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск песен..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="songs-grid">
|
||||
{filteredSongs.map(song => (
|
||||
<div key={song.id} className="song-card">
|
||||
<div className="song-cover">
|
||||
{song.cover_path ? (
|
||||
<img src={`http://localhost:8000/${song.cover_path}`} alt={song.title} />
|
||||
) : (
|
||||
<div className="default-cover">♪</div>
|
||||
)}
|
||||
<div className="play-overlay" onClick={() => playSong(song)}>
|
||||
<Play size={32} fill="#fff" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="song-info">
|
||||
<h3>{song.title}</h3>
|
||||
<p>{song.artist}</p>
|
||||
</div>
|
||||
<div className="song-actions">
|
||||
<button onClick={() => downloadSong(song.id, song.title)} className="download-btn">
|
||||
<Download size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Home
|
||||
53
frontend/src/pages/Login.jsx
Normal file
53
frontend/src/pages/Login.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import './Auth.css'
|
||||
|
||||
function Login({ setToken }) {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('username', username)
|
||||
formData.append('password', password)
|
||||
|
||||
const response = await axios.post('http://localhost:8000/api/auth/login', formData)
|
||||
setToken(response.data.access_token)
|
||||
navigate('/')
|
||||
} catch (error) {
|
||||
alert('Ошибка входа: ' + (error.response?.data?.detail || 'Неизвестная ошибка'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<div className="auth-card">
|
||||
<h1>Вход</h1>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Имя пользователя"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Пароль"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<button type="submit">Войти</button>
|
||||
</form>
|
||||
<p>Нет аккаунта? <Link to="/register">Зарегистрироваться</Link></p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login
|
||||
408
frontend/src/pages/Playlists.css
Normal file
408
frontend/src/pages/Playlists.css
Normal file
@@ -0,0 +1,408 @@
|
||||
.playlists-page {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 40px;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.back-btn-inline {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 10px 20px;
|
||||
border-radius: 12px;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.back-btn-inline:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 12px 24px;
|
||||
border-radius: 20px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.create-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
|
||||
}
|
||||
|
||||
.playlists-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.playlist-card {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 15px;
|
||||
padding: 20px;
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.playlist-card:hover {
|
||||
transform: translateY(-5px);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.playlist-cover {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 15px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.default-playlist-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 64px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.playlist-info h3 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.playlist-info p {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.playlist-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.modal-content h2 {
|
||||
margin-bottom: 20px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.modal-content form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.modal-content input,
|
||||
.modal-content textarea {
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.modal-content textarea {
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-content button {
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.modal-content button:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.modal-content button[type="button"] {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.modal-content button[type="button"]:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.playlist-songs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.playlist-song-card {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 15px;
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.playlist-song-card:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.song-cover-small {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.song-cover-small img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.default-cover-small {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.song-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.song-details h3 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 5px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.song-details p {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.play-song-btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 50%;
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
transition: all 0.3s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.play-song-btn:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.empty-playlist {
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 18px;
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
.songs-modal {
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.songs-list {
|
||||
overflow-y: auto;
|
||||
max-height: 500px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.songs-list::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.songs-list::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.songs-list::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.songs-list::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(135deg, #7c8ef5 0%, #8a5bb5 100%);
|
||||
}
|
||||
|
||||
.song-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.song-item:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.song-item-cover {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.song-item-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.mini-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.song-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.song-item-info h4 {
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.song-item-info p {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
padding: 15px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.checkbox-label:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
accent-color: #667eea;
|
||||
}
|
||||
|
||||
.checkbox-label span {
|
||||
font-size: 16px;
|
||||
user-select: none;
|
||||
}
|
||||
220
frontend/src/pages/Playlists.jsx
Normal file
220
frontend/src/pages/Playlists.jsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { Plus, Play } from 'lucide-react'
|
||||
import './Playlists.css'
|
||||
|
||||
function Playlists({ setCurrentSong, setPlaylist }) {
|
||||
const [playlists, setPlaylists] = useState([])
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [selectedPlaylist, setSelectedPlaylist] = useState(null)
|
||||
const [showAddSong, setShowAddSong] = useState(false)
|
||||
const [allSongs, setAllSongs] = useState([])
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [isPublic, setIsPublic] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlaylists()
|
||||
fetchAllSongs()
|
||||
}, [])
|
||||
|
||||
const fetchPlaylists = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await axios.get('http://localhost:8000/api/playlists/my-playlists', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
setPlaylists(response.data)
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки плейлистов:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchAllSongs = async () => {
|
||||
try {
|
||||
const response = await axios.get('http://localhost:8000/api/music/songs')
|
||||
setAllSongs(response.data)
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки песен:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const createPlaylist = async (e) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
await axios.post('http://localhost:8000/api/playlists/create', {
|
||||
name,
|
||||
description,
|
||||
is_public: isPublic
|
||||
}, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
setShowCreate(false)
|
||||
setName('')
|
||||
setDescription('')
|
||||
setIsPublic(false)
|
||||
fetchPlaylists()
|
||||
} catch (error) {
|
||||
alert('Ошибка создания плейлиста')
|
||||
}
|
||||
}
|
||||
|
||||
const openPlaylist = async (playlistId) => {
|
||||
try {
|
||||
const response = await axios.get(`http://localhost:8000/api/playlists/${playlistId}`)
|
||||
setSelectedPlaylist(response.data)
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки плейлиста:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const addSongToPlaylist = async (songId) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
await axios.post(
|
||||
`http://localhost:8000/api/playlists/${selectedPlaylist.id}/add-song/${songId}`,
|
||||
{},
|
||||
{ headers: { 'Authorization': `Bearer ${token}` }}
|
||||
)
|
||||
openPlaylist(selectedPlaylist.id)
|
||||
setShowAddSong(false)
|
||||
} catch (error) {
|
||||
alert('Ошибка добавления песни')
|
||||
}
|
||||
}
|
||||
|
||||
const playSong = (song) => {
|
||||
setCurrentSong(song)
|
||||
if (selectedPlaylist && selectedPlaylist.songs) {
|
||||
setPlaylist(selectedPlaylist.songs)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="playlists-page">
|
||||
{!selectedPlaylist ? (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<h1>Мои плейлисты</h1>
|
||||
<button onClick={() => setShowCreate(true)} className="create-btn">
|
||||
<Plus size={20} /> Создать плейлист
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="modal-overlay" onClick={() => setShowCreate(false)}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>Новый плейлист</h2>
|
||||
<form onSubmit={createPlaylist}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Название"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Описание"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPublic}
|
||||
onChange={(e) => setIsPublic(e.target.checked)}
|
||||
/>
|
||||
<span>Публичный плейлист</span>
|
||||
</label>
|
||||
<button type="submit">Создать</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="playlists-grid">
|
||||
{playlists.map(playlist => (
|
||||
<div key={playlist.id} className="playlist-card" onClick={() => openPlaylist(playlist.id)}>
|
||||
<div className="playlist-cover">
|
||||
<div className="default-playlist-cover">♫</div>
|
||||
</div>
|
||||
<div className="playlist-info">
|
||||
<h3>{playlist.name}</h3>
|
||||
<p>{playlist.description || 'Без описания'}</p>
|
||||
<span className="playlist-badge">{playlist.is_public ? 'Публичный' : 'Приватный'}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<button onClick={() => setSelectedPlaylist(null)} className="back-btn-inline">
|
||||
← Назад
|
||||
</button>
|
||||
<h1>{selectedPlaylist.name}</h1>
|
||||
<button onClick={() => setShowAddSong(true)} className="create-btn">
|
||||
<Plus size={20} /> Добавить песню
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAddSong && (
|
||||
<div className="modal-overlay" onClick={() => setShowAddSong(false)}>
|
||||
<div className="modal-content songs-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>Добавить песню</h2>
|
||||
<div className="songs-list">
|
||||
{allSongs.map(song => (
|
||||
<div key={song.id} className="song-item" onClick={() => addSongToPlaylist(song.id)}>
|
||||
<div className="song-item-cover">
|
||||
{song.cover_path ? (
|
||||
<img src={`http://localhost:8000/${song.cover_path}`} alt={song.title} />
|
||||
) : (
|
||||
<div className="mini-cover">♪</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="song-item-info">
|
||||
<h4>{song.title}</h4>
|
||||
<p>{song.artist}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="playlist-songs">
|
||||
{selectedPlaylist.songs && selectedPlaylist.songs.length > 0 ? (
|
||||
selectedPlaylist.songs.map(song => (
|
||||
<div key={song.id} className="playlist-song-card">
|
||||
<div className="song-cover-small">
|
||||
{song.cover_path ? (
|
||||
<img src={`http://localhost:8000/${song.cover_path}`} alt={song.title} />
|
||||
) : (
|
||||
<div className="default-cover-small">♪</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="song-details">
|
||||
<h3>{song.title}</h3>
|
||||
<p>{song.artist}</p>
|
||||
</div>
|
||||
<button onClick={() => playSong(song)} className="play-song-btn">
|
||||
<Play size={20} />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="empty-playlist">В плейлисте пока нет песен</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Playlists
|
||||
61
frontend/src/pages/Register.jsx
Normal file
61
frontend/src/pages/Register.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import './Auth.css'
|
||||
|
||||
function Register() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await axios.post('http://localhost:8000/api/auth/register', {
|
||||
username,
|
||||
email,
|
||||
password
|
||||
})
|
||||
alert('Регистрация успешна! Войдите в систему.')
|
||||
navigate('/login')
|
||||
} catch (error) {
|
||||
alert('Ошибка регистрации: ' + (error.response?.data?.detail || 'Неизвестная ошибка'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<div className="auth-card">
|
||||
<h1>Регистрация</h1>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Имя пользователя"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Пароль"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<button type="submit">Зарегистрироваться</button>
|
||||
</form>
|
||||
<p>Уже есть аккаунт? <Link to="/login">Войти</Link></p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Register
|
||||
278
frontend/src/pages/Room.css
Normal file
278
frontend/src/pages/Room.css
Normal file
@@ -0,0 +1,278 @@
|
||||
.room-container {
|
||||
min-height: 100vh;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.room-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.room-header h1 {
|
||||
font-size: 32px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.room-header p {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.room-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.room-player, .room-chat {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.room-player h2, .room-chat h2 {
|
||||
margin-bottom: 20px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.player-info {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.player-cover {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
margin: 0 auto 20px;
|
||||
border-radius: 15px;
|
||||
overflow: hidden;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.player-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.player-info h3 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.player-info p {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.play-control {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.play-control:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.messages {
|
||||
height: 400px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.messages::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.messages::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.messages::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.messages::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(135deg, #7c8ef5 0%, #8a5bb5 100%);
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.message strong {
|
||||
color: #667eea;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chat-input input {
|
||||
flex: 1;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.chat-input button {
|
||||
padding: 15px 20px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.room-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.user-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 10px 20px;
|
||||
border-radius: 20px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.player-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.add-music-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.add-music-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.music-menu {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 15px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.music-menu::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.music-menu::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.music-menu::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.music-menu::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(135deg, #7c8ef5 0%, #8a5bb5 100%);
|
||||
}
|
||||
|
||||
.menu-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.menu-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.menu-section h3 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 10px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.menu-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 15px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.menu-item span {
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.no-music {
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 16px;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.default-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 80px;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
285
frontend/src/pages/Room.jsx
Normal file
285
frontend/src/pages/Room.jsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import { useState, useEffect, useRef, useContext } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { ArrowLeft, Send, Play, Pause, Users, Plus, Music } from 'lucide-react'
|
||||
import { AudioContext } from '../App'
|
||||
import './Room.css'
|
||||
|
||||
function Room() {
|
||||
const [room, setRoom] = useState(null)
|
||||
const [messages, setMessages] = useState([])
|
||||
const [message, setMessage] = useState('')
|
||||
const [currentSong, setCurrentSong] = useState(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [userCount, setUserCount] = useState(0)
|
||||
const [username, setUsername] = useState('')
|
||||
const [showSongMenu, setShowSongMenu] = useState(false)
|
||||
const [mySongs, setMySongs] = useState([])
|
||||
const [myPlaylists, setMyPlaylists] = useState([])
|
||||
const ws = useRef(null)
|
||||
const audioRef = useRef(null)
|
||||
const navigate = useNavigate()
|
||||
const { code } = useParams()
|
||||
const { stopGlobalPlayer, setIsInRoom } = useContext(AudioContext)
|
||||
|
||||
useEffect(() => {
|
||||
// Останавливаем глобальный плеер при входе в комнату
|
||||
stopGlobalPlayer()
|
||||
setIsInRoom(true)
|
||||
|
||||
fetchUserInfo()
|
||||
fetchRoom()
|
||||
fetchMySongs()
|
||||
fetchMyPlaylists()
|
||||
|
||||
return () => {
|
||||
// Возвращаем возможность использовать глобальный плеер при выходе
|
||||
setIsInRoom(false)
|
||||
if (ws.current) {
|
||||
ws.current.close()
|
||||
}
|
||||
}
|
||||
}, [code])
|
||||
|
||||
const fetchUserInfo = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await axios.get('http://localhost:8000/api/auth/me', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
setUsername(response.data.username)
|
||||
} catch (error) {
|
||||
console.error('Ошибка получения информации о пользователе:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchMySongs = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await axios.get('http://localhost:8000/api/music/my-songs', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
setMySongs(response.data)
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки песен:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchMyPlaylists = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await axios.get('http://localhost:8000/api/playlists/my-playlists', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
setMyPlaylists(response.data)
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки плейлистов:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRoom = async () => {
|
||||
try {
|
||||
const response = await axios.get(`http://localhost:8000/api/rooms/${code}`)
|
||||
setRoom(response.data)
|
||||
setUserCount(response.data.user_count || 0)
|
||||
connectWebSocket()
|
||||
} catch (error) {
|
||||
alert('Комната не найдена')
|
||||
navigate('/')
|
||||
}
|
||||
}
|
||||
|
||||
const connectWebSocket = () => {
|
||||
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
|
||||
ws.current = new WebSocket(`ws://localhost:8000/api/rooms/ws/${code}?username=${username}`)
|
||||
|
||||
ws.current.onopen = () => {
|
||||
console.log('WebSocket connected')
|
||||
}
|
||||
|
||||
ws.current.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
|
||||
if (data.type === 'chat') {
|
||||
setMessages(prev => [...prev, data])
|
||||
} else if (data.type === 'play') {
|
||||
setCurrentSong(data.song)
|
||||
setIsPlaying(true)
|
||||
} else if (data.type === 'pause') {
|
||||
setIsPlaying(false)
|
||||
} else if (data.type === 'user_count') {
|
||||
setUserCount(data.count)
|
||||
}
|
||||
}
|
||||
|
||||
ws.current.onerror = (error) => {
|
||||
console.error('WebSocket error:', error)
|
||||
}
|
||||
|
||||
ws.current.onclose = () => {
|
||||
console.log('WebSocket disconnected')
|
||||
}
|
||||
}
|
||||
|
||||
const sendMessage = (e) => {
|
||||
e.preventDefault()
|
||||
if (!message.trim()) return
|
||||
|
||||
const data = {
|
||||
type: 'chat',
|
||||
username: username,
|
||||
message: message
|
||||
}
|
||||
|
||||
ws.current.send(JSON.stringify(data))
|
||||
setMessage('')
|
||||
}
|
||||
|
||||
const togglePlay = () => {
|
||||
const newIsPlaying = !isPlaying
|
||||
setIsPlaying(newIsPlaying)
|
||||
|
||||
const data = {
|
||||
type: newIsPlaying ? 'play' : 'pause',
|
||||
song: currentSong
|
||||
}
|
||||
ws.current.send(JSON.stringify(data))
|
||||
}
|
||||
|
||||
const playSongInRoom = (song) => {
|
||||
const data = {
|
||||
type: 'play',
|
||||
song: song
|
||||
}
|
||||
ws.current.send(JSON.stringify(data))
|
||||
setShowSongMenu(false)
|
||||
// Устанавливаем песню локально для комнаты
|
||||
setCurrentSong(song)
|
||||
setIsPlaying(true)
|
||||
}
|
||||
|
||||
const addPlaylistToRoom = async (playlistId) => {
|
||||
try {
|
||||
const response = await axios.get(`http://localhost:8000/api/playlists/${playlistId}`)
|
||||
if (response.data.songs && response.data.songs.length > 0) {
|
||||
playSongInRoom(response.data.songs[0])
|
||||
}
|
||||
setShowSongMenu(false)
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки плейлиста:', error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (audioRef.current && currentSong) {
|
||||
if (isPlaying) {
|
||||
audioRef.current.play().catch(err => console.error('Play error:', err))
|
||||
} else {
|
||||
audioRef.current.pause()
|
||||
}
|
||||
}
|
||||
}, [isPlaying, currentSong])
|
||||
|
||||
if (!room) return <div>Загрузка...</div>
|
||||
|
||||
return (
|
||||
<div className="room-container">
|
||||
<div className="room-header">
|
||||
<button onClick={() => navigate('/')} className="back-btn">
|
||||
<ArrowLeft size={24} />
|
||||
</button>
|
||||
<div>
|
||||
<h1>{room.name}</h1>
|
||||
<p>Код комнаты: {room.code}</p>
|
||||
</div>
|
||||
<div className="user-count">
|
||||
<Users size={20} />
|
||||
<span>{userCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="room-content">
|
||||
<div className="room-player">
|
||||
<div className="player-header">
|
||||
<h2>Сейчас играет</h2>
|
||||
<button onClick={() => setShowSongMenu(!showSongMenu)} className="add-music-btn">
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showSongMenu && (
|
||||
<div className="music-menu">
|
||||
<div className="menu-section">
|
||||
<h3>Мои песни</h3>
|
||||
<div className="menu-items">
|
||||
{mySongs.map(song => (
|
||||
<div key={song.id} className="menu-item" onClick={() => playSongInRoom(song)}>
|
||||
<Music size={16} />
|
||||
<span>{song.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="menu-section">
|
||||
<h3>Мои плейлисты</h3>
|
||||
<div className="menu-items">
|
||||
{myPlaylists.map(playlist => (
|
||||
<div key={playlist.id} className="menu-item" onClick={() => addPlaylistToRoom(playlist.id)}>
|
||||
<Music size={16} />
|
||||
<span>{playlist.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentSong ? (
|
||||
<div className="player-info">
|
||||
<div className="player-cover">
|
||||
{currentSong.cover_path ? (
|
||||
<img src={`http://localhost:8000/${currentSong.cover_path}`} alt={currentSong.title} />
|
||||
) : (
|
||||
<div className="default-cover">♪</div>
|
||||
)}
|
||||
</div>
|
||||
<h3>{currentSong.title}</h3>
|
||||
<p>{currentSong.artist}</p>
|
||||
<button onClick={togglePlay} className="play-control">
|
||||
{isPlaying ? <Pause size={32} /> : <Play size={32} />}
|
||||
</button>
|
||||
<audio ref={audioRef} src={`http://localhost:8000/${currentSong.file_path}`} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="no-music">Ничего не играет</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="room-chat">
|
||||
<h2>Чат</h2>
|
||||
<div className="messages">
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className="message">
|
||||
<strong>{msg.username}:</strong> {msg.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<form onSubmit={sendMessage} className="chat-input">
|
||||
<input
|
||||
type="text"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Сообщение..."
|
||||
/>
|
||||
<button type="submit"><Send size={20} /></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Room
|
||||
105
frontend/src/pages/Rooms.css
Normal file
105
frontend/src/pages/Rooms.css
Normal file
@@ -0,0 +1,105 @@
|
||||
.rooms-container {
|
||||
min-height: 100vh;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.rooms-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.rooms-header h1 {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.rooms-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 30px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.room-action-card {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.room-action-card:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 20px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.action-icon.create {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.action-icon.join {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
|
||||
.room-action-card:hover .action-icon {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.room-action-card h2 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.room-action-card p {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.modal-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.cancel-btn, .submit-btn {
|
||||
flex: 1;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
154
frontend/src/pages/Rooms.jsx
Normal file
154
frontend/src/pages/Rooms.jsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { ArrowLeft, Plus, LogIn } from 'lucide-react'
|
||||
import './Rooms.css'
|
||||
|
||||
function Rooms() {
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [showJoin, setShowJoin] = useState(false)
|
||||
const [roomName, setRoomName] = useState('')
|
||||
const [roomCode, setRoomCode] = useState('')
|
||||
const navigate = useNavigate()
|
||||
|
||||
const createRoom = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!roomName.trim()) {
|
||||
alert('Введите название комнаты')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
if (!token) {
|
||||
alert('Вы не авторизованы. Пожалуйста, войдите снова.')
|
||||
navigate('/login')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Creating room with token:', token.substring(0, 20) + '...')
|
||||
|
||||
const response = await axios.post('http://localhost:8000/api/rooms/create',
|
||||
{ name: roomName },
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
console.log('Room created:', response.data)
|
||||
setShowCreate(false)
|
||||
setRoomName('')
|
||||
navigate(`/room/${response.data.code}`)
|
||||
} catch (error) {
|
||||
console.error('Ошибка создания комнаты:', error)
|
||||
console.error('Error response:', error.response?.data)
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
alert('Сессия истекла. Пожалуйста, войдите снова.')
|
||||
localStorage.removeItem('token')
|
||||
navigate('/login')
|
||||
} else {
|
||||
alert('Ошибка создания комнаты: ' + (error.response?.data?.detail || error.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const joinRoom = (e) => {
|
||||
e.preventDefault()
|
||||
if (!roomCode.trim()) {
|
||||
alert('Введите код комнаты')
|
||||
return
|
||||
}
|
||||
setShowJoin(false)
|
||||
setRoomCode('')
|
||||
navigate(`/room/${roomCode.toUpperCase()}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rooms-container">
|
||||
<div className="rooms-header">
|
||||
<button onClick={() => navigate('/')} className="back-btn">
|
||||
<ArrowLeft size={24} />
|
||||
</button>
|
||||
<h1>Комнаты</h1>
|
||||
</div>
|
||||
|
||||
<div className="rooms-actions">
|
||||
<div className="room-action-card" onClick={() => setShowCreate(true)}>
|
||||
<div className="action-icon create">
|
||||
<Plus size={48} />
|
||||
</div>
|
||||
<h2>Создать комнату</h2>
|
||||
<p>Создайте свою комнату для прослушивания музыки с друзьями</p>
|
||||
</div>
|
||||
|
||||
<div className="room-action-card" onClick={() => setShowJoin(true)}>
|
||||
<div className="action-icon join">
|
||||
<LogIn size={48} />
|
||||
</div>
|
||||
<h2>Войти в комнату</h2>
|
||||
<p>Присоединитесь к существующей комнате по коду</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="modal-overlay" onClick={() => setShowCreate(false)}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>Создать комнату</h2>
|
||||
<form onSubmit={createRoom}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Название комнаты"
|
||||
value={roomName}
|
||||
onChange={(e) => setRoomName(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<div className="modal-buttons">
|
||||
<button type="button" onClick={() => setShowCreate(false)} className="cancel-btn">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" className="submit-btn">
|
||||
Создать
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showJoin && (
|
||||
<div className="modal-overlay" onClick={() => setShowJoin(false)}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>Войти в комнату</h2>
|
||||
<form onSubmit={joinRoom}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Код комнаты"
|
||||
value={roomCode}
|
||||
onChange={(e) => setRoomCode(e.target.value.toUpperCase())}
|
||||
required
|
||||
autoFocus
|
||||
maxLength={6}
|
||||
/>
|
||||
<div className="modal-buttons">
|
||||
<button type="button" onClick={() => setShowJoin(false)} className="cancel-btn">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" className="submit-btn">
|
||||
Войти
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Rooms
|
||||
142
frontend/src/pages/Upload.css
Normal file
142
frontend/src/pages/Upload.css
Normal file
@@ -0,0 +1,142 @@
|
||||
.upload-container {
|
||||
min-height: 100vh;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.upload-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 12px;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.upload-header h1 {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.upload-card {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.form-group input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.file-input-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-input-hidden {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.file-input-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 18px 24px;
|
||||
border-radius: 12px;
|
||||
border: 2px dashed rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.file-input-label:hover {
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.file-button {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 10px 24px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s ease;
|
||||
white-space: nowrap;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.file-input-label:hover .file-button {
|
||||
background: linear-gradient(135deg, #7c8ef5 0%, #8a5bb5 100%);
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.file-name {
|
||||
flex: 1;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
126
frontend/src/pages/Upload.jsx
Normal file
126
frontend/src/pages/Upload.jsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { ArrowLeft, Upload as UploadIcon } from 'lucide-react'
|
||||
import './Upload.css'
|
||||
|
||||
function Upload() {
|
||||
const [title, setTitle] = useState('')
|
||||
const [artist, setArtist] = useState('')
|
||||
const [file, setFile] = useState(null)
|
||||
const [cover, setCover] = useState(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!file) {
|
||||
alert('Выберите аудиофайл')
|
||||
return
|
||||
}
|
||||
|
||||
const audio = new Audio()
|
||||
audio.src = URL.createObjectURL(file)
|
||||
|
||||
audio.addEventListener('loadedmetadata', async () => {
|
||||
const duration = Math.floor(audio.duration)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('title', title)
|
||||
formData.append('artist', artist)
|
||||
formData.append('duration', duration)
|
||||
formData.append('file', file)
|
||||
if (cover) formData.append('cover', cover)
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
await axios.post('http://localhost:8000/api/music/upload', formData, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
alert('Песня успешно загружена!')
|
||||
navigate('/')
|
||||
} catch (error) {
|
||||
alert('Ошибка загрузки: ' + (error.response?.data?.detail || 'Неизвестная ошибка'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="upload-container">
|
||||
<div className="upload-header">
|
||||
<button onClick={() => navigate('/')} className="back-btn">
|
||||
<ArrowLeft size={24} />
|
||||
</button>
|
||||
<h1>Загрузить песню</h1>
|
||||
</div>
|
||||
|
||||
<div className="upload-card">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Название</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Исполнитель</label>
|
||||
<input
|
||||
type="text"
|
||||
value={artist}
|
||||
onChange={(e) => setArtist(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Аудиофайл (MP3)</label>
|
||||
<div className="file-input-wrapper">
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={(e) => setFile(e.target.files[0])}
|
||||
required
|
||||
id="audio-file"
|
||||
className="file-input-hidden"
|
||||
/>
|
||||
<label htmlFor="audio-file" className="file-input-label">
|
||||
<span className="file-button">Выбор файла</span>
|
||||
<span className="file-name">{file ? file.name : 'Не выбран ни один файл'}</span>
|
||||
<UploadIcon size={20} className="upload-icon" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Обложка (необязательно)</label>
|
||||
<div className="file-input-wrapper">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setCover(e.target.files[0])}
|
||||
id="cover-file"
|
||||
className="file-input-hidden"
|
||||
/>
|
||||
<label htmlFor="cover-file" className="file-input-label">
|
||||
<span className="file-button">Выбор файла</span>
|
||||
<span className="file-name">{cover ? cover.name : 'Не выбран ни один файл'}</span>
|
||||
<UploadIcon size={20} className="upload-icon" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="submit-btn">Загрузить</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Upload
|
||||
17
frontend/src/services/api.js
Normal file
17
frontend/src/services/api.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const API_URL = 'http://localhost:8000'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_URL
|
||||
})
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
export default api
|
||||
9
frontend/vite.config.js
Normal file
9
frontend/vite.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user