initial commit
This commit is contained in:
61
frontend/src/App.jsx
Normal file
61
frontend/src/App.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useState, useEffect, createContext } from 'react'
|
||||
import Login from './pages/Login'
|
||||
import Register from './pages/Register'
|
||||
import Home from './pages/Home'
|
||||
import Upload from './pages/Upload'
|
||||
import Playlists from './pages/Playlists'
|
||||
import Rooms from './pages/Rooms'
|
||||
import Room from './pages/Room'
|
||||
import Admin from './pages/Admin'
|
||||
import DynamicPlayer from './components/DynamicPlayer'
|
||||
import Layout from './components/Layout'
|
||||
|
||||
// Создаем контекст для управления аудио
|
||||
export const AudioContext = createContext()
|
||||
|
||||
function App() {
|
||||
const [token, setToken] = useState(localStorage.getItem('token'))
|
||||
const [currentSong, setCurrentSong] = useState(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [isInRoom, setIsInRoom] = useState(false)
|
||||
const [playlist, setPlaylist] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
localStorage.setItem('token', token)
|
||||
} else {
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
}, [token])
|
||||
|
||||
// Функция для остановки глобального плеера
|
||||
const stopGlobalPlayer = () => {
|
||||
setIsPlaying(false)
|
||||
}
|
||||
|
||||
const handleSongChange = (song) => {
|
||||
setCurrentSong(song)
|
||||
setIsPlaying(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<AudioContext.Provider value={{ stopGlobalPlayer, isInRoom, setIsInRoom }}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={!token ? <Login setToken={setToken} /> : <Navigate to="/" />} />
|
||||
<Route path="/register" element={!token ? <Register /> : <Navigate to="/" />} />
|
||||
<Route path="/" element={token ? <Layout><Home setCurrentSong={setCurrentSong} setIsPlaying={setIsPlaying} setPlaylist={setPlaylist} /></Layout> : <Navigate to="/login" />} />
|
||||
<Route path="/upload" element={token ? <Layout><Upload /></Layout> : <Navigate to="/login" />} />
|
||||
<Route path="/playlists" element={token ? <Layout><Playlists setCurrentSong={setCurrentSong} setPlaylist={setPlaylist} /></Layout> : <Navigate to="/login" />} />
|
||||
<Route path="/rooms" element={token ? <Layout><Rooms /></Layout> : <Navigate to="/login" />} />
|
||||
<Route path="/room/:code" element={token ? <Room /> : <Navigate to="/login" />} />
|
||||
<Route path="/admin" element={token ? <Layout><Admin /></Layout> : <Navigate to="/login" />} />
|
||||
</Routes>
|
||||
{currentSong && !isInRoom && <DynamicPlayer song={currentSong} isPlaying={isPlaying} setIsPlaying={setIsPlaying} playlist={playlist} onSongChange={handleSongChange} />}
|
||||
</BrowserRouter>
|
||||
</AudioContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
452
frontend/src/components/DynamicPlayer.css
Normal file
452
frontend/src/components/DynamicPlayer.css
Normal file
@@ -0,0 +1,452 @@
|
||||
.dynamic-player {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 40px;
|
||||
padding: 12px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||
animation: slideDown 0.5s ease;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.dynamic-player.expanded {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
max-width: 450px;
|
||||
width: 90%;
|
||||
height: 600px;
|
||||
border-radius: 30px;
|
||||
padding: 40px 30px;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
animation: expandPlayer 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
transform: translateX(-50%) translateY(-100px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes expandPlayer {
|
||||
from {
|
||||
top: 20px;
|
||||
transform: translateX(-50%);
|
||||
height: 74px;
|
||||
}
|
||||
to {
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
height: 600px;
|
||||
}
|
||||
}
|
||||
|
||||
.player-mini-cover {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.player-mini-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.mini-default-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.player-info-mini {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.player-info-mini h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.player-info-mini p {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.player-controls-mini {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.control-btn.play-btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.control-btn.play-btn:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
.player-progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0 0 40px 40px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.dynamic-player {
|
||||
padding: 10px 16px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.player-mini-cover {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
}
|
||||
}
|
||||
|
||||
.player-expanded-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.collapse-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.expanded-cover {
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
margin: 60px 0 30px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.expanded-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.expanded-default-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 120px;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.expanded-info {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.expanded-info h2 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.expanded-info p {
|
||||
font-size: 16px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.expanded-progress-container {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.expanded-progress-track {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.expanded-progress-track:hover .expanded-progress-bar::after {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) scale(1.2);
|
||||
}
|
||||
|
||||
.expanded-progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 3px;
|
||||
transition: width 0.1s linear;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.expanded-progress-bar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -7px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.expanded-progress-track:active .expanded-progress-bar::after {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) scale(1.3);
|
||||
}
|
||||
|
||||
.expanded-progress-time {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.expanded-controls {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.control-btn-large {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.control-btn-large:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.play-btn-large {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.play-btn-large:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
.show-player-btn {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 30px;
|
||||
padding: 12px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
z-index: 1000;
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||
animation: slideDown 0.5s ease;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.show-player-btn:hover {
|
||||
background: rgba(0, 0, 0, 1);
|
||||
transform: translateX(-50%) translateY(-2px);
|
||||
}
|
||||
|
||||
.show-player-btn span {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.volume-control {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 5px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.volume-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
transition: all 0.3s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.volume-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.volume-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.volume-slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.3);
|
||||
box-shadow: 0 4px 10px rgba(102, 126, 234, 0.6);
|
||||
}
|
||||
|
||||
.volume-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.volume-slider::-moz-range-thumb:hover {
|
||||
transform: scale(1.3);
|
||||
box-shadow: 0 4px 10px rgba(102, 126, 234, 0.6);
|
||||
}
|
||||
|
||||
.volume-slider::-webkit-slider-runnable-track {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.volume-slider::-moz-range-track {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.volume-percentage {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-weight: 500;
|
||||
min-width: 42px;
|
||||
text-align: right;
|
||||
}
|
||||
273
frontend/src/components/DynamicPlayer.jsx
Normal file
273
frontend/src/components/DynamicPlayer.jsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Play, Pause, SkipForward, SkipBack, ChevronDown, ChevronUp, Volume2, VolumeX } from 'lucide-react'
|
||||
import './DynamicPlayer.css'
|
||||
|
||||
function DynamicPlayer({ song, isPlaying, setIsPlaying, playlist = [], onSongChange }) {
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [isHidden, setIsHidden] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [volume, setVolume] = useState(1)
|
||||
const [isMuted, setIsMuted] = useState(false)
|
||||
const audioRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (audioRef.current && song) {
|
||||
audioRef.current.src = `http://localhost:8000/${song.file_path}`
|
||||
audioRef.current.volume = volume
|
||||
audioRef.current.load()
|
||||
if (isPlaying) {
|
||||
audioRef.current.play().catch(err => console.error('Play error:', err))
|
||||
}
|
||||
}
|
||||
}, [song])
|
||||
|
||||
useEffect(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.volume = isMuted ? 0 : volume
|
||||
}
|
||||
}, [volume, isMuted])
|
||||
|
||||
useEffect(() => {
|
||||
if (audioRef.current && audioRef.current.src) {
|
||||
if (isPlaying) {
|
||||
audioRef.current.play().catch(err => console.error('Play error:', err))
|
||||
} else {
|
||||
audioRef.current.pause()
|
||||
}
|
||||
}
|
||||
}, [isPlaying])
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (audioRef.current && audioRef.current.duration) {
|
||||
const percent = (audioRef.current.currentTime / audioRef.current.duration) * 100
|
||||
setProgress(isNaN(percent) ? 0 : percent)
|
||||
setCurrentTime(audioRef.current.currentTime)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
if (audioRef.current) {
|
||||
setDuration(audioRef.current.duration)
|
||||
}
|
||||
}
|
||||
|
||||
const togglePlay = (e) => {
|
||||
e.stopPropagation()
|
||||
setIsPlaying(!isPlaying)
|
||||
}
|
||||
|
||||
const formatTime = (time) => {
|
||||
if (isNaN(time) || !isFinite(time)) return '0:00'
|
||||
const minutes = Math.floor(time / 60)
|
||||
const seconds = Math.floor(time % 60)
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const handleProgressClick = (e) => {
|
||||
if (audioRef.current && audioRef.current.duration) {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const percent = (e.clientX - rect.left) / rect.width
|
||||
const newTime = percent * audioRef.current.duration
|
||||
audioRef.current.currentTime = newTime
|
||||
setProgress(percent * 100)
|
||||
setCurrentTime(newTime)
|
||||
}
|
||||
}
|
||||
|
||||
const handleProgressMouseDown = (e) => {
|
||||
setIsDragging(true)
|
||||
handleProgressClick(e)
|
||||
}
|
||||
|
||||
const handleProgressMouseMove = (e) => {
|
||||
if (isDragging && audioRef.current && audioRef.current.duration) {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
const newTime = percent * audioRef.current.duration
|
||||
audioRef.current.currentTime = newTime
|
||||
setProgress(percent * 100)
|
||||
setCurrentTime(newTime)
|
||||
}
|
||||
}
|
||||
|
||||
const handleProgressMouseUp = () => {
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
document.addEventListener('mouseup', handleProgressMouseUp)
|
||||
return () => {
|
||||
document.removeEventListener('mouseup', handleProgressMouseUp)
|
||||
}
|
||||
}
|
||||
}, [isDragging])
|
||||
|
||||
const handleHide = (e) => {
|
||||
e.stopPropagation()
|
||||
setIsHidden(true)
|
||||
// Не останавливаем воспроизведение при скрытии
|
||||
}
|
||||
|
||||
const handleShow = () => {
|
||||
setIsHidden(false)
|
||||
}
|
||||
|
||||
const handleVolumeChange = (e) => {
|
||||
const newVolume = parseFloat(e.target.value)
|
||||
setVolume(newVolume)
|
||||
if (newVolume > 0) {
|
||||
setIsMuted(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMute = () => {
|
||||
setIsMuted(!isMuted)
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (playlist.length === 0 || !song) return
|
||||
const currentIndex = playlist.findIndex(s => s.id === song.id)
|
||||
const nextIndex = (currentIndex + 1) % playlist.length
|
||||
if (onSongChange) {
|
||||
onSongChange(playlist[nextIndex])
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (playlist.length === 0 || !song) return
|
||||
const currentIndex = playlist.findIndex(s => s.id === song.id)
|
||||
const prevIndex = currentIndex === 0 ? playlist.length - 1 : currentIndex - 1
|
||||
if (onSongChange) {
|
||||
onSongChange(playlist[prevIndex])
|
||||
}
|
||||
}
|
||||
|
||||
if (!song) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Аудио элемент всегда рендерится, даже когда плеер скрыт */}
|
||||
<audio
|
||||
ref={audioRef}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
/>
|
||||
|
||||
{!isHidden && (
|
||||
<div className={`dynamic-player ${isExpanded ? 'expanded' : ''}`}>
|
||||
{!isExpanded ? (
|
||||
<>
|
||||
<div className="player-mini-cover" onClick={() => setIsExpanded(true)}>
|
||||
{song.cover_path ? (
|
||||
<img src={`http://localhost:8000/${song.cover_path}`} alt={song.title} />
|
||||
) : (
|
||||
<div className="mini-default-cover">♪</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="player-info-mini" onClick={() => setIsExpanded(true)}>
|
||||
<h4>{song.title}</h4>
|
||||
<p>{song.artist}</p>
|
||||
</div>
|
||||
|
||||
<div className="player-controls-mini">
|
||||
<button className="control-btn" onClick={handlePrevious}>
|
||||
<SkipBack size={18} />
|
||||
</button>
|
||||
<button className="control-btn play-btn" onClick={togglePlay}>
|
||||
{isPlaying ? <Pause size={20} /> : <Play size={20} />}
|
||||
</button>
|
||||
<button className="control-btn" onClick={handleNext}>
|
||||
<SkipForward size={18} />
|
||||
</button>
|
||||
<button className="control-btn" onClick={handleHide}>
|
||||
<ChevronUp size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="player-progress">
|
||||
<div className="progress-bar" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="player-expanded-content">
|
||||
<button className="collapse-btn" onClick={() => setIsExpanded(false)}>
|
||||
<ChevronDown size={24} />
|
||||
</button>
|
||||
|
||||
<div className="expanded-cover">
|
||||
{song.cover_path ? (
|
||||
<img src={`http://localhost:8000/${song.cover_path}`} alt={song.title} />
|
||||
) : (
|
||||
<div className="expanded-default-cover">♪</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="expanded-info">
|
||||
<h2>{song.title}</h2>
|
||||
<p>{song.artist}</p>
|
||||
</div>
|
||||
|
||||
<div className="expanded-progress-container">
|
||||
<div
|
||||
className="expanded-progress-track"
|
||||
onClick={handleProgressClick}
|
||||
onMouseDown={handleProgressMouseDown}
|
||||
onMouseMove={handleProgressMouseMove}
|
||||
>
|
||||
<div className="expanded-progress-bar" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="expanded-progress-time">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="expanded-controls">
|
||||
<button className="control-btn-large" onClick={handlePrevious}>
|
||||
<SkipBack size={28} />
|
||||
</button>
|
||||
<button className="control-btn-large play-btn-large" onClick={togglePlay}>
|
||||
{isPlaying ? <Pause size={36} /> : <Play size={36} />}
|
||||
</button>
|
||||
<button className="control-btn-large" onClick={handleNext}>
|
||||
<SkipForward size={28} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="volume-control">
|
||||
<button className="volume-btn" onClick={toggleMute}>
|
||||
{isMuted || volume === 0 ? <VolumeX size={20} /> : <Volume2 size={20} />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={isMuted ? 0 : volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="volume-slider"
|
||||
/>
|
||||
<span className="volume-percentage">{Math.round((isMuted ? 0 : volume) * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isHidden && (
|
||||
<button className="show-player-btn" onClick={handleShow}>
|
||||
<ChevronDown size={20} />
|
||||
<span>Показать плеер</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DynamicPlayer
|
||||
19
frontend/src/components/Layout.css
Normal file
19
frontend/src/components/Layout.css
Normal file
@@ -0,0 +1,19 @@
|
||||
.layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-left: 260px;
|
||||
padding: 40px;
|
||||
padding-bottom: 120px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
margin-left: 80px;
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
15
frontend/src/components/Layout.jsx
Normal file
15
frontend/src/components/Layout.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import Sidebar from './Sidebar'
|
||||
import './Layout.css'
|
||||
|
||||
function Layout({ children }) {
|
||||
return (
|
||||
<div className="layout">
|
||||
<Sidebar />
|
||||
<main className="main-content">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Layout
|
||||
140
frontend/src/components/Sidebar.css
Normal file
140
frontend/src/components/Sidebar.css
Normal file
@@ -0,0 +1,140 @@
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 260px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(20px);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 30px 20px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 40px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.sidebar-header h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.nav-item span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
margin-top: auto;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background: rgba(255, 77, 77, 0.2);
|
||||
color: #ff4d4d;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 80px;
|
||||
padding: 20px 10px;
|
||||
}
|
||||
|
||||
.sidebar-header h2,
|
||||
.nav-item span,
|
||||
.logout-btn span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-item,
|
||||
.logout-btn {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-item.admin-link {
|
||||
border: 2px solid rgba(255, 204, 0, 0.3);
|
||||
background: rgba(255, 204, 0, 0.05);
|
||||
}
|
||||
|
||||
.nav-item.admin-link:hover {
|
||||
background: rgba(255, 204, 0, 0.15);
|
||||
border-color: rgba(255, 204, 0, 0.5);
|
||||
}
|
||||
|
||||
.nav-item.admin-link.active {
|
||||
background: linear-gradient(135deg, #ffcc00 0%, #ff9500 100%);
|
||||
border-color: transparent;
|
||||
box-shadow: 0 4px 15px rgba(255, 204, 0, 0.4);
|
||||
}
|
||||
74
frontend/src/components/Sidebar.jsx
Normal file
74
frontend/src/components/Sidebar.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { Home, Upload, ListMusic, Users, LogOut, Music, Shield } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
import './Sidebar.css'
|
||||
|
||||
function Sidebar() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
checkAdmin()
|
||||
}, [])
|
||||
|
||||
const checkAdmin = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await axios.get('http://localhost:8000/api/auth/me', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
setIsAdmin(response.data.is_admin || response.data.is_owner)
|
||||
} catch (error) {
|
||||
console.error('Ошибка проверки админа:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ icon: Home, label: 'Главная', path: '/' },
|
||||
{ icon: Upload, label: 'Загрузить', path: '/upload' },
|
||||
{ icon: ListMusic, label: 'Плейлисты', path: '/playlists' },
|
||||
{ icon: Users, label: 'Комнаты', path: '/rooms' },
|
||||
]
|
||||
|
||||
if (isAdmin) {
|
||||
menuItems.push({ icon: Shield, label: 'Админка', path: '/admin' })
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token')
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<Music size={32} className="logo-icon" />
|
||||
<h2>Music Platform</h2>
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
{menuItems.map((item) => (
|
||||
<button
|
||||
key={item.path}
|
||||
onClick={() => navigate(item.path)}
|
||||
className={`nav-item ${location.pathname === item.path ? 'active' : ''} ${item.path === '/admin' ? 'admin-link' : ''}`}
|
||||
>
|
||||
<item.icon size={22} />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<button onClick={logout} className="logout-btn">
|
||||
<LogOut size={22} />
|
||||
<span>Выход</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Sidebar
|
||||
67
frontend/src/index.css
Normal file
67
frontend/src/index.css
Normal file
@@ -0,0 +1,67 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Кастомный скроллбар для Webkit (Chrome, Safari, Edge) */
|
||||
::-webkit-scrollbar {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border-radius: 8px;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 8px;
|
||||
border: 3px solid rgba(0, 0, 0, 0.4);
|
||||
box-shadow: inset 0 0 6px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, #7c8ef5 0%, #8a5bb5 100%);
|
||||
box-shadow: inset 0 0 8px rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:active {
|
||||
background: linear-gradient(180deg, #5568d3 0%, #6a3d8f 100%);
|
||||
}
|
||||
|
||||
/* Для Firefox */
|
||||
html {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #764ba2 rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
292
frontend/src/pages/Admin.css
Normal file
292
frontend/src/pages/Admin.css
Normal file
@@ -0,0 +1,292 @@
|
||||
.admin-page {
|
||||
min-height: 100vh;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
font-size: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.admin-tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 2px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.admin-tabs button {
|
||||
padding: 15px 30px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-tabs button:hover {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.admin-tabs button.active {
|
||||
color: #fff;
|
||||
border-bottom-color: #667eea;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
padding: 20px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.admin-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 800px;
|
||||
}
|
||||
|
||||
.admin-table thead {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
padding: 15px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.admin-table td {
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.admin-table tbody tr {
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.admin-table tbody tr:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.admin-table tbody tr.banned-row {
|
||||
background: rgba(255, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.table-cover {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.table-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.table-default-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.edit-btn, .delete-btn, .ban-btn, .unban-btn, .save-btn, .cancel-btn, .promote-btn, .demote-btn {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.3s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background: rgba(102, 126, 234, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: rgba(255, 59, 48, 0.2);
|
||||
color: #ff3b30;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: rgba(255, 59, 48, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.ban-btn {
|
||||
background: rgba(255, 149, 0, 0.2);
|
||||
color: #ff9500;
|
||||
}
|
||||
|
||||
.ban-btn:hover {
|
||||
background: rgba(255, 149, 0, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.unban-btn {
|
||||
background: rgba(52, 199, 89, 0.2);
|
||||
color: #34c759;
|
||||
}
|
||||
|
||||
.unban-btn:hover {
|
||||
background: rgba(52, 199, 89, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
background: rgba(52, 199, 89, 0.2);
|
||||
color: #34c759;
|
||||
}
|
||||
|
||||
.save-btn:hover {
|
||||
background: rgba(52, 199, 89, 0.3);
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.edit-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.edit-input:focus {
|
||||
border-color: #667eea;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.admin-badge, .user-badge, .banned-badge, .active-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.admin-badge {
|
||||
background: rgba(255, 204, 0, 0.2);
|
||||
color: #ffcc00;
|
||||
}
|
||||
|
||||
.user-badge {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.banned-badge {
|
||||
background: rgba(255, 59, 48, 0.2);
|
||||
color: #ff3b30;
|
||||
}
|
||||
|
||||
.active-badge {
|
||||
background: rgba(52, 199, 89, 0.2);
|
||||
color: #34c759;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.admin-tabs button {
|
||||
padding: 12px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.promote-btn {
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.promote-btn:hover {
|
||||
background: rgba(102, 126, 234, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.demote-btn {
|
||||
background: rgba(255, 149, 0, 0.2);
|
||||
color: #ff9500;
|
||||
}
|
||||
|
||||
.demote-btn:hover {
|
||||
background: rgba(255, 149, 0, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.owner-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
338
frontend/src/pages/Admin.jsx
Normal file
338
frontend/src/pages/Admin.jsx
Normal file
@@ -0,0 +1,338 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
import { Trash2, Edit2, Ban, UserX, Shield } from 'lucide-react'
|
||||
import './Admin.css'
|
||||
|
||||
function Admin() {
|
||||
const [activeTab, setActiveTab] = useState('songs')
|
||||
const [songs, setSongs] = useState([])
|
||||
const [users, setUsers] = useState([])
|
||||
const [editingSong, setEditingSong] = useState(null)
|
||||
const [editTitle, setEditTitle] = useState('')
|
||||
const [editArtist, setEditArtist] = useState('')
|
||||
const [isOwner, setIsOwner] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
checkOwner()
|
||||
fetchSongs()
|
||||
fetchUsers()
|
||||
}, [])
|
||||
|
||||
const checkOwner = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await axios.get('http://localhost:8000/api/auth/me', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
setIsOwner(response.data.is_owner)
|
||||
} catch (error) {
|
||||
console.error('Ошибка проверки прав:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSongs = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await axios.get('http://localhost:8000/api/admin/songs', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
setSongs(response.data)
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки песен:', error)
|
||||
if (error.response?.status === 403) {
|
||||
alert('У вас нет прав администратора')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await axios.get('http://localhost:8000/api/admin/users', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
setUsers(response.data)
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки пользователей:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteSong = async (songId) => {
|
||||
if (!confirm('Вы уверены, что хотите удалить эту песню?')) return
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
await axios.delete(`http://localhost:8000/api/admin/songs/${songId}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
alert('Песня удалена')
|
||||
fetchSongs()
|
||||
} catch (error) {
|
||||
alert('Ошибка удаления песни')
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditSong = (song) => {
|
||||
setEditingSong(song.id)
|
||||
setEditTitle(song.title)
|
||||
setEditArtist(song.artist)
|
||||
}
|
||||
|
||||
const handleSaveSong = async (songId) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
await axios.put(`http://localhost:8000/api/admin/songs/${songId}`, {
|
||||
title: editTitle,
|
||||
artist: editArtist
|
||||
}, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
alert('Песня обновлена')
|
||||
setEditingSong(null)
|
||||
fetchSongs()
|
||||
} catch (error) {
|
||||
alert('Ошибка обновления песни')
|
||||
}
|
||||
}
|
||||
|
||||
const handleBanUser = async (userId, isBanned) => {
|
||||
const action = isBanned ? 'разбанить' : 'забанить'
|
||||
if (!confirm(`Вы уверены, что хотите ${action} этого пользователя?`)) return
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
await axios.post('http://localhost:8000/api/admin/users/ban', {
|
||||
user_id: userId,
|
||||
is_banned: !isBanned
|
||||
}, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
alert(`Пользователь ${!isBanned ? 'забанен' : 'разбанен'}`)
|
||||
fetchUsers()
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteUser = async (userId) => {
|
||||
if (!confirm('Вы уверены, что хотите удалить этого пользователя? Все его данные будут удалены!')) return
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
await axios.delete(`http://localhost:8000/api/admin/users/${userId}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
alert('Пользователь удален')
|
||||
fetchUsers()
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка удаления')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePromoteUser = async (userId, isAdmin) => {
|
||||
const action = isAdmin ? 'понизить' : 'повысить до администратора'
|
||||
if (!confirm(`Вы уверены, что хотите ${action} этого пользователя?`)) return
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
await axios.post('http://localhost:8000/api/admin/users/promote', {
|
||||
user_id: userId,
|
||||
is_admin: !isAdmin
|
||||
}, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
alert(`Пользователь ${!isAdmin ? 'повышен до администратора' : 'понижен до пользователя'}`)
|
||||
fetchUsers()
|
||||
} catch (error) {
|
||||
alert(error.response?.data?.detail || 'Ошибка')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-header">
|
||||
<h1><Shield size={32} /> Панель администратора</h1>
|
||||
</div>
|
||||
|
||||
<div className="admin-tabs">
|
||||
<button
|
||||
className={activeTab === 'songs' ? 'active' : ''}
|
||||
onClick={() => setActiveTab('songs')}
|
||||
>
|
||||
Песни ({songs.length})
|
||||
</button>
|
||||
<button
|
||||
className={activeTab === 'users' ? 'active' : ''}
|
||||
onClick={() => setActiveTab('users')}
|
||||
>
|
||||
Пользователи ({users.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'songs' && (
|
||||
<div className="admin-content">
|
||||
<div className="admin-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Обложка</th>
|
||||
<th>Название</th>
|
||||
<th>Исполнитель</th>
|
||||
<th>Владелец</th>
|
||||
<th>Дата</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{songs.map(song => (
|
||||
<tr key={song.id}>
|
||||
<td>{song.id}</td>
|
||||
<td>
|
||||
<div className="table-cover">
|
||||
{song.cover_path ? (
|
||||
<img src={`http://localhost:8000/${song.cover_path}`} alt={song.title} />
|
||||
) : (
|
||||
<div className="table-default-cover">♪</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{editingSong === song.id ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
className="edit-input"
|
||||
/>
|
||||
) : (
|
||||
song.title
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{editingSong === song.id ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editArtist}
|
||||
onChange={(e) => setEditArtist(e.target.value)}
|
||||
className="edit-input"
|
||||
/>
|
||||
) : (
|
||||
song.artist
|
||||
)}
|
||||
</td>
|
||||
<td>{song.owner_username}</td>
|
||||
<td>{new Date(song.created_at).toLocaleDateString()}</td>
|
||||
<td>
|
||||
<div className="action-buttons">
|
||||
{editingSong === song.id ? (
|
||||
<>
|
||||
<button onClick={() => handleSaveSong(song.id)} className="save-btn">
|
||||
Сохранить
|
||||
</button>
|
||||
<button onClick={() => setEditingSong(null)} className="cancel-btn">
|
||||
Отмена
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button onClick={() => handleEditSong(song)} className="edit-btn">
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
<button onClick={() => handleDeleteSong(song.id)} className="delete-btn">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'users' && (
|
||||
<div className="admin-content">
|
||||
<div className="admin-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Имя пользователя</th>
|
||||
<th>Email</th>
|
||||
<th>Роль</th>
|
||||
<th>Статус</th>
|
||||
<th>Дата регистрации</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(user => (
|
||||
<tr key={user.id} className={user.is_banned ? 'banned-row' : ''}>
|
||||
<td>{user.id}</td>
|
||||
<td>{user.username}</td>
|
||||
<td>{user.email}</td>
|
||||
<td>
|
||||
{user.is_owner ? (
|
||||
<span className="owner-badge">Создатель</span>
|
||||
) : user.is_admin ? (
|
||||
<span className="admin-badge">Админ</span>
|
||||
) : (
|
||||
<span className="user-badge">Пользователь</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{user.is_banned ? (
|
||||
<span className="banned-badge">Забанен</span>
|
||||
) : (
|
||||
<span className="active-badge">Активен</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{new Date(user.created_at).toLocaleDateString()}</td>
|
||||
<td>
|
||||
<div className="action-buttons">
|
||||
{!user.is_owner && (
|
||||
<>
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={() => handlePromoteUser(user.id, user.is_admin)}
|
||||
className={user.is_admin ? 'demote-btn' : 'promote-btn'}
|
||||
>
|
||||
<Shield size={16} />
|
||||
{user.is_admin ? 'Понизить' : 'Сделать админом'}
|
||||
</button>
|
||||
)}
|
||||
{!user.is_admin && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleBanUser(user.id, user.is_banned)}
|
||||
className={user.is_banned ? 'unban-btn' : 'ban-btn'}
|
||||
>
|
||||
<Ban size={16} />
|
||||
{user.is_banned ? 'Разбанить' : 'Забанить'}
|
||||
</button>
|
||||
<button onClick={() => handleDeleteUser(user.id)} className="delete-btn">
|
||||
<UserX size={16} />
|
||||
Удалить
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Admin
|
||||
68
frontend/src/pages/Auth.css
Normal file
68
frontend/src/pages/Auth.css
Normal file
@@ -0,0 +1,68 @@
|
||||
.auth-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.auth-card h1 {
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.auth-card form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.auth-card input {
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.auth-card input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.auth-card button {
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.auth-card button:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.auth-card p {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.auth-card a {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
text-decoration: underline;
|
||||
}
|
||||
176
frontend/src/pages/Home.css
Normal file
176
frontend/src/pages/Home.css
Normal file
@@ -0,0 +1,176 @@
|
||||
.home-page {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 40px;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 12px 20px;
|
||||
border-radius: 30px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
min-width: 300px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.search-bar:focus-within {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 20px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.search-bar input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.songs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.song-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
transition: all 0.3s;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.song-card:hover {
|
||||
transform: translateY(-8px);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.song-cover {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.song-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.default-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 64px;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.play-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.song-card:hover .play-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.play-overlay:hover {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.song-info {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.song-info h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.song-info p {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.song-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.download-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.songs-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
97
frontend/src/pages/Home.jsx
Normal file
97
frontend/src/pages/Home.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { Play, Download, Search } from 'lucide-react'
|
||||
import './Home.css'
|
||||
|
||||
function Home({ setCurrentSong, setIsPlaying, setPlaylist }) {
|
||||
const [songs, setSongs] = useState([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
fetchSongs()
|
||||
}, [])
|
||||
|
||||
const fetchSongs = async () => {
|
||||
try {
|
||||
const response = await axios.get('http://localhost:8000/api/music/songs')
|
||||
setSongs(response.data)
|
||||
setPlaylist(response.data)
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки песен:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const playSong = (song) => {
|
||||
setCurrentSong(song)
|
||||
setIsPlaying(true)
|
||||
}
|
||||
|
||||
const downloadSong = async (songId, title) => {
|
||||
try {
|
||||
const response = await axios.get(`http://localhost:8000/api/music/download/${songId}`, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]))
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.setAttribute('download', `${title}.mp3`)
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
} catch (error) {
|
||||
console.error('Ошибка скачивания:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredSongs = songs.filter(song =>
|
||||
song.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
song.artist.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="home-page">
|
||||
<div className="page-header">
|
||||
<h1>Все песни</h1>
|
||||
<div className="search-bar">
|
||||
<Search size={20} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск песен..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="songs-grid">
|
||||
{filteredSongs.map(song => (
|
||||
<div key={song.id} className="song-card">
|
||||
<div className="song-cover">
|
||||
{song.cover_path ? (
|
||||
<img src={`http://localhost:8000/${song.cover_path}`} alt={song.title} />
|
||||
) : (
|
||||
<div className="default-cover">♪</div>
|
||||
)}
|
||||
<div className="play-overlay" onClick={() => playSong(song)}>
|
||||
<Play size={32} fill="#fff" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="song-info">
|
||||
<h3>{song.title}</h3>
|
||||
<p>{song.artist}</p>
|
||||
</div>
|
||||
<div className="song-actions">
|
||||
<button onClick={() => downloadSong(song.id, song.title)} className="download-btn">
|
||||
<Download size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Home
|
||||
53
frontend/src/pages/Login.jsx
Normal file
53
frontend/src/pages/Login.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import './Auth.css'
|
||||
|
||||
function Login({ setToken }) {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('username', username)
|
||||
formData.append('password', password)
|
||||
|
||||
const response = await axios.post('http://localhost:8000/api/auth/login', formData)
|
||||
setToken(response.data.access_token)
|
||||
navigate('/')
|
||||
} catch (error) {
|
||||
alert('Ошибка входа: ' + (error.response?.data?.detail || 'Неизвестная ошибка'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<div className="auth-card">
|
||||
<h1>Вход</h1>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Имя пользователя"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Пароль"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<button type="submit">Войти</button>
|
||||
</form>
|
||||
<p>Нет аккаунта? <Link to="/register">Зарегистрироваться</Link></p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login
|
||||
408
frontend/src/pages/Playlists.css
Normal file
408
frontend/src/pages/Playlists.css
Normal file
@@ -0,0 +1,408 @@
|
||||
.playlists-page {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 40px;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.back-btn-inline {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 10px 20px;
|
||||
border-radius: 12px;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.back-btn-inline:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 12px 24px;
|
||||
border-radius: 20px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.create-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
|
||||
}
|
||||
|
||||
.playlists-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.playlist-card {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 15px;
|
||||
padding: 20px;
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.playlist-card:hover {
|
||||
transform: translateY(-5px);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.playlist-cover {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 15px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.default-playlist-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 64px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.playlist-info h3 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.playlist-info p {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.playlist-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.modal-content h2 {
|
||||
margin-bottom: 20px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.modal-content form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.modal-content input,
|
||||
.modal-content textarea {
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.modal-content textarea {
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-content button {
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.modal-content button:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.modal-content button[type="button"] {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.modal-content button[type="button"]:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.playlist-songs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.playlist-song-card {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 15px;
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.playlist-song-card:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.song-cover-small {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.song-cover-small img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.default-cover-small {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.song-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.song-details h3 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 5px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.song-details p {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.play-song-btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 50%;
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
transition: all 0.3s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.play-song-btn:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.empty-playlist {
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 18px;
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
.songs-modal {
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.songs-list {
|
||||
overflow-y: auto;
|
||||
max-height: 500px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.songs-list::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.songs-list::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.songs-list::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.songs-list::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(135deg, #7c8ef5 0%, #8a5bb5 100%);
|
||||
}
|
||||
|
||||
.song-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.song-item:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.song-item-cover {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.song-item-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.mini-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.song-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.song-item-info h4 {
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.song-item-info p {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
padding: 15px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.checkbox-label:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
accent-color: #667eea;
|
||||
}
|
||||
|
||||
.checkbox-label span {
|
||||
font-size: 16px;
|
||||
user-select: none;
|
||||
}
|
||||
220
frontend/src/pages/Playlists.jsx
Normal file
220
frontend/src/pages/Playlists.jsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { Plus, Play } from 'lucide-react'
|
||||
import './Playlists.css'
|
||||
|
||||
function Playlists({ setCurrentSong, setPlaylist }) {
|
||||
const [playlists, setPlaylists] = useState([])
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [selectedPlaylist, setSelectedPlaylist] = useState(null)
|
||||
const [showAddSong, setShowAddSong] = useState(false)
|
||||
const [allSongs, setAllSongs] = useState([])
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [isPublic, setIsPublic] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlaylists()
|
||||
fetchAllSongs()
|
||||
}, [])
|
||||
|
||||
const fetchPlaylists = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await axios.get('http://localhost:8000/api/playlists/my-playlists', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
setPlaylists(response.data)
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки плейлистов:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchAllSongs = async () => {
|
||||
try {
|
||||
const response = await axios.get('http://localhost:8000/api/music/songs')
|
||||
setAllSongs(response.data)
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки песен:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const createPlaylist = async (e) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
await axios.post('http://localhost:8000/api/playlists/create', {
|
||||
name,
|
||||
description,
|
||||
is_public: isPublic
|
||||
}, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
setShowCreate(false)
|
||||
setName('')
|
||||
setDescription('')
|
||||
setIsPublic(false)
|
||||
fetchPlaylists()
|
||||
} catch (error) {
|
||||
alert('Ошибка создания плейлиста')
|
||||
}
|
||||
}
|
||||
|
||||
const openPlaylist = async (playlistId) => {
|
||||
try {
|
||||
const response = await axios.get(`http://localhost:8000/api/playlists/${playlistId}`)
|
||||
setSelectedPlaylist(response.data)
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки плейлиста:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const addSongToPlaylist = async (songId) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
await axios.post(
|
||||
`http://localhost:8000/api/playlists/${selectedPlaylist.id}/add-song/${songId}`,
|
||||
{},
|
||||
{ headers: { 'Authorization': `Bearer ${token}` }}
|
||||
)
|
||||
openPlaylist(selectedPlaylist.id)
|
||||
setShowAddSong(false)
|
||||
} catch (error) {
|
||||
alert('Ошибка добавления песни')
|
||||
}
|
||||
}
|
||||
|
||||
const playSong = (song) => {
|
||||
setCurrentSong(song)
|
||||
if (selectedPlaylist && selectedPlaylist.songs) {
|
||||
setPlaylist(selectedPlaylist.songs)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="playlists-page">
|
||||
{!selectedPlaylist ? (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<h1>Мои плейлисты</h1>
|
||||
<button onClick={() => setShowCreate(true)} className="create-btn">
|
||||
<Plus size={20} /> Создать плейлист
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="modal-overlay" onClick={() => setShowCreate(false)}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>Новый плейлист</h2>
|
||||
<form onSubmit={createPlaylist}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Название"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Описание"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPublic}
|
||||
onChange={(e) => setIsPublic(e.target.checked)}
|
||||
/>
|
||||
<span>Публичный плейлист</span>
|
||||
</label>
|
||||
<button type="submit">Создать</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="playlists-grid">
|
||||
{playlists.map(playlist => (
|
||||
<div key={playlist.id} className="playlist-card" onClick={() => openPlaylist(playlist.id)}>
|
||||
<div className="playlist-cover">
|
||||
<div className="default-playlist-cover">♫</div>
|
||||
</div>
|
||||
<div className="playlist-info">
|
||||
<h3>{playlist.name}</h3>
|
||||
<p>{playlist.description || 'Без описания'}</p>
|
||||
<span className="playlist-badge">{playlist.is_public ? 'Публичный' : 'Приватный'}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<button onClick={() => setSelectedPlaylist(null)} className="back-btn-inline">
|
||||
← Назад
|
||||
</button>
|
||||
<h1>{selectedPlaylist.name}</h1>
|
||||
<button onClick={() => setShowAddSong(true)} className="create-btn">
|
||||
<Plus size={20} /> Добавить песню
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAddSong && (
|
||||
<div className="modal-overlay" onClick={() => setShowAddSong(false)}>
|
||||
<div className="modal-content songs-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>Добавить песню</h2>
|
||||
<div className="songs-list">
|
||||
{allSongs.map(song => (
|
||||
<div key={song.id} className="song-item" onClick={() => addSongToPlaylist(song.id)}>
|
||||
<div className="song-item-cover">
|
||||
{song.cover_path ? (
|
||||
<img src={`http://localhost:8000/${song.cover_path}`} alt={song.title} />
|
||||
) : (
|
||||
<div className="mini-cover">♪</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="song-item-info">
|
||||
<h4>{song.title}</h4>
|
||||
<p>{song.artist}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="playlist-songs">
|
||||
{selectedPlaylist.songs && selectedPlaylist.songs.length > 0 ? (
|
||||
selectedPlaylist.songs.map(song => (
|
||||
<div key={song.id} className="playlist-song-card">
|
||||
<div className="song-cover-small">
|
||||
{song.cover_path ? (
|
||||
<img src={`http://localhost:8000/${song.cover_path}`} alt={song.title} />
|
||||
) : (
|
||||
<div className="default-cover-small">♪</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="song-details">
|
||||
<h3>{song.title}</h3>
|
||||
<p>{song.artist}</p>
|
||||
</div>
|
||||
<button onClick={() => playSong(song)} className="play-song-btn">
|
||||
<Play size={20} />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="empty-playlist">В плейлисте пока нет песен</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Playlists
|
||||
61
frontend/src/pages/Register.jsx
Normal file
61
frontend/src/pages/Register.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import './Auth.css'
|
||||
|
||||
function Register() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await axios.post('http://localhost:8000/api/auth/register', {
|
||||
username,
|
||||
email,
|
||||
password
|
||||
})
|
||||
alert('Регистрация успешна! Войдите в систему.')
|
||||
navigate('/login')
|
||||
} catch (error) {
|
||||
alert('Ошибка регистрации: ' + (error.response?.data?.detail || 'Неизвестная ошибка'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<div className="auth-card">
|
||||
<h1>Регистрация</h1>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Имя пользователя"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Пароль"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<button type="submit">Зарегистрироваться</button>
|
||||
</form>
|
||||
<p>Уже есть аккаунт? <Link to="/login">Войти</Link></p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Register
|
||||
278
frontend/src/pages/Room.css
Normal file
278
frontend/src/pages/Room.css
Normal file
@@ -0,0 +1,278 @@
|
||||
.room-container {
|
||||
min-height: 100vh;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.room-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.room-header h1 {
|
||||
font-size: 32px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.room-header p {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.room-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.room-player, .room-chat {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.room-player h2, .room-chat h2 {
|
||||
margin-bottom: 20px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.player-info {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.player-cover {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
margin: 0 auto 20px;
|
||||
border-radius: 15px;
|
||||
overflow: hidden;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.player-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.player-info h3 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.player-info p {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.play-control {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.play-control:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.messages {
|
||||
height: 400px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.messages::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.messages::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.messages::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.messages::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(135deg, #7c8ef5 0%, #8a5bb5 100%);
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.message strong {
|
||||
color: #667eea;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chat-input input {
|
||||
flex: 1;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.chat-input button {
|
||||
padding: 15px 20px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.room-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.user-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 10px 20px;
|
||||
border-radius: 20px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.player-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.add-music-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.add-music-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.music-menu {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 15px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.music-menu::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.music-menu::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.music-menu::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.music-menu::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(135deg, #7c8ef5 0%, #8a5bb5 100%);
|
||||
}
|
||||
|
||||
.menu-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.menu-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.menu-section h3 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 10px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.menu-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 15px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.menu-item span {
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.no-music {
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 16px;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.default-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 80px;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
285
frontend/src/pages/Room.jsx
Normal file
285
frontend/src/pages/Room.jsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import { useState, useEffect, useRef, useContext } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { ArrowLeft, Send, Play, Pause, Users, Plus, Music } from 'lucide-react'
|
||||
import { AudioContext } from '../App'
|
||||
import './Room.css'
|
||||
|
||||
function Room() {
|
||||
const [room, setRoom] = useState(null)
|
||||
const [messages, setMessages] = useState([])
|
||||
const [message, setMessage] = useState('')
|
||||
const [currentSong, setCurrentSong] = useState(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [userCount, setUserCount] = useState(0)
|
||||
const [username, setUsername] = useState('')
|
||||
const [showSongMenu, setShowSongMenu] = useState(false)
|
||||
const [mySongs, setMySongs] = useState([])
|
||||
const [myPlaylists, setMyPlaylists] = useState([])
|
||||
const ws = useRef(null)
|
||||
const audioRef = useRef(null)
|
||||
const navigate = useNavigate()
|
||||
const { code } = useParams()
|
||||
const { stopGlobalPlayer, setIsInRoom } = useContext(AudioContext)
|
||||
|
||||
useEffect(() => {
|
||||
// Останавливаем глобальный плеер при входе в комнату
|
||||
stopGlobalPlayer()
|
||||
setIsInRoom(true)
|
||||
|
||||
fetchUserInfo()
|
||||
fetchRoom()
|
||||
fetchMySongs()
|
||||
fetchMyPlaylists()
|
||||
|
||||
return () => {
|
||||
// Возвращаем возможность использовать глобальный плеер при выходе
|
||||
setIsInRoom(false)
|
||||
if (ws.current) {
|
||||
ws.current.close()
|
||||
}
|
||||
}
|
||||
}, [code])
|
||||
|
||||
const fetchUserInfo = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await axios.get('http://localhost:8000/api/auth/me', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
setUsername(response.data.username)
|
||||
} catch (error) {
|
||||
console.error('Ошибка получения информации о пользователе:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchMySongs = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await axios.get('http://localhost:8000/api/music/my-songs', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
setMySongs(response.data)
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки песен:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchMyPlaylists = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await axios.get('http://localhost:8000/api/playlists/my-playlists', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
setMyPlaylists(response.data)
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки плейлистов:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRoom = async () => {
|
||||
try {
|
||||
const response = await axios.get(`http://localhost:8000/api/rooms/${code}`)
|
||||
setRoom(response.data)
|
||||
setUserCount(response.data.user_count || 0)
|
||||
connectWebSocket()
|
||||
} catch (error) {
|
||||
alert('Комната не найдена')
|
||||
navigate('/')
|
||||
}
|
||||
}
|
||||
|
||||
const connectWebSocket = () => {
|
||||
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
|
||||
ws.current = new WebSocket(`ws://localhost:8000/api/rooms/ws/${code}?username=${username}`)
|
||||
|
||||
ws.current.onopen = () => {
|
||||
console.log('WebSocket connected')
|
||||
}
|
||||
|
||||
ws.current.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
|
||||
if (data.type === 'chat') {
|
||||
setMessages(prev => [...prev, data])
|
||||
} else if (data.type === 'play') {
|
||||
setCurrentSong(data.song)
|
||||
setIsPlaying(true)
|
||||
} else if (data.type === 'pause') {
|
||||
setIsPlaying(false)
|
||||
} else if (data.type === 'user_count') {
|
||||
setUserCount(data.count)
|
||||
}
|
||||
}
|
||||
|
||||
ws.current.onerror = (error) => {
|
||||
console.error('WebSocket error:', error)
|
||||
}
|
||||
|
||||
ws.current.onclose = () => {
|
||||
console.log('WebSocket disconnected')
|
||||
}
|
||||
}
|
||||
|
||||
const sendMessage = (e) => {
|
||||
e.preventDefault()
|
||||
if (!message.trim()) return
|
||||
|
||||
const data = {
|
||||
type: 'chat',
|
||||
username: username,
|
||||
message: message
|
||||
}
|
||||
|
||||
ws.current.send(JSON.stringify(data))
|
||||
setMessage('')
|
||||
}
|
||||
|
||||
const togglePlay = () => {
|
||||
const newIsPlaying = !isPlaying
|
||||
setIsPlaying(newIsPlaying)
|
||||
|
||||
const data = {
|
||||
type: newIsPlaying ? 'play' : 'pause',
|
||||
song: currentSong
|
||||
}
|
||||
ws.current.send(JSON.stringify(data))
|
||||
}
|
||||
|
||||
const playSongInRoom = (song) => {
|
||||
const data = {
|
||||
type: 'play',
|
||||
song: song
|
||||
}
|
||||
ws.current.send(JSON.stringify(data))
|
||||
setShowSongMenu(false)
|
||||
// Устанавливаем песню локально для комнаты
|
||||
setCurrentSong(song)
|
||||
setIsPlaying(true)
|
||||
}
|
||||
|
||||
const addPlaylistToRoom = async (playlistId) => {
|
||||
try {
|
||||
const response = await axios.get(`http://localhost:8000/api/playlists/${playlistId}`)
|
||||
if (response.data.songs && response.data.songs.length > 0) {
|
||||
playSongInRoom(response.data.songs[0])
|
||||
}
|
||||
setShowSongMenu(false)
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки плейлиста:', error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (audioRef.current && currentSong) {
|
||||
if (isPlaying) {
|
||||
audioRef.current.play().catch(err => console.error('Play error:', err))
|
||||
} else {
|
||||
audioRef.current.pause()
|
||||
}
|
||||
}
|
||||
}, [isPlaying, currentSong])
|
||||
|
||||
if (!room) return <div>Загрузка...</div>
|
||||
|
||||
return (
|
||||
<div className="room-container">
|
||||
<div className="room-header">
|
||||
<button onClick={() => navigate('/')} className="back-btn">
|
||||
<ArrowLeft size={24} />
|
||||
</button>
|
||||
<div>
|
||||
<h1>{room.name}</h1>
|
||||
<p>Код комнаты: {room.code}</p>
|
||||
</div>
|
||||
<div className="user-count">
|
||||
<Users size={20} />
|
||||
<span>{userCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="room-content">
|
||||
<div className="room-player">
|
||||
<div className="player-header">
|
||||
<h2>Сейчас играет</h2>
|
||||
<button onClick={() => setShowSongMenu(!showSongMenu)} className="add-music-btn">
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showSongMenu && (
|
||||
<div className="music-menu">
|
||||
<div className="menu-section">
|
||||
<h3>Мои песни</h3>
|
||||
<div className="menu-items">
|
||||
{mySongs.map(song => (
|
||||
<div key={song.id} className="menu-item" onClick={() => playSongInRoom(song)}>
|
||||
<Music size={16} />
|
||||
<span>{song.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="menu-section">
|
||||
<h3>Мои плейлисты</h3>
|
||||
<div className="menu-items">
|
||||
{myPlaylists.map(playlist => (
|
||||
<div key={playlist.id} className="menu-item" onClick={() => addPlaylistToRoom(playlist.id)}>
|
||||
<Music size={16} />
|
||||
<span>{playlist.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentSong ? (
|
||||
<div className="player-info">
|
||||
<div className="player-cover">
|
||||
{currentSong.cover_path ? (
|
||||
<img src={`http://localhost:8000/${currentSong.cover_path}`} alt={currentSong.title} />
|
||||
) : (
|
||||
<div className="default-cover">♪</div>
|
||||
)}
|
||||
</div>
|
||||
<h3>{currentSong.title}</h3>
|
||||
<p>{currentSong.artist}</p>
|
||||
<button onClick={togglePlay} className="play-control">
|
||||
{isPlaying ? <Pause size={32} /> : <Play size={32} />}
|
||||
</button>
|
||||
<audio ref={audioRef} src={`http://localhost:8000/${currentSong.file_path}`} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="no-music">Ничего не играет</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="room-chat">
|
||||
<h2>Чат</h2>
|
||||
<div className="messages">
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className="message">
|
||||
<strong>{msg.username}:</strong> {msg.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<form onSubmit={sendMessage} className="chat-input">
|
||||
<input
|
||||
type="text"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Сообщение..."
|
||||
/>
|
||||
<button type="submit"><Send size={20} /></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Room
|
||||
105
frontend/src/pages/Rooms.css
Normal file
105
frontend/src/pages/Rooms.css
Normal file
@@ -0,0 +1,105 @@
|
||||
.rooms-container {
|
||||
min-height: 100vh;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.rooms-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.rooms-header h1 {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.rooms-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 30px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.room-action-card {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.room-action-card:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 20px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.action-icon.create {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.action-icon.join {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
|
||||
.room-action-card:hover .action-icon {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.room-action-card h2 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.room-action-card p {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.modal-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.cancel-btn, .submit-btn {
|
||||
flex: 1;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
154
frontend/src/pages/Rooms.jsx
Normal file
154
frontend/src/pages/Rooms.jsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { ArrowLeft, Plus, LogIn } from 'lucide-react'
|
||||
import './Rooms.css'
|
||||
|
||||
function Rooms() {
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [showJoin, setShowJoin] = useState(false)
|
||||
const [roomName, setRoomName] = useState('')
|
||||
const [roomCode, setRoomCode] = useState('')
|
||||
const navigate = useNavigate()
|
||||
|
||||
const createRoom = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!roomName.trim()) {
|
||||
alert('Введите название комнаты')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
if (!token) {
|
||||
alert('Вы не авторизованы. Пожалуйста, войдите снова.')
|
||||
navigate('/login')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Creating room with token:', token.substring(0, 20) + '...')
|
||||
|
||||
const response = await axios.post('http://localhost:8000/api/rooms/create',
|
||||
{ name: roomName },
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
console.log('Room created:', response.data)
|
||||
setShowCreate(false)
|
||||
setRoomName('')
|
||||
navigate(`/room/${response.data.code}`)
|
||||
} catch (error) {
|
||||
console.error('Ошибка создания комнаты:', error)
|
||||
console.error('Error response:', error.response?.data)
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
alert('Сессия истекла. Пожалуйста, войдите снова.')
|
||||
localStorage.removeItem('token')
|
||||
navigate('/login')
|
||||
} else {
|
||||
alert('Ошибка создания комнаты: ' + (error.response?.data?.detail || error.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const joinRoom = (e) => {
|
||||
e.preventDefault()
|
||||
if (!roomCode.trim()) {
|
||||
alert('Введите код комнаты')
|
||||
return
|
||||
}
|
||||
setShowJoin(false)
|
||||
setRoomCode('')
|
||||
navigate(`/room/${roomCode.toUpperCase()}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rooms-container">
|
||||
<div className="rooms-header">
|
||||
<button onClick={() => navigate('/')} className="back-btn">
|
||||
<ArrowLeft size={24} />
|
||||
</button>
|
||||
<h1>Комнаты</h1>
|
||||
</div>
|
||||
|
||||
<div className="rooms-actions">
|
||||
<div className="room-action-card" onClick={() => setShowCreate(true)}>
|
||||
<div className="action-icon create">
|
||||
<Plus size={48} />
|
||||
</div>
|
||||
<h2>Создать комнату</h2>
|
||||
<p>Создайте свою комнату для прослушивания музыки с друзьями</p>
|
||||
</div>
|
||||
|
||||
<div className="room-action-card" onClick={() => setShowJoin(true)}>
|
||||
<div className="action-icon join">
|
||||
<LogIn size={48} />
|
||||
</div>
|
||||
<h2>Войти в комнату</h2>
|
||||
<p>Присоединитесь к существующей комнате по коду</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="modal-overlay" onClick={() => setShowCreate(false)}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>Создать комнату</h2>
|
||||
<form onSubmit={createRoom}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Название комнаты"
|
||||
value={roomName}
|
||||
onChange={(e) => setRoomName(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<div className="modal-buttons">
|
||||
<button type="button" onClick={() => setShowCreate(false)} className="cancel-btn">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" className="submit-btn">
|
||||
Создать
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showJoin && (
|
||||
<div className="modal-overlay" onClick={() => setShowJoin(false)}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>Войти в комнату</h2>
|
||||
<form onSubmit={joinRoom}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Код комнаты"
|
||||
value={roomCode}
|
||||
onChange={(e) => setRoomCode(e.target.value.toUpperCase())}
|
||||
required
|
||||
autoFocus
|
||||
maxLength={6}
|
||||
/>
|
||||
<div className="modal-buttons">
|
||||
<button type="button" onClick={() => setShowJoin(false)} className="cancel-btn">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" className="submit-btn">
|
||||
Войти
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Rooms
|
||||
142
frontend/src/pages/Upload.css
Normal file
142
frontend/src/pages/Upload.css
Normal file
@@ -0,0 +1,142 @@
|
||||
.upload-container {
|
||||
min-height: 100vh;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.upload-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 12px;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.upload-header h1 {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.upload-card {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.form-group input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.file-input-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-input-hidden {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.file-input-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 18px 24px;
|
||||
border-radius: 12px;
|
||||
border: 2px dashed rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.file-input-label:hover {
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.file-button {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 10px 24px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s ease;
|
||||
white-space: nowrap;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.file-input-label:hover .file-button {
|
||||
background: linear-gradient(135deg, #7c8ef5 0%, #8a5bb5 100%);
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.file-name {
|
||||
flex: 1;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
126
frontend/src/pages/Upload.jsx
Normal file
126
frontend/src/pages/Upload.jsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { ArrowLeft, Upload as UploadIcon } from 'lucide-react'
|
||||
import './Upload.css'
|
||||
|
||||
function Upload() {
|
||||
const [title, setTitle] = useState('')
|
||||
const [artist, setArtist] = useState('')
|
||||
const [file, setFile] = useState(null)
|
||||
const [cover, setCover] = useState(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!file) {
|
||||
alert('Выберите аудиофайл')
|
||||
return
|
||||
}
|
||||
|
||||
const audio = new Audio()
|
||||
audio.src = URL.createObjectURL(file)
|
||||
|
||||
audio.addEventListener('loadedmetadata', async () => {
|
||||
const duration = Math.floor(audio.duration)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('title', title)
|
||||
formData.append('artist', artist)
|
||||
formData.append('duration', duration)
|
||||
formData.append('file', file)
|
||||
if (cover) formData.append('cover', cover)
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
await axios.post('http://localhost:8000/api/music/upload', formData, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
alert('Песня успешно загружена!')
|
||||
navigate('/')
|
||||
} catch (error) {
|
||||
alert('Ошибка загрузки: ' + (error.response?.data?.detail || 'Неизвестная ошибка'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="upload-container">
|
||||
<div className="upload-header">
|
||||
<button onClick={() => navigate('/')} className="back-btn">
|
||||
<ArrowLeft size={24} />
|
||||
</button>
|
||||
<h1>Загрузить песню</h1>
|
||||
</div>
|
||||
|
||||
<div className="upload-card">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Название</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Исполнитель</label>
|
||||
<input
|
||||
type="text"
|
||||
value={artist}
|
||||
onChange={(e) => setArtist(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Аудиофайл (MP3)</label>
|
||||
<div className="file-input-wrapper">
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={(e) => setFile(e.target.files[0])}
|
||||
required
|
||||
id="audio-file"
|
||||
className="file-input-hidden"
|
||||
/>
|
||||
<label htmlFor="audio-file" className="file-input-label">
|
||||
<span className="file-button">Выбор файла</span>
|
||||
<span className="file-name">{file ? file.name : 'Не выбран ни один файл'}</span>
|
||||
<UploadIcon size={20} className="upload-icon" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Обложка (необязательно)</label>
|
||||
<div className="file-input-wrapper">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setCover(e.target.files[0])}
|
||||
id="cover-file"
|
||||
className="file-input-hidden"
|
||||
/>
|
||||
<label htmlFor="cover-file" className="file-input-label">
|
||||
<span className="file-button">Выбор файла</span>
|
||||
<span className="file-name">{cover ? cover.name : 'Не выбран ни один файл'}</span>
|
||||
<UploadIcon size={20} className="upload-icon" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="submit-btn">Загрузить</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Upload
|
||||
17
frontend/src/services/api.js
Normal file
17
frontend/src/services/api.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const API_URL = 'http://localhost:8000'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_URL
|
||||
})
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
export default api
|
||||
Reference in New Issue
Block a user