feat: add beautiful web queue dashboard with loop, reordering, and text channel settings persistence

This commit is contained in:
mmacierzanka committed 2026-05-23 14:32:43 +02:00
1 parent 6382955b3f
commit 71800c8fd5
9 files changed
+2367 -298

No files matched your search

+621
View File
@@ -0,0 +1,621 @@
// Global variables
let isDragging = false;
let currentStatus = null;
let currentVolume = 50;
let isUpdatingVolume = false;
// DOM Elements
const statusDot = document.getElementById('status-dot');
const statusText = document.getElementById('status-text');
const voiceSelect = document.getElementById('voice-channel-select');
const textChannelSelect = document.getElementById('text-channel-select');
const btnConnect = document.getElementById('btn-connect');
const trackTitle = document.getElementById('track-title');
const trackArtist = document.getElementById('track-artist');
const trackLink = document.getElementById('track-link');
const loopBadge = document.getElementById('loop-badge');
const btnPlayPause = document.getElementById('btn-play-pause');
const playPauseIcon = document.getElementById('play-pause-icon');
const btnSkip = document.getElementById('btn-skip');
const btnStop = document.getElementById('btn-stop');
const btnLoop = document.getElementById('btn-loop');
const vinylRecord = document.getElementById('vinyl-record');
const musicWave = document.getElementById('music-wave');
const volumeSlider = document.getElementById('volume-slider');
const volumeIcon = document.getElementById('volume-icon');
const volumeValue = document.getElementById('volume-value');
const addForm = document.getElementById('add-track-form');
const btnAddTrack = document.getElementById('btn-add-track');
const btnAddText = document.getElementById('btn-add-text');
const btnAddSpinner = document.getElementById('btn-add-spinner');
const trackQueryInput = document.getElementById('track-query');
const queueList = document.getElementById('queue-list');
const emptyQueueMsg = document.getElementById('empty-queue-msg');
const queueCountBadge = document.getElementById('queue-count');
const toastContainer = document.getElementById('toast-container');
// Initialize
document.addEventListener('DOMContentLoaded', () => {
// Initial fetch
fetchStatus();
// Start Poller (every 1 second)
setInterval(() => {
if (!isDragging) {
fetchStatus();
}
}, 1000);
// Bind Event Listeners
bindEvents();
});
// Bind UI Events
function bindEvents() {
// Connection Button
btnConnect.addEventListener('click', toggleConnection);
// Playback Controls
btnPlayPause.addEventListener('click', togglePlayPause);
btnSkip.addEventListener('click', skipTrack);
btnStop.addEventListener('click', stopPlayback);
btnLoop.addEventListener('click', toggleLoop);
// Volume slider
volumeSlider.addEventListener('input', (e) => {
isUpdatingVolume = true;
const val = e.target.value;
volumeValue.textContent = `${val}%`;
updateVolumeIcon(val);
});
volumeSlider.addEventListener('change', (e) => {
const val = parseInt(e.target.value);
setVolume(val);
setTimeout(() => {
isUpdatingVolume = false;
}, 500);
});
// Add Song Form
addForm.addEventListener('submit', addTrack);
// Text Channel Select
textChannelSelect.addEventListener('change', changeTextChannel);
}
// Show Toast Notification
function showToast(message, type = 'success') {
const toast = document.createElement('div');
toast.className = `toast ${type}`;
let icon = 'info';
if (type === 'success') icon = 'check-circle-2';
if (type === 'error') icon = 'alert-triangle';
toast.innerHTML = `<i data-lucide="${icon}"></i> <span>${message}</span>`;
toastContainer.appendChild(toast);
lucide.createIcons();
// Remove after 3.5 seconds
setTimeout(() => {
toast.style.transform = 'translateY(-20px)';
toast.style.opacity = '0';
setTimeout(() => {
toast.remove();
}, 300);
}, 3500);
}
// Update Volume Icon
function updateVolumeIcon(vol) {
vol = parseInt(vol);
if (vol === 0) {
volumeIcon.setAttribute('data-lucide', 'volume-x');
} else if (vol < 35) {
volumeIcon.setAttribute('data-lucide', 'volume');
} else if (vol < 75) {
volumeIcon.setAttribute('data-lucide', 'volume-1');
} else {
volumeIcon.setAttribute('data-lucide', 'volume-2');
}
lucide.createIcons();
}
// API Calls
async function fetchStatus() {
try {
const res = await fetch('/api/status');
if (!res.ok) throw new Error('API Error');
const data = await res.json();
currentStatus = data;
updateUI(data);
} catch (err) {
console.error('Błąd pobierania statusu:', err);
statusDot.className = 'status-indicator offline';
statusText.textContent = 'Błąd połączenia z API';
}
}
// Update UI States
function updateUI(status) {
// 1. Connection Status & Available Channels
if (status.active_voice_channel) {
statusDot.className = 'status-indicator online';
statusText.textContent = `Połączony: ${status.active_voice_channel.channel_name} (${status.active_voice_channel.guild_name})`;
btnConnect.innerHTML = '<i data-lucide="log-out"></i> Odłącz';
btnConnect.className = 'btn btn-danger';
} else {
statusDot.className = 'status-indicator offline';
statusText.textContent = 'Rozłączony';
btnConnect.innerHTML = '<i data-lucide="log-in"></i> Połącz';
btnConnect.className = 'btn btn-primary btn-glow';
}
// Voice selector population (only if select doesn't have focus/interaction)
if (document.activeElement !== voiceSelect) {
// Keep selected value
const currentSelected = voiceSelect.value;
// Clear
voiceSelect.innerHTML = '<option value="" disabled>Wybierz kanał...</option>';
if (status.voice_channels && status.voice_channels.length > 0) {
status.voice_channels.forEach(channel => {
const opt = document.createElement('option');
opt.value = channel.channel_id;
opt.textContent = `${channel.guild_name} - ${channel.channel_name}`;
voiceSelect.appendChild(opt);
});
// Re-select or select first/active
if (status.active_voice_channel) {
voiceSelect.value = status.active_voice_channel.channel_id;
} else if (currentSelected && status.voice_channels.some(c => c.channel_id === currentSelected)) {
voiceSelect.value = currentSelected;
} else {
voiceSelect.value = status.voice_channels[0].channel_id;
}
} else {
const opt = document.createElement('option');
opt.value = "";
opt.textContent = "Brak dostępnych kanałów";
voiceSelect.appendChild(opt);
}
}
// Text selector population (only if select doesn't have focus/interaction)
if (document.activeElement !== textChannelSelect) {
const currentSelectedText = textChannelSelect.value;
textChannelSelect.innerHTML = '<option value="" disabled>Wybierz kanał...</option>';
if (status.text_channels && status.text_channels.length > 0) {
status.text_channels.forEach(channel => {
const opt = document.createElement('option');
opt.value = channel.channel_id;
opt.textContent = `${channel.guild_name} - #${channel.channel_name}`;
textChannelSelect.appendChild(opt);
});
if (status.active_text_channel_id) {
textChannelSelect.value = status.active_text_channel_id;
} else if (currentSelectedText && status.text_channels.some(c => c.channel_id === currentSelectedText)) {
textChannelSelect.value = currentSelectedText;
}
} else {
const opt = document.createElement('option');
opt.value = "";
opt.textContent = "Brak dostępnych kanałów";
textChannelSelect.appendChild(opt);
}
}
// 2. Playback State
if (status.current) {
trackTitle.textContent = status.current.title;
trackArtist.textContent = `Wyszukano/URL: ${status.current.url ? 'YouTube Video' : 'Nieznany'}`;
trackLink.href = status.current.url || '#';
trackLink.style.display = status.current.url ? 'inline-flex' : 'none';
// Vinyl animation
if (status.playing) {
vinylRecord.classList.add('playing');
musicWave.classList.add('active');
} else {
vinylRecord.classList.remove('playing');
musicWave.classList.remove('active');
}
} else {
trackTitle.textContent = 'Nic nie jest odtwarzane';
trackArtist.textContent = 'Połącz bota i dodaj utwór';
trackLink.style.display = 'none';
vinylRecord.classList.remove('playing');
musicWave.classList.remove('active');
}
// 3. Play/Pause Button State
if (status.playing) {
playPauseIcon.setAttribute('data-lucide', 'pause');
btnPlayPause.title = "Wstrzymaj";
} else {
playPauseIcon.setAttribute('data-lucide', 'play');
btnPlayPause.title = "Odtwarzaj";
}
// 4. Volume State
if (!isUpdatingVolume) {
volumeSlider.value = status.volume;
volumeValue.textContent = `${status.volume}%`;
updateVolumeIcon(status.volume);
}
// 5. Loop State
if (status.loop_single) {
loopBadge.style.display = 'flex';
btnLoop.classList.add('active');
} else {
loopBadge.style.display = 'none';
btnLoop.classList.remove('active');
}
// 6. Queue render (if not currently dragging)
if (!isDragging) {
renderQueue(status.queue);
}
// Re-trigger lucide icons update
lucide.createIcons();
}
// Render Queue Items list
function renderQueue(queue) {
// Clear list but keep empty queue message
const items = queueList.querySelectorAll('.queue-item');
items.forEach(el => el.remove());
if (!queue || queue.length === 0) {
emptyQueueMsg.style.display = 'flex';
queueCountBadge.textContent = '0 utworów';
return;
}
emptyQueueMsg.style.display = 'none';
queueCountBadge.textContent = `${queue.length} ${queue.length === 1 ? 'utwór' : queue.length < 5 ? 'utwory' : 'utworów'}`;
queue.forEach((track, idx) => {
const li = document.createElement('li');
li.className = 'queue-item';
li.setAttribute('draggable', 'true');
li.setAttribute('data-id', track.id);
li.setAttribute('data-index', idx);
li.innerHTML = `
<div class="drag-handle" title="Przeciągnij, aby zmienić kolejność">
<i data-lucide="grip-vertical"></i>
</div>
<div class="queue-index">${idx + 1}</div>
<div class="queue-track-info">
<div class="queue-track-title" title="${track.title}">${track.title}</div>
</div>
<div class="queue-item-actions">
<button class="btn-action-icon" onclick="moveItemUp(${idx})" title="Przesuń w górę">
<i data-lucide="arrow-up"></i>
</button>
<button class="btn-action-icon" onclick="moveItemDown(${idx})" title="Przesuń w dół">
<i data-lucide="arrow-down"></i>
</button>
<button class="btn-action-icon delete" onclick="removeTrack('${track.id}')" title="Usuń z kolejki">
<i data-lucide="trash-2"></i>
</button>
</div>
`;
// Add Drag and Drop listeners
li.addEventListener('dragstart', handleDragStart);
li.addEventListener('dragenter', handleDragEnter);
li.addEventListener('dragover', handleDragOver);
li.addEventListener('dragleave', handleDragLeave);
li.addEventListener('drop', handleDrop);
li.addEventListener('dragend', handleDragEnd);
queueList.appendChild(li);
});
}
// Drag & Drop Handlers
let dragSrcEl = null;
function handleDragStart(e) {
this.classList.add('dragging');
isDragging = true;
dragSrcEl = this;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', this.getAttribute('data-id'));
}
function handleDragEnter(e) {
if (this !== dragSrcEl) {
this.classList.add('drag-over');
}
}
function handleDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
return false;
}
function handleDragLeave(e) {
this.classList.remove('drag-over');
}
function handleDrop(e) {
e.stopPropagation();
e.preventDefault();
this.classList.remove('drag-over');
if (dragSrcEl !== this) {
const listItems = Array.from(queueList.querySelectorAll('.queue-item'));
const fromIdx = parseInt(dragSrcEl.getAttribute('data-index'));
const toIdx = parseInt(this.getAttribute('data-index'));
// Local state swap for speed
const srcId = dragSrcEl.getAttribute('data-id');
const targetId = this.getAttribute('data-id');
// Re-arrange DOM locally for visual feedback
if (fromIdx < toIdx) {
this.parentNode.insertBefore(dragSrcEl, this.nextSibling);
} else {
this.parentNode.insertBefore(dragSrcEl, this);
}
// Recalculate index numbers locally
const updatedItems = Array.from(queueList.querySelectorAll('.queue-item'));
const newOrderIds = updatedItems.map(item => item.getAttribute('data-id'));
// Send to backend
sendReorder(newOrderIds);
}
return false;
}
function handleDragEnd(e) {
this.classList.remove('dragging');
const items = queueList.querySelectorAll('.queue-item');
items.forEach(item => {
item.classList.remove('drag-over');
});
isDragging = false;
}
// Manual Up/Down Buttons
function moveItemUp(idx) {
if (idx === 0) return;
const items = Array.from(queueList.querySelectorAll('.queue-item'));
const ids = items.map(item => item.getAttribute('data-id'));
// Swap elements in ids
const temp = ids[idx];
ids[idx] = ids[idx - 1];
ids[idx - 1] = temp;
sendReorder(ids);
}
function moveItemDown(idx) {
const items = Array.from(queueList.querySelectorAll('.queue-item'));
if (idx === items.length - 1) return;
const ids = items.map(item => item.getAttribute('data-id'));
// Swap elements in ids
const temp = ids[idx];
ids[idx] = ids[idx + 1];
ids[idx + 1] = temp;
sendReorder(ids);
}
async function sendReorder(queueIds) {
try {
const res = await fetch('/api/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ queue_ids: queueIds })
});
if (!res.ok) throw new Error('Reorder failed');
showToast('Zmieniono kolejność utworów!', 'success');
fetchStatus();
} catch (err) {
showToast('Nie udało się zapisać kolejności', 'error');
fetchStatus();
}
}
// Add Track to Queue
async function addTrack(e) {
e.preventDefault();
const query = trackQueryInput.value.trim();
if (!query) return;
// Check if bot is connected
if (!currentStatus || !currentStatus.active_voice_channel) {
showToast('Bot musi być połączony z kanałem głosowym!', 'error');
return;
}
// Show spinner
btnAddText.style.display = 'none';
btnAddSpinner.style.display = 'inline-block';
btnAddTrack.disabled = true;
trackQueryInput.disabled = true;
try {
const res = await fetch('/api/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: query })
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.detail || 'Failed to add');
}
showToast(`Dodano utwór do kolejki!`, 'success');
trackQueryInput.value = '';
fetchStatus();
} catch (err) {
showToast(`Błąd dodawania: ${err.message}`, 'error');
} finally {
btnAddText.style.display = 'inline-flex';
btnAddSpinner.style.display = 'none';
btnAddTrack.disabled = false;
trackQueryInput.disabled = false;
}
}
// Remove Track
async function removeTrack(id) {
try {
const res = await fetch('/api/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: id })
});
if (!res.ok) throw new Error('Remove failed');
showToast('Usunięto utwór z kolejki', 'success');
fetchStatus();
} catch (err) {
showToast('Nie udało się usunąć utworu', 'error');
}
}
// Toggle Connection
async function toggleConnection() {
if (currentStatus && currentStatus.active_voice_channel) {
// Disconnect
try {
const res = await fetch('/api/disconnect', { method: 'POST' });
if (!res.ok) throw new Error('Disconnect failed');
showToast('Rozłączono z kanału głosowego', 'info');
fetchStatus();
} catch (err) {
showToast('Błąd rozłączania', 'error');
}
} else {
// Connect
const channelId = voiceSelect.value;
if (!channelId) {
showToast('Wybierz kanał głosowy z listy!', 'error');
return;
}
try {
const res = await fetch('/api/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel_id: channelId })
});
if (!res.ok) throw new Error('Connect failed');
showToast('Połączono z kanałem głosowym!', 'success');
fetchStatus();
} catch (err) {
showToast('Nie udało się połączyć', 'error');
}
}
}
// Toggle Play / Pause
async function togglePlayPause() {
if (!currentStatus) return;
const endpoint = currentStatus.playing ? '/api/pause' : '/api/resume';
try {
const res = await fetch(endpoint, { method: 'POST' });
if (!res.ok) throw new Error('Action failed');
showToast(currentStatus.playing ? 'Wstrzymano odtwarzanie' : 'Wznowiono odtwarzanie', 'info');
fetchStatus();
} catch (err) {
showToast('Błąd sterowania odtwarzaniem', 'error');
}
}
// Skip Track
async function skipTrack() {
try {
const res = await fetch('/api/skip', { method: 'POST' });
if (!res.ok) throw new Error('Skip failed');
showToast('Pominięto utwór', 'info');
fetchStatus();
} catch (err) {
showToast('Błąd pomijania utworu', 'error');
}
}
// Stop Playback
async function stopPlayback() {
try {
const res = await fetch('/api/stop', { method: 'POST' });
if (!res.ok) throw new Error('Stop failed');
showToast('Zatrzymano odtwarzanie i wyczyszczono kolejkę', 'info');
fetchStatus();
} catch (err) {
showToast('Błąd zatrzymywania', 'error');
}
}
// Toggle Loop State
async function toggleLoop() {
if (!currentStatus) return;
const nextState = !currentStatus.loop_single;
try {
const res = await fetch('/api/loop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ loop: nextState })
});
if (!res.ok) throw new Error('Loop failed');
showToast(nextState ? 'Zapętlanie utworu włączone' : 'Zapętlanie utworu wyłączone', 'info');
fetchStatus();
} catch (err) {
showToast('Błąd ustawiania pętli', 'error');
}
}
// Set Volume
async function setVolume(vol) {
try {
const res = await fetch('/api/volume', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ volume: vol })
});
if (!res.ok) throw new Error('Volume failed');
fetchStatus();
} catch (err) {
showToast('Nie udało się zmienić głośności', 'error');
}
}
// Change Text Notification Channel
async function changeTextChannel() {
const channelId = textChannelSelect.value;
if (!channelId) return;
try {
const res = await fetch('/api/settings/text_channel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel_id: channelId })
});
if (!res.ok) throw new Error('Settings change failed');
showToast('Zapisano kanał powiadomień na Discordzie!', 'success');
fetchStatus();
} catch (err) {
showToast('Nie udało się zapisać kanału powiadomień', 'error');
}
}
+167
View File
@@ -0,0 +1,167 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VibeBot - Dashboard Kolejki</title>
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="background-glow bg-glow-1"></div>
<div class="background-glow bg-glow-2"></div>
<div class="app-container">
<!-- Header -->
<header class="main-header">
<div class="logo-area">
<div class="logo-icon">
<i data-lucide="music"></i>
</div>
<div class="logo-text">
<h1>VibeBot</h1>
<span class="subtitle">Web Controller</span>
</div>
</div>
<div class="bot-status-badge" id="bot-status-container">
<span class="status-indicator offline" id="status-dot"></span>
<span id="status-text">Rozłączony</span>
</div>
</header>
<!-- Main Layout Grid -->
<main class="dashboard-grid">
<!-- Left Side: Controls & Connection -->
<section class="grid-column left-column">
<!-- Connection & Settings Panel -->
<div class="glass-card connection-panel">
<h2 class="card-title"><i data-lucide="radio"></i> Kanał Głosowy</h2>
<div class="control-row" style="margin-bottom: 20px;">
<select id="voice-channel-select" class="premium-select">
<option value="" disabled selected>Wybierz kanał...</option>
</select>
<button id="btn-connect" class="btn btn-primary btn-glow">
<i data-lucide="log-in"></i> Połącz
</button>
</div>
<h2 class="card-title" style="margin-top: 10px;"><i data-lucide="message-square"></i> Kanał Tekstowy Logów</h2>
<div class="control-row">
<select id="text-channel-select" class="premium-select">
<option value="" disabled selected>Wybierz kanał...</option>
</select>
</div>
</div>
<!-- Player Panel (Now Playing) -->
<div class="glass-card player-panel">
<div class="card-header-actions">
<span class="badge badge-live">Live</span>
</div>
<!-- Vinyl / Wave Visualizer -->
<div class="visualizer-container">
<div class="vinyl-record" id="vinyl-record">
<div class="vinyl-center"></div>
<div class="vinyl-grooves"></div>
<div class="vinyl-glow"></div>
<div class="vinyl-icon"><i data-lucide="music-4"></i></div>
</div>
<div class="music-wave" id="music-wave">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
</div>
<!-- Song Info -->
<div class="now-playing-info">
<h3 class="track-title" id="track-title">Nic nie jest odtwarzane</h3>
<p class="track-artist" id="track-artist">Połącz bota i dodaj utwór</p>
<a href="#" target="_blank" class="track-link-btn" id="track-link" style="display: none;">
<i data-lucide="external-link"></i> Otwórz w YouTube
</a>
</div>
<!-- Progress / State (Simple status text) -->
<div class="playback-state-bar">
<span class="badge" id="loop-badge"><i data-lucide="repeat-1"></i> Zapętlenie aktywne</span>
</div>
<!-- Playback Controls -->
<div class="player-controls">
<button class="btn-icon btn-secondary" id="btn-stop" title="Zatrzymaj i wyczyść">
<i data-lucide="square"></i>
</button>
<button class="btn-icon btn-primary" id="btn-play-pause" title="Odtwarzaj / Pauza">
<i data-lucide="play" id="play-pause-icon"></i>
</button>
<button class="btn-icon btn-secondary" id="btn-skip" title="Pomiń utwór">
<i data-lucide="skip-forward"></i>
</button>
<button class="btn-icon btn-secondary" id="btn-loop" title="Zapętl ten utwór">
<i data-lucide="repeat-1"></i>
</button>
</div>
<!-- Volume Control -->
<div class="volume-container">
<button class="btn-icon-small" id="btn-mute" title="Wycisz">
<i data-lucide="volume-2" id="volume-icon"></i>
</button>
<input type="range" id="volume-slider" class="premium-slider" min="0" max="100" value="50">
<span class="volume-value" id="volume-value">50%</span>
</div>
</div>
</section>
<!-- Right Side: Add Song & Queue -->
<section class="grid-column right-column">
<!-- Add Track Panel -->
<div class="glass-card add-track-panel">
<h2 class="card-title"><i data-lucide="plus-circle"></i> Dodaj do kolejki</h2>
<form id="add-track-form" class="add-form">
<div class="input-with-icon">
<i data-lucide="search" class="input-icon"></i>
<input type="text" id="track-query" class="premium-input" placeholder="Wklej link YouTube lub wpisz wyszukiwanie..." required>
</div>
<button type="submit" id="btn-add-track" class="btn btn-primary">
<span id="btn-add-text"><i data-lucide="music-3"></i> Dodaj</span>
<span id="btn-add-spinner" class="spinner" style="display: none;"></span>
</button>
</form>
</div>
<!-- Queue List Panel -->
<div class="glass-card queue-panel">
<div class="queue-header">
<h2 class="card-title"><i data-lucide="list-music"></i> Kolejka utworów</h2>
<span class="queue-count" id="queue-count">0 utworów</span>
</div>
<div class="queue-list-container">
<ul class="queue-list" id="queue-list">
<!-- Items dynamically injected here -->
<div class="empty-queue-message" id="empty-queue-msg">
<i data-lucide="disc"></i>
<p>Kolejka jest pusta. Dodaj utwór powyżej!</p>
</div>
</ul>
</div>
</div>
</section>
</main>
</div>
<!-- Notification Toast -->
<div class="toast-container" id="toast-container"></div>
<script src="app.js"></script>
</body>
</html>
+881
View File
@@ -0,0 +1,881 @@
/* Global Styles & Variables */
:root {
--bg-dark: #070913;
--bg-surface: rgba(15, 18, 36, 0.6);
--bg-surface-hover: rgba(22, 26, 54, 0.7);
--border-glass: rgba(255, 255, 255, 0.06);
--border-active: rgba(139, 92, 246, 0.4);
--primary: #8b5cf6;
--primary-hover: #a78bfa;
--primary-glow: rgba(139, 92, 246, 0.35);
--secondary: #6366f1;
--secondary-hover: #818cf8;
--success: #10b981;
--success-glow: rgba(16, 185, 129, 0.25);
--danger: #ef4444;
--text-main: #f3f4f6;
--text-muted: #9ca3af;
--font-sans: 'Outfit', sans-serif;
--shadow-premium: 0 10px 30px -10px rgba(0, 0, 0, 0.7);
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: var(--font-sans);
}
body {
background-color: var(--bg-dark);
color: var(--text-main);
min-height: 100vh;
overflow-x: hidden;
position: relative;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
/* Background Animated Glows */
.background-glow {
position: fixed;
width: 600px;
height: 600px;
border-radius: 50%;
filter: blur(140px);
z-index: -1;
opacity: 0.25;
pointer-events: none;
animation: pulse 12s infinite alternate ease-in-out;
}
.bg-glow-1 {
background: radial-gradient(circle, var(--primary) 0%, transparent 70%);
top: -200px;
left: -200px;
}
.bg-glow-2 {
background: radial-gradient(circle, var(--secondary) 0%, transparent 70%);
bottom: -200px;
right: -200px;
animation-delay: -6s;
}
@keyframes pulse {
0% { transform: scale(1) translate(0, 0); opacity: 0.2; }
100% { transform: scale(1.2) translate(100px, 50px); opacity: 0.3; }
}
/* App Container */
.app-container {
width: 100%;
max-width: 1200px;
margin: 0 auto;
z-index: 1;
}
/* Header */
.main-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
margin-bottom: 24px;
background: var(--bg-surface);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--border-glass);
border-radius: 20px;
box-shadow: var(--shadow-premium);
}
.logo-area {
display: flex;
align-items: center;
gap: 15px;
}
.logo-icon {
width: 48px;
height: 48px;
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
color: white;
box-shadow: 0 4px 15px var(--primary-glow);
}
.logo-icon i {
width: 24px;
height: 24px;
}
.logo-text h1 {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.5px;
background: linear-gradient(to right, #ffffff, #d8b4fe);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.logo-text .subtitle {
font-size: 0.8rem;
color: var(--text-muted);
font-weight: 500;
}
.bot-status-badge {
display: flex;
align-items: center;
gap: 8px;
background: rgba(255, 255, 255, 0.05);
padding: 8px 16px;
border-radius: 30px;
font-size: 0.85rem;
font-weight: 600;
border: 1px solid var(--border-glass);
transition: var(--transition-smooth);
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.status-indicator.online {
background-color: var(--success);
box-shadow: 0 0 10px var(--success-glow);
}
.status-indicator.offline {
background-color: var(--danger);
box-shadow: 0 0 10px rgba(239, 68, 68, 0.4);
}
/* Dashboard Grid Layout */
.dashboard-grid {
display: grid;
grid-template-columns: 1fr 1.2fr;
gap: 24px;
}
@media (max-width: 900px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
}
.grid-column {
display: flex;
flex-direction: column;
gap: 24px;
}
/* Glassmorphic Cards */
.glass-card {
background: var(--bg-surface);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--border-glass);
border-radius: 24px;
padding: 24px;
box-shadow: var(--shadow-premium);
transition: var(--transition-smooth);
}
.glass-card:hover {
border-color: rgba(255, 255, 255, 0.1);
}
.card-title {
font-size: 1.2rem;
font-weight: 600;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
color: #ffffff;
}
.card-title i {
color: var(--primary);
width: 20px;
height: 20px;
}
/* Inputs & Buttons Styling */
.premium-select, .premium-input {
width: 100%;
background: rgba(0, 0, 0, 0.3);
border: 1px solid var(--border-glass);
color: var(--text-main);
padding: 12px 16px;
border-radius: 12px;
outline: none;
font-size: 0.95rem;
transition: var(--transition-smooth);
}
.premium-select:focus, .premium-input:focus {
border-color: var(--primary);
box-shadow: 0 0 10px var(--primary-glow);
background: rgba(0, 0, 0, 0.45);
}
.premium-select option {
background-color: var(--bg-dark);
color: var(--text-main);
}
.control-row {
display: flex;
gap: 12px;
}
.btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 24px;
border-radius: 12px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
border: none;
transition: var(--transition-smooth);
white-space: nowrap;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px var(--primary-glow);
}
.btn-primary:active {
transform: translateY(0);
}
.btn-glow {
position: relative;
overflow: hidden;
}
.btn-glow::after {
content: '';
position: absolute;
top: 0; left: -100%;
width: 100%; height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: 0.5s;
}
.btn-glow:hover::after {
left: 100%;
}
/* Vinyl / Wave Visualizer */
.visualizer-container {
height: 180px;
display: flex;
justify-content: center;
align-items: center;
position: relative;
margin-bottom: 20px;
}
.vinyl-record {
width: 150px;
height: 150px;
border-radius: 50%;
background: #09090b;
position: relative;
box-shadow: 0 10px 25px rgba(0,0,0,0.8);
display: flex;
justify-content: center;
align-items: center;
border: 4px solid #18181b;
transition: var(--transition-smooth);
}
.vinyl-center {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
z-index: 2;
border: 3px solid #09090b;
}
.vinyl-grooves {
position: absolute;
width: calc(100% - 20px);
height: calc(100% - 20px);
border-radius: 50%;
border: 1px double rgba(255,255,255,0.05);
}
.vinyl-grooves::before {
content: '';
position: absolute;
width: calc(100% - 30px);
height: calc(100% - 30px);
border-radius: 50%;
border: 1px solid rgba(255,255,255,0.03);
top: 15px; left: 15px;
}
.vinyl-icon {
position: absolute;
color: rgba(255,255,255,0.15);
z-index: 1;
}
.vinyl-record.playing {
animation: spin 6s linear infinite;
box-shadow: 0 10px 30px var(--primary-glow);
border-color: #27272a;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Sound Waves */
.music-wave {
position: absolute;
bottom: 5px;
display: flex;
gap: 4px;
height: 30px;
align-items: flex-end;
opacity: 0;
transition: var(--transition-smooth);
}
.music-wave.active {
opacity: 1;
}
.music-wave span {
width: 3px;
height: 10px;
background-color: var(--success);
border-radius: 3px;
animation: bounce 0.6s ease-in-out infinite alternate;
}
.music-wave span:nth-child(2) { animation-delay: 0.15s; height: 18px; }
.music-wave span:nth-child(3) { animation-delay: 0.3s; height: 25px; }
.music-wave span:nth-child(4) { animation-delay: 0.45s; height: 15px; }
.music-wave span:nth-child(5) { animation-delay: 0.6s; height: 8px; }
@keyframes bounce {
0% { height: 5px; }
100% { height: 28px; }
}
/* Now Playing Information */
.now-playing-info {
text-align: center;
margin-bottom: 20px;
}
.track-title {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 6px;
color: white;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 10px;
}
.track-artist {
font-size: 0.9rem;
color: var(--text-muted);
font-weight: 500;
}
.track-link-btn {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 10px;
font-size: 0.8rem;
color: var(--primary-hover);
text-decoration: none;
font-weight: 600;
background: rgba(139, 92, 246, 0.1);
padding: 6px 12px;
border-radius: 20px;
border: 1px solid rgba(139, 92, 246, 0.2);
transition: var(--transition-smooth);
}
.track-link-btn:hover {
background: rgba(139, 92, 246, 0.2);
transform: scale(1.05);
}
.playback-state-bar {
display: flex;
justify-content: center;
margin-bottom: 20px;
min-height: 25px;
}
.badge {
background: rgba(255, 255, 255, 0.05);
color: var(--text-muted);
padding: 4px 12px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
border: 1px solid var(--border-glass);
}
.badge-live {
background: rgba(239, 68, 68, 0.15);
color: var(--danger);
border-color: rgba(239, 68, 68, 0.2);
}
#loop-badge {
display: none;
background: rgba(16, 185, 129, 0.15);
color: var(--success);
border-color: rgba(16, 185, 129, 0.3);
animation: pulse-glow 1.5s infinite alternate;
}
@keyframes pulse-glow {
0% { box-shadow: 0 0 5px rgba(16, 185, 129, 0.1); }
100% { box-shadow: 0 0 15px rgba(16, 185, 129, 0.4); }
}
/* Playback Control Buttons */
.player-controls {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-bottom: 20px;
}
.btn-icon {
width: 54px;
height: 54px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border: none;
transition: var(--transition-smooth);
color: white;
}
.btn-icon i {
width: 22px;
height: 22px;
}
.btn-icon.btn-primary {
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
width: 64px;
height: 64px;
box-shadow: 0 4px 15px var(--primary-glow);
}
.btn-icon.btn-primary:hover {
transform: scale(1.08);
}
.btn-icon.btn-secondary {
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-glass);
}
.btn-icon.btn-secondary:hover {
background: rgba(255, 255, 255, 0.12);
transform: scale(1.05);
border-color: rgba(255,255,255,0.15);
}
/* Loop button active state */
.btn-icon.btn-secondary.active {
background: rgba(16, 185, 129, 0.15);
color: var(--success);
border-color: rgba(16, 185, 129, 0.4);
box-shadow: 0 0 10px rgba(16, 185, 129, 0.25);
}
/* Volume Control */
.volume-container {
display: flex;
align-items: center;
gap: 12px;
background: rgba(0, 0, 0, 0.2);
padding: 10px 16px;
border-radius: 16px;
border: 1px solid var(--border-glass);
}
.btn-icon-small {
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition-smooth);
}
.btn-icon-small:hover {
color: white;
}
.btn-icon-small i {
width: 18px;
height: 18px;
}
.premium-slider {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 5px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.1);
outline: none;
cursor: pointer;
}
.premium-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--primary);
box-shadow: 0 0 8px var(--primary-glow);
transition: var(--transition-smooth);
}
.premium-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
background: var(--primary-hover);
}
.volume-value {
font-size: 0.8rem;
font-weight: 600;
min-width: 35px;
text-align: right;
color: var(--text-muted);
}
/* Search / Add Form */
.add-form {
display: flex;
gap: 12px;
}
.input-with-icon {
position: relative;
flex: 1;
}
.input-icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
width: 18px;
height: 18px;
}
.premium-input {
padding-left: 42px;
}
/* Spinner Loader */
.spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(255,255,255,0.3);
border-radius: 50%;
border-top-color: white;
animation: spin-loader 0.8s ease-in-out infinite;
display: inline-block;
}
@keyframes spin-loader {
to { transform: rotate(360deg); }
}
/* Queue List */
.queue-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.queue-header .card-title {
margin-bottom: 0;
}
.queue-count {
font-size: 0.8rem;
background: rgba(139, 92, 246, 0.15);
color: var(--primary-hover);
padding: 4px 10px;
border-radius: 12px;
font-weight: 700;
}
.queue-list-container {
max-height: 400px;
overflow-y: auto;
padding-right: 4px;
}
.queue-list-container::-webkit-scrollbar {
width: 6px;
}
.queue-list-container::-webkit-scrollbar-track {
background: transparent;
}
.queue-list-container::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
}
.queue-list-container::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
.queue-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 10px;
}
.queue-item {
display: flex;
align-items: center;
gap: 12px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-glass);
padding: 12px 16px;
border-radius: 16px;
transition: var(--transition-smooth);
user-select: none;
}
.queue-item:hover {
background: var(--bg-surface-hover);
border-color: rgba(255, 255, 255, 0.1);
transform: translateX(2px);
}
/* Dragging state */
.queue-item.dragging {
opacity: 0.4;
transform: scale(0.96);
background: rgba(139, 92, 246, 0.1);
border-color: var(--primary);
}
/* Drag over visual indicator */
.queue-item.drag-over {
border-color: var(--primary);
box-shadow: 0 0 10px var(--primary-glow);
background: rgba(139, 92, 246, 0.08);
}
.drag-handle {
cursor: grab;
color: var(--text-muted);
display: flex;
align-items: center;
transition: var(--transition-smooth);
}
.drag-handle:hover {
color: white;
}
.drag-handle i {
width: 18px;
height: 18px;
}
.queue-index {
font-size: 0.9rem;
font-weight: 700;
color: var(--text-muted);
min-width: 20px;
}
.queue-track-info {
flex: 1;
overflow: hidden;
}
.queue-track-title {
font-size: 0.95rem;
font-weight: 600;
color: white;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.queue-item-actions {
display: flex;
align-items: center;
gap: 4px;
}
.btn-action-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition-smooth);
}
.btn-action-icon:hover {
background: rgba(255,255,255,0.06);
color: white;
}
.btn-action-icon.delete:hover {
background: rgba(239, 68, 68, 0.15);
color: var(--danger);
}
.btn-action-icon i {
width: 16px;
height: 16px;
}
/* Empty Queue State */
.empty-queue-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
text-align: center;
color: var(--text-muted);
gap: 12px;
}
.empty-queue-message i {
width: 48px;
height: 48px;
color: rgba(255,255,255,0.1);
stroke-width: 1.5;
}
.empty-queue-message p {
font-size: 0.9rem;
font-weight: 500;
}
/* Toast Notification */
.toast-container {
position: fixed;
bottom: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: 1000;
}
.toast {
background: rgba(15, 18, 36, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--border-glass);
padding: 12px 20px;
border-radius: 12px;
color: white;
font-size: 0.9rem;
font-weight: 500;
box-shadow: 0 10px 25px rgba(0,0,0,0.5);
display: flex;
align-items: center;
gap: 10px;
transform: translateY(50px);
opacity: 0;
animation: slide-in 0.3s forwards cubic-bezier(0.4, 0, 0.2, 1);
transition: var(--transition-smooth);
}
@keyframes slide-in {
to { transform: translateY(0); opacity: 1; }
}
.toast.success {
border-left: 4px solid var(--success);
}
.toast.success i {
color: var(--success);
}
.toast.error {
border-left: 4px solid var(--danger);
}
.toast.error i {
color: var(--danger);
}
.toast.info {
border-left: 4px solid var(--secondary);
}
.toast.info i {
color: var(--secondary);
}