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

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