first attempt at code mostly working
This commit is contained in:
169
web/src/App.jsx
Normal file
169
web/src/App.jsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import NowPlaying from './components/NowPlaying';
|
||||
import Controls from './components/Controls';
|
||||
import TrackList from './components/TrackList';
|
||||
import UploadZone from './components/UploadZone';
|
||||
import ChannelSelector from './components/ChannelSelector';
|
||||
import useWebSocket from './hooks/useWebSocket';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
function App() {
|
||||
const [tracks, setTracks] = useState([]);
|
||||
const [currentTrack, setCurrentTrack] = useState(null);
|
||||
const [playbackState, setPlaybackState] = useState('stopped');
|
||||
const [position, setPosition] = useState(0);
|
||||
const [volume, setVolume] = useState(100);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const { connected } = useWebSocket((event, data) => {
|
||||
if (event === 'trackChange') {
|
||||
setCurrentTrack(data.track);
|
||||
} else if (event === 'playbackUpdate') {
|
||||
setPlaybackState(data.state);
|
||||
if (data.position !== undefined) setPosition(data.position);
|
||||
if (data.volume !== undefined) setVolume(data.volume);
|
||||
} else if (event === 'libraryUpdate') {
|
||||
loadTracks();
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadTracks();
|
||||
loadPlaybackState();
|
||||
}, []);
|
||||
|
||||
const loadTracks = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/tracks`);
|
||||
const data = await response.json();
|
||||
setTracks(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load tracks:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPlaybackState = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/playback`);
|
||||
const data = await response.json();
|
||||
setCurrentTrack(data.currentTrack);
|
||||
setPlaybackState(data.state);
|
||||
setPosition(data.position);
|
||||
setVolume(data.volume);
|
||||
} catch (error) {
|
||||
console.error('Failed to load playback state:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlay = async () => {
|
||||
try {
|
||||
await fetch(`${API_URL}/playback/play`, { method: 'POST' });
|
||||
setPlaybackState('playing');
|
||||
} catch (error) {
|
||||
console.error('Failed to play:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = async () => {
|
||||
try {
|
||||
await fetch(`${API_URL}/playback/pause`, { method: 'POST' });
|
||||
setPlaybackState('paused');
|
||||
} catch (error) {
|
||||
console.error('Failed to pause:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = async () => {
|
||||
try {
|
||||
await fetch(`${API_URL}/playback/skip`, { method: 'POST' });
|
||||
} catch (error) {
|
||||
console.error('Failed to skip:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVolumeChange = async (newVolume) => {
|
||||
try {
|
||||
await fetch(`${API_URL}/playback/volume`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ volume: newVolume })
|
||||
});
|
||||
setVolume(newVolume);
|
||||
} catch (error) {
|
||||
console.error('Failed to set volume:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShuffle = async () => {
|
||||
try {
|
||||
await fetch(`${API_URL}/queue/shuffle`, { method: 'POST' });
|
||||
} catch (error) {
|
||||
console.error('Failed to shuffle:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadComplete = () => {
|
||||
loadTracks();
|
||||
};
|
||||
|
||||
const handleDeleteTrack = async (trackId) => {
|
||||
try {
|
||||
await fetch(`${API_URL}/tracks/${trackId}`, { method: 'DELETE' });
|
||||
setTracks(tracks.filter(t => t.id !== trackId));
|
||||
} catch (error) {
|
||||
console.error('Failed to delete track:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-red-900 to-green-900">
|
||||
<div className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-4xl font-bold text-white mb-2">10node christmas bot</h1>
|
||||
<p className="text-slate-300 flex items-center gap-2">
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${connected ? 'bg-green-400' : 'bg-red-400'}`}></span>
|
||||
{connected ? 'Connected' : 'Disconnected'}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<NowPlaying
|
||||
track={currentTrack}
|
||||
state={playbackState}
|
||||
position={position}
|
||||
/>
|
||||
|
||||
<Controls
|
||||
state={playbackState}
|
||||
volume={volume}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onSkip={handleSkip}
|
||||
onVolumeChange={handleVolumeChange}
|
||||
onShuffle={handleShuffle}
|
||||
/>
|
||||
|
||||
<ChannelSelector />
|
||||
|
||||
<UploadZone onUploadComplete={handleUploadComplete} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TrackList
|
||||
tracks={tracks}
|
||||
currentTrack={currentTrack}
|
||||
loading={loading}
|
||||
onDelete={handleDeleteTrack}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
109
web/src/components/ChannelSelector.jsx
Normal file
109
web/src/components/ChannelSelector.jsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
function ChannelSelector() {
|
||||
const [channels, setChannels] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [joining, setJoining] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadChannels();
|
||||
const interval = setInterval(loadChannels, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const loadChannels = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${API_URL}/channels`);
|
||||
const data = await response.json();
|
||||
setChannels(data.channels || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load channels:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJoin = async (channelId) => {
|
||||
try {
|
||||
setJoining(true);
|
||||
const response = await fetch(`${API_URL}/channels/join`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ channelId })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
loadChannels();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
console.error('Failed to join channel:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to join channel:', error);
|
||||
} finally {
|
||||
setJoining(false);
|
||||
}
|
||||
};
|
||||
|
||||
const currentChannel = channels.find(c => c.current);
|
||||
|
||||
return (
|
||||
<div className="bg-slate-800/50 backdrop-blur-sm rounded-xl p-6 border border-slate-700/50">
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Voice Channel</h2>
|
||||
|
||||
{currentChannel && (
|
||||
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/30 rounded-lg">
|
||||
<div className="text-sm text-red-300 mb-1">Currently in:</div>
|
||||
<div className="text-white font-medium">{currentChannel.name}</div>
|
||||
{currentChannel.userCount > 0 && (
|
||||
<div className="text-sm text-slate-400 mt-1">
|
||||
{currentChannel.userCount} {currentChannel.userCount === 1 ? 'user' : 'users'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{loading && channels.length === 0 ? (
|
||||
<div className="text-slate-400 text-center py-4">Loading channels...</div>
|
||||
) : channels.length === 0 ? (
|
||||
<div className="text-slate-400 text-center py-4">No channels available</div>
|
||||
) : (
|
||||
channels.map(channel => (
|
||||
<button
|
||||
key={channel.id}
|
||||
onClick={() => handleJoin(channel.id)}
|
||||
disabled={channel.current || joining}
|
||||
className={`w-full text-left p-3 rounded-lg transition-all ${
|
||||
channel.current
|
||||
? 'bg-red-500/30 border border-red-500/50 cursor-default'
|
||||
: 'bg-slate-700/50 border border-slate-600/50 hover:bg-slate-600/50 hover:border-slate-500/50'
|
||||
} ${joining ? 'opacity-50 cursor-wait' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-white font-medium">{channel.name}</div>
|
||||
{channel.userCount > 0 && (
|
||||
<div className="text-sm text-slate-400">
|
||||
{channel.userCount} {channel.userCount === 1 ? 'user' : 'users'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{channel.current && (
|
||||
<svg className="w-5 h-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChannelSelector;
|
||||
59
web/src/components/Controls.jsx
Normal file
59
web/src/components/Controls.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
export default function Controls({ state, volume, onPlay, onPause, onSkip, onVolumeChange, onShuffle }) {
|
||||
return (
|
||||
<div className="bg-slate-800/50 backdrop-blur-sm rounded-xl p-6 border border-slate-700">
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
{state === 'playing' ? (
|
||||
<button
|
||||
onClick={onPause}
|
||||
className="w-14 h-14 bg-red-600 hover:bg-red-700 rounded-full flex items-center justify-center text-white text-xl transition-colors"
|
||||
title="Pause"
|
||||
>
|
||||
⏸
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onPlay}
|
||||
className="w-14 h-14 bg-red-600 hover:bg-red-700 rounded-full flex items-center justify-center text-white text-xl transition-colors"
|
||||
title="Play"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onSkip}
|
||||
className="w-14 h-14 bg-slate-700 hover:bg-slate-600 rounded-full flex items-center justify-center text-white text-xl transition-colors"
|
||||
title="Skip"
|
||||
>
|
||||
⏭
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onShuffle}
|
||||
className="w-14 h-14 bg-slate-700 hover:bg-slate-600 rounded-full flex items-center justify-center text-white text-xl transition-colors"
|
||||
title="Shuffle"
|
||||
>
|
||||
🔀
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 flex-1 max-w-xs">
|
||||
<span className="text-white text-xl">🔊</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={volume}
|
||||
onChange={(e) => onVolumeChange(parseInt(e.target.value))}
|
||||
className="flex-1 h-2 bg-slate-700 rounded-full appearance-none cursor-pointer"
|
||||
style={{
|
||||
background: `linear-gradient(to right, rgb(220 38 38) 0%, rgb(220 38 38) ${volume}%, rgb(51 65 85) ${volume}%, rgb(51 65 85) 100%)`
|
||||
}}
|
||||
/>
|
||||
<span className="text-slate-400 text-sm w-12 text-right">{volume}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
web/src/components/NowPlaying.jsx
Normal file
72
web/src/components/NowPlaying.jsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
function formatTime(seconds) {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function NowPlaying({ track, state, position }) {
|
||||
const [displayPosition, setDisplayPosition] = useState(position);
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayPosition(position);
|
||||
}, [position]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state === 'playing') {
|
||||
const interval = setInterval(() => {
|
||||
setDisplayPosition(prev => prev + 1);
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
if (!track) {
|
||||
return (
|
||||
<div className="bg-slate-800/50 backdrop-blur-sm rounded-xl p-8 border border-slate-700">
|
||||
<div className="text-center text-slate-400">
|
||||
<div className="text-6xl mb-4">🎵</div>
|
||||
<p>No track playing</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-slate-800/50 backdrop-blur-sm rounded-xl p-8 border border-slate-700">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="w-24 h-24 bg-gradient-to-br from-red-600 to-green-600 rounded-lg flex items-center justify-center text-4xl">
|
||||
🎵
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-slate-400 mb-1">Now Playing</div>
|
||||
<h2 className="text-3xl font-bold text-white mb-2">{track.title}</h2>
|
||||
<p className="text-lg text-slate-300">{track.artist}</p>
|
||||
|
||||
<div className="mt-4 flex items-center gap-4">
|
||||
<span className="text-sm text-slate-400">{formatTime(displayPosition)}</span>
|
||||
<div className="flex-1 h-2 bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-red-600 to-green-600 transition-all"
|
||||
style={{ width: track.duration > 0 ? `${(displayPosition / track.duration) * 100}%` : '0%' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-slate-400">{formatTime(track.duration || 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||
state === 'playing' ? 'bg-green-500/20 text-green-400' :
|
||||
state === 'paused' ? 'bg-yellow-500/20 text-yellow-400' :
|
||||
'bg-slate-500/20 text-slate-400'
|
||||
}`}>
|
||||
{state === 'playing' ? '▶ Playing' : state === 'paused' ? '⏸ Paused' : '⏹ Stopped'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
web/src/components/TrackList.jsx
Normal file
74
web/src/components/TrackList.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
function formatDuration(seconds) {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
export default function TrackList({ tracks, currentTrack, loading, onDelete }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-slate-800/50 backdrop-blur-sm rounded-xl p-6 border border-slate-700">
|
||||
<h3 className="text-xl font-bold text-white mb-4">Library</h3>
|
||||
<div className="text-center text-slate-400 py-8">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (tracks.length === 0) {
|
||||
return (
|
||||
<div className="bg-slate-800/50 backdrop-blur-sm rounded-xl p-6 border border-slate-700">
|
||||
<h3 className="text-xl font-bold text-white mb-4">Library</h3>
|
||||
<div className="text-center text-slate-400 py-8">
|
||||
<p>No tracks in library</p>
|
||||
<p className="text-sm mt-2">Upload some MP3 files to get started</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-slate-800/50 backdrop-blur-sm rounded-xl p-6 border border-slate-700">
|
||||
<h3 className="text-xl font-bold text-white mb-4">
|
||||
Library <span className="text-slate-400 text-sm font-normal">({tracks.length} tracks)</span>
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2 max-h-[600px] overflow-y-auto pr-2">
|
||||
{tracks.map((track) => (
|
||||
<div
|
||||
key={track.id}
|
||||
className={`p-3 rounded-lg transition-colors group ${
|
||||
currentTrack?.id === track.id
|
||||
? 'bg-purple-600/20 border border-purple-500/50'
|
||||
: 'bg-slate-700/30 hover:bg-slate-700/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-white truncate">{track.title}</div>
|
||||
<div className="text-sm text-slate-400 truncate">{track.artist}</div>
|
||||
<div className="flex gap-3 mt-1 text-xs text-slate-500">
|
||||
{track.duration > 0 && <span>{formatDuration(track.duration)}</span>}
|
||||
{track.size && <span>{formatFileSize(track.size)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onDelete(track.id)}
|
||||
className="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-300 text-sm transition-opacity"
|
||||
title="Delete track"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
web/src/components/UploadZone.jsx
Normal file
158
web/src/components/UploadZone.jsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useState, useRef } from 'react';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
export default function UploadZone({ onUploadComplete }) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadStatus, setUploadStatus] = useState(null);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const handleDragEnter = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files).filter(
|
||||
file => file.type === 'audio/mpeg' || file.name.toLowerCase().endsWith('.mp3')
|
||||
);
|
||||
|
||||
if (files.length > 0) {
|
||||
uploadFiles(files);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const files = Array.from(e.target.files);
|
||||
if (files.length > 0) {
|
||||
uploadFiles(files);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFiles = async (files) => {
|
||||
setUploading(true);
|
||||
setUploadStatus(null);
|
||||
|
||||
const formData = new FormData();
|
||||
files.forEach(file => {
|
||||
formData.append('files', file);
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/tracks/upload`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
setUploadStatus({
|
||||
success: result.success,
|
||||
uploaded: result.uploaded.length,
|
||||
errors: result.errors.length
|
||||
});
|
||||
|
||||
if (result.uploaded.length > 0) {
|
||||
onUploadComplete();
|
||||
}
|
||||
|
||||
setTimeout(() => setUploadStatus(null), 5000);
|
||||
} catch (error) {
|
||||
setUploadStatus({
|
||||
success: false,
|
||||
uploaded: 0,
|
||||
errors: 1
|
||||
});
|
||||
console.error('Upload failed:', error);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-slate-800/50 backdrop-blur-sm rounded-xl p-6 border border-slate-700">
|
||||
<h3 className="text-xl font-bold text-white mb-4">Upload Music</h3>
|
||||
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
|
||||
isDragging
|
||||
? 'border-purple-500 bg-purple-500/10'
|
||||
: 'border-slate-600 hover:border-slate-500'
|
||||
}`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="audio/mpeg,.mp3"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
id="file-upload"
|
||||
/>
|
||||
|
||||
<div className="text-5xl mb-4">🎵</div>
|
||||
|
||||
{uploading ? (
|
||||
<div>
|
||||
<div className="text-white mb-2">Uploading...</div>
|
||||
<div className="w-full h-2 bg-slate-700 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-purple-600 animate-pulse" style={{ width: '100%' }} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-white mb-2">
|
||||
Drag and drop MP3 files here
|
||||
</p>
|
||||
<p className="text-slate-400 text-sm mb-4">or</p>
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className="inline-block px-6 py-3 bg-purple-600 hover:bg-purple-700 text-white rounded-lg cursor-pointer transition-colors"
|
||||
>
|
||||
Browse Files
|
||||
</label>
|
||||
<p className="text-slate-500 text-xs mt-4">
|
||||
Max 50MB per file, up to 20 files
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{uploadStatus && (
|
||||
<div className={`mt-4 p-4 rounded-lg ${
|
||||
uploadStatus.success ? 'bg-green-500/20 border border-green-500/50' : 'bg-red-500/20 border border-red-500/50'
|
||||
}`}>
|
||||
<p className={uploadStatus.success ? 'text-green-400' : 'text-red-400'}>
|
||||
{uploadStatus.success
|
||||
? `Successfully uploaded ${uploadStatus.uploaded} file(s)`
|
||||
: `Upload failed: ${uploadStatus.errors} error(s)`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
web/src/hooks/useWebSocket.js
Normal file
74
web/src/hooks/useWebSocket.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
|
||||
const WS_URL = import.meta.env.VITE_WS_URL || `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`;
|
||||
|
||||
export default function useWebSocket(onMessage) {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const wsRef = useRef(null);
|
||||
const reconnectTimeoutRef = useRef(null);
|
||||
const onMessageRef = useRef(onMessage);
|
||||
|
||||
// Keep the callback ref up to date
|
||||
useEffect(() => {
|
||||
onMessageRef.current = onMessage;
|
||||
}, [onMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
const connect = () => {
|
||||
try {
|
||||
const ws = new WebSocket(WS_URL);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
setConnected(true);
|
||||
ws.send(JSON.stringify({ event: 'subscribe' }));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.event && message.event !== 'connected') {
|
||||
onMessageRef.current(message.event, message.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket disconnected');
|
||||
setConnected(false);
|
||||
wsRef.current = null;
|
||||
|
||||
// Reconnect after 3 seconds
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
connect();
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
ws.close();
|
||||
};
|
||||
|
||||
wsRef.current = ws;
|
||||
} catch (error) {
|
||||
console.error('Failed to create WebSocket:', error);
|
||||
reconnectTimeoutRef.current = setTimeout(connect, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { connected };
|
||||
}
|
||||
14
web/src/index.css
Normal file
14
web/src/index.css
Normal file
@@ -0,0 +1,14 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: #0f172a;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
10
web/src/main.jsx
Normal file
10
web/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>
|
||||
);
|
||||
Reference in New Issue
Block a user