initial commit

This commit is contained in:
2026-02-24 21:24:16 +06:00
commit aa35aaa8ce
46 changed files with 6906 additions and 0 deletions

63
README.md Normal file
View 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
View 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
View File

55
backend/app/auth.py Normal file
View 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
View 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
View 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"}

View File

View 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")

View File

174
backend/app/routes/admin.py Normal file
View 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": "Пользователь удален"}

View 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
}

View 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")

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

File diff suppressed because it is too large Load Diff

24
frontend/package.json Normal file
View 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
View 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

View 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;
}

View 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

View 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;
}
}

View 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

View 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);
}

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

View 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);
}

View 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

View 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
View 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;
}
}

View 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

View 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

View 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;
}

View 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

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

View 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);
}

View 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

View 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);
}

View 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

View 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
View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173
}
})