first attempt at code mostly working

This commit is contained in:
2025-12-12 21:30:26 -08:00
parent 7bc01a3828
commit a840e9385d
32 changed files with 2160 additions and 0 deletions

18
web/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:20-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

12
web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Discord DJ</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

28
web/nginx.conf Normal file
View File

@@ -0,0 +1,28 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://api:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
location /ws {
proxy_pass http://api:3001/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
}

24
web/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "music-bot-web",
"version": "1.0.0",
"description": "Web interface for Discord music bot",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"vite": "^6.0.5"
}
}

6
web/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

169
web/src/App.jsx Normal file
View 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;

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

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

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

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

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

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

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

10
web/tailwind.config.js Normal file
View File

@@ -0,0 +1,10 @@
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

20
web/vite.config.js Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true
},
'/ws': {
target: 'ws://localhost:3001',
ws: true
}
}
}
});