progress
This commit is contained in:
parent
a78732fb08
commit
afd5662afa
3 changed files with 563 additions and 1 deletions
|
|
@ -592,6 +592,81 @@ def start_download():
|
|||
print(f"Download error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/downloads/status')
|
||||
def get_download_status():
|
||||
"""
|
||||
Sophisticated download status fetching that matches GUI's update_download_status method.
|
||||
Fetches raw transfer data directly from slskd and flattens it like the GUI.
|
||||
"""
|
||||
if not soulseek_client:
|
||||
return jsonify({"transfers": []})
|
||||
|
||||
try:
|
||||
# Fetch raw transfers data exactly like GUI's StatusProcessingWorker
|
||||
transfers_data = asyncio.run(soulseek_client._make_request('GET', 'transfers/downloads'))
|
||||
|
||||
if not transfers_data:
|
||||
return jsonify({"transfers": []})
|
||||
|
||||
# Flatten the nested data structure exactly like the GUI
|
||||
all_transfers = []
|
||||
for user_data in transfers_data:
|
||||
if 'directories' in user_data:
|
||||
for directory in user_data['directories']:
|
||||
if 'files' in directory:
|
||||
for file_info in directory['files']:
|
||||
# Add username to each file object for convenience
|
||||
file_info['username'] = user_data.get('username', 'Unknown')
|
||||
all_transfers.append(file_info)
|
||||
|
||||
return jsonify({"transfers": all_transfers})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error fetching download status: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/downloads/cancel', methods=['POST'])
|
||||
def cancel_download():
|
||||
"""
|
||||
Cancel a specific download transfer, matching GUI functionality.
|
||||
"""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"success": False, "error": "No data provided."}), 400
|
||||
|
||||
download_id = data.get('download_id')
|
||||
username = data.get('username')
|
||||
|
||||
if not all([download_id, username]):
|
||||
return jsonify({"success": False, "error": "Missing download_id or username."}), 400
|
||||
|
||||
try:
|
||||
# Call the same client method the GUI uses
|
||||
success = asyncio.run(soulseek_client.cancel_download(download_id, username, remove=True))
|
||||
if success:
|
||||
return jsonify({"success": True, "message": "Download cancelled."})
|
||||
else:
|
||||
return jsonify({"success": False, "error": "Failed to cancel download via slskd."}), 500
|
||||
except Exception as e:
|
||||
print(f"Error cancelling download: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@app.route('/api/downloads/clear-finished', methods=['POST'])
|
||||
def clear_finished_downloads():
|
||||
"""
|
||||
Clear all terminal (completed, cancelled, failed) downloads from slskd.
|
||||
"""
|
||||
try:
|
||||
# This single client call handles clearing everything that is no longer active
|
||||
success = asyncio.run(soulseek_client.clear_all_completed_downloads())
|
||||
if success:
|
||||
return jsonify({"success": True, "message": "Finished downloads cleared."})
|
||||
else:
|
||||
return jsonify({"success": False, "error": "Backend failed to clear downloads."}), 500
|
||||
except Exception as e:
|
||||
print(f"Error clearing finished downloads: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@app.route('/api/artists')
|
||||
def get_artists():
|
||||
# Placeholder: returns mock artist data
|
||||
|
|
|
|||
|
|
@ -96,18 +96,26 @@ async function loadPageData(pageId) {
|
|||
try {
|
||||
switch (pageId) {
|
||||
case 'dashboard':
|
||||
// Stop download polling when leaving downloads page
|
||||
stopDownloadPolling();
|
||||
await loadDashboardData();
|
||||
break;
|
||||
case 'sync':
|
||||
// Stop download polling when leaving downloads page
|
||||
stopDownloadPolling();
|
||||
await loadSyncData();
|
||||
break;
|
||||
case 'downloads':
|
||||
await loadDownloadsData();
|
||||
break;
|
||||
case 'artists':
|
||||
// Stop download polling when leaving downloads page
|
||||
stopDownloadPolling();
|
||||
await loadArtistsData();
|
||||
break;
|
||||
case 'settings':
|
||||
// Stop download polling when leaving downloads page
|
||||
stopDownloadPolling();
|
||||
await loadSettingsData();
|
||||
break;
|
||||
}
|
||||
|
|
@ -921,6 +929,12 @@ async function loadSyncData() {
|
|||
}
|
||||
}
|
||||
|
||||
// Download tracking state management - matching GUI functionality
|
||||
let activeDownloads = {};
|
||||
let finishedDownloads = {};
|
||||
let downloadStatusInterval = null;
|
||||
let isDownloadPollingActive = false;
|
||||
|
||||
async function loadDownloadsData() {
|
||||
// Downloads page loads search results dynamically
|
||||
console.log('Downloads page loaded');
|
||||
|
|
@ -928,6 +942,7 @@ async function loadDownloadsData() {
|
|||
// Connect downloads search button
|
||||
const searchButton = document.getElementById('downloads-search-btn');
|
||||
const searchInput = document.getElementById('downloads-search-input');
|
||||
const clearButton = document.querySelector('.controls-panel__clear-btn');
|
||||
|
||||
if (searchButton && searchInput) {
|
||||
searchButton.addEventListener('click', performDownloadsSearch);
|
||||
|
|
@ -935,6 +950,264 @@ async function loadDownloadsData() {
|
|||
if (e.key === 'Enter') performDownloadsSearch();
|
||||
});
|
||||
}
|
||||
|
||||
if (clearButton) {
|
||||
clearButton.addEventListener('click', clearFinishedDownloads);
|
||||
}
|
||||
|
||||
// Start sophisticated polling system (1-second interval like GUI)
|
||||
startDownloadPolling();
|
||||
|
||||
// Initialize tab management
|
||||
initializeDownloadTabs();
|
||||
}
|
||||
|
||||
function startDownloadPolling() {
|
||||
if (isDownloadPollingActive) return;
|
||||
|
||||
console.log('Starting download status polling (1-second interval)');
|
||||
isDownloadPollingActive = true;
|
||||
|
||||
// Initial call
|
||||
updateDownloadQueues();
|
||||
|
||||
// Start 1-second polling (matching GUI's 1000ms timer)
|
||||
downloadStatusInterval = setInterval(updateDownloadQueues, 1000);
|
||||
}
|
||||
|
||||
function stopDownloadPolling() {
|
||||
if (downloadStatusInterval) {
|
||||
clearInterval(downloadStatusInterval);
|
||||
downloadStatusInterval = null;
|
||||
}
|
||||
isDownloadPollingActive = false;
|
||||
console.log('Stopped download status polling');
|
||||
}
|
||||
|
||||
async function updateDownloadQueues() {
|
||||
try {
|
||||
const response = await fetch('/api/downloads/status');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
console.error("Error fetching download status:", data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const newActive = {};
|
||||
const newFinished = {};
|
||||
|
||||
// Terminal states matching GUI logic
|
||||
const terminalStates = ['Completed', 'Succeeded', 'Cancelled', 'Canceled', 'Failed', 'Errored'];
|
||||
|
||||
// Process transfers exactly like GUI
|
||||
data.transfers.forEach(item => {
|
||||
const isTerminal = terminalStates.some(state =>
|
||||
item.state && item.state.includes(state)
|
||||
);
|
||||
|
||||
if (isTerminal) {
|
||||
newFinished[item.id] = item;
|
||||
} else {
|
||||
newActive[item.id] = item;
|
||||
}
|
||||
});
|
||||
|
||||
// Update global state
|
||||
activeDownloads = newActive;
|
||||
finishedDownloads = newFinished;
|
||||
|
||||
// Render both queues
|
||||
renderQueue('active-queue', activeDownloads, true);
|
||||
renderQueue('finished-queue', finishedDownloads, false);
|
||||
|
||||
// Update tab counts
|
||||
updateTabCounts();
|
||||
|
||||
// Update stats in the side panel
|
||||
updateDownloadStats();
|
||||
|
||||
} catch (error) {
|
||||
// Only log errors occasionally to avoid console spam
|
||||
if (Math.random() < 0.1) {
|
||||
console.error("Failed to update download queues:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderQueue(containerId, downloads, isActiveQueue) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
const downloadIds = Object.keys(downloads);
|
||||
|
||||
if (downloadIds.length === 0) {
|
||||
container.innerHTML = `<div class="download-queue__empty-message">${isActiveQueue ? 'No active downloads.' : 'No finished downloads.'}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const id of downloadIds) {
|
||||
const item = downloads[id];
|
||||
const title = item.filename ? item.filename.split(/[\\/]/).pop() : 'Unknown File';
|
||||
const progress = item.percentComplete || 0;
|
||||
const bytesTransferred = item.bytesTransferred || 0;
|
||||
const totalBytes = item.size || 0;
|
||||
const speed = item.averageSpeed || 0;
|
||||
|
||||
// Format file size
|
||||
const formatSize = (bytes) => {
|
||||
if (!bytes) return 'Unknown size';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
// Format speed
|
||||
const formatSpeed = (bytesPerSecond) => {
|
||||
if (!bytesPerSecond || bytesPerSecond <= 0) return '';
|
||||
return `${formatSize(bytesPerSecond)}/s`;
|
||||
};
|
||||
|
||||
let actionButtonHTML = '';
|
||||
if (isActiveQueue) {
|
||||
// Active items get progress bar and cancel button
|
||||
actionButtonHTML = `
|
||||
<div class="download-item__progress-container">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: ${progress}%;"></div>
|
||||
</div>
|
||||
<div class="progress-text">
|
||||
${item.state} - ${progress.toFixed(1)}%
|
||||
${speed > 0 ? `• ${formatSpeed(speed)}` : ''}
|
||||
${totalBytes > 0 ? `• ${formatSize(bytesTransferred)} / ${formatSize(totalBytes)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button class="download-item__cancel-btn" onclick="cancelDownloadItem('${item.id}', '${item.username}')">✕ Cancel</button>
|
||||
`;
|
||||
} else {
|
||||
// Finished items get status and open button
|
||||
let statusClass = '';
|
||||
if (item.state.includes('Cancelled')) statusClass = 'status--cancelled';
|
||||
else if (item.state.includes('Failed') || item.state.includes('Errored')) statusClass = 'status--failed';
|
||||
else if (item.state.includes('Completed') || item.state.includes('Succeeded')) statusClass = 'status--completed';
|
||||
|
||||
actionButtonHTML = `
|
||||
<div class="download-item__status-container">
|
||||
<span class="download-item__status-text ${statusClass}">${item.state}</span>
|
||||
</div>
|
||||
<button class="download-item__open-btn" title="Cannot open folder from web browser" disabled>📁 Open</button>
|
||||
`;
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="download-item" data-id="${item.id}">
|
||||
<div class="download-item__header">
|
||||
<div class="download-item__title" title="${title}">${title}</div>
|
||||
<div class="download-item__uploader" title="from ${item.username}">from ${item.username}</div>
|
||||
</div>
|
||||
<div class="download-item__content">
|
||||
${actionButtonHTML}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function updateTabCounts() {
|
||||
const activeCount = Object.keys(activeDownloads).length;
|
||||
const finishedCount = Object.keys(finishedDownloads).length;
|
||||
|
||||
const activeTabBtn = document.querySelector('.tab-btn[data-tab="active-queue"]');
|
||||
const finishedTabBtn = document.querySelector('.tab-btn[data-tab="finished-queue"]');
|
||||
|
||||
if (activeTabBtn) activeTabBtn.textContent = `Download Queue (${activeCount})`;
|
||||
if (finishedTabBtn) finishedTabBtn.textContent = `Finished (${finishedCount})`;
|
||||
}
|
||||
|
||||
function updateDownloadStats() {
|
||||
const activeCount = Object.keys(activeDownloads).length;
|
||||
const finishedCount = Object.keys(finishedDownloads).length;
|
||||
|
||||
const activeLabel = document.getElementById('active-downloads-label');
|
||||
const finishedLabel = document.getElementById('finished-downloads-label');
|
||||
|
||||
if (activeLabel) activeLabel.textContent = `• Active Downloads: ${activeCount}`;
|
||||
if (finishedLabel) finishedLabel.textContent = `• Finished Downloads: ${finishedCount}`;
|
||||
}
|
||||
|
||||
function initializeDownloadTabs() {
|
||||
const tabButtons = document.querySelectorAll('.tab-btn');
|
||||
tabButtons.forEach(btn => {
|
||||
btn.addEventListener('click', () => switchDownloadTab(btn));
|
||||
});
|
||||
}
|
||||
|
||||
function switchDownloadTab(button) {
|
||||
const targetTabId = button.getAttribute('data-tab');
|
||||
|
||||
// Update buttons
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
||||
button.classList.add('active');
|
||||
|
||||
// Update content panes
|
||||
document.querySelectorAll('.download-queue').forEach(queue => queue.classList.remove('active'));
|
||||
const targetQueue = document.getElementById(targetTabId);
|
||||
if (targetQueue) targetQueue.classList.add('active');
|
||||
}
|
||||
|
||||
async function cancelDownloadItem(downloadId, username) {
|
||||
if (!confirm('Are you sure you want to cancel this download?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/downloads/cancel', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ download_id: downloadId, username: username })
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast('Download cancelled', 'success');
|
||||
} else {
|
||||
showToast(`Failed to cancel: ${result.error}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cancelling download:', error);
|
||||
showToast('Error sending cancel request', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function clearFinishedDownloads() {
|
||||
const finishedCount = Object.keys(finishedDownloads).length;
|
||||
if (finishedCount === 0) {
|
||||
showToast('No finished downloads to clear', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to clear all ${finishedCount} finished downloads?`)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/downloads/clear-finished', {
|
||||
method: 'POST'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast('Finished downloads cleared', 'success');
|
||||
} else {
|
||||
showToast(`Failed to clear: ${result.error}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error clearing finished downloads:', error);
|
||||
showToast('Error sending clear request', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function performDownloadsSearch() {
|
||||
|
|
@ -1519,4 +1792,7 @@ window.startDownload = startDownload;
|
|||
window.downloadTrack = downloadTrack;
|
||||
window.downloadAlbum = downloadAlbum;
|
||||
window.toggleAlbumExpansion = toggleAlbumExpansion;
|
||||
window.downloadAlbumTrack = downloadAlbumTrack;
|
||||
window.downloadAlbumTrack = downloadAlbumTrack;
|
||||
window.switchDownloadTab = switchDownloadTab;
|
||||
window.cancelDownloadItem = cancelDownloadItem;
|
||||
window.clearFinishedDownloads = clearFinishedDownloads;
|
||||
|
|
@ -2287,4 +2287,215 @@ body {
|
|||
rgba(30, 215, 96, 0.9),
|
||||
rgba(25, 180, 80, 0.8));
|
||||
border-color: rgba(29, 185, 84, 0.6);
|
||||
}
|
||||
|
||||
/* Sophisticated Download Tracking Styles */
|
||||
|
||||
/* Download Queue Items */
|
||||
.download-item {
|
||||
background: linear-gradient(to bottom,
|
||||
rgba(45, 45, 50, 0.95),
|
||||
rgba(35, 35, 40, 0.98));
|
||||
border: 1px solid rgba(65, 65, 70, 0.5);
|
||||
border-radius: 12px;
|
||||
margin: 6px 0;
|
||||
padding: 12px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.download-item:hover {
|
||||
background: linear-gradient(to bottom,
|
||||
rgba(50, 50, 55, 0.98),
|
||||
rgba(40, 40, 45, 1.0));
|
||||
border-color: rgba(29, 185, 84, 0.6);
|
||||
}
|
||||
|
||||
.download-item__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.download-item__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
max-width: 200px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.download-item__uploader {
|
||||
font-size: 10px;
|
||||
color: rgba(29, 185, 84, 1.0);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.download-item__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Progress Bar Styles */
|
||||
.download-item__progress-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #1ed760, #1db954);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 10px;
|
||||
color: #b3b3b3;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Action Buttons */
|
||||
.download-item__cancel-btn {
|
||||
background: linear-gradient(to bottom,
|
||||
rgba(255, 69, 58, 0.9),
|
||||
rgba(255, 45, 85, 0.8));
|
||||
border: 1px solid rgba(255, 69, 58, 0.4);
|
||||
border-radius: 6px;
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.download-item__cancel-btn:hover {
|
||||
background: linear-gradient(to bottom,
|
||||
rgba(255, 69, 58, 1.0),
|
||||
rgba(255, 45, 85, 0.9));
|
||||
border-color: rgba(255, 69, 58, 0.6);
|
||||
}
|
||||
|
||||
.download-item__status-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.download-item__status-text {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.download-item__status-text.status--completed {
|
||||
background: rgba(29, 185, 84, 0.2);
|
||||
color: #1ed760;
|
||||
border: 1px solid rgba(29, 185, 84, 0.4);
|
||||
}
|
||||
|
||||
.download-item__status-text.status--cancelled {
|
||||
background: rgba(255, 159, 10, 0.2);
|
||||
color: #ff9f0a;
|
||||
border: 1px solid rgba(255, 159, 10, 0.4);
|
||||
}
|
||||
|
||||
.download-item__status-text.status--failed {
|
||||
background: rgba(255, 69, 58, 0.2);
|
||||
color: #ff453a;
|
||||
border: 1px solid rgba(255, 69, 58, 0.4);
|
||||
}
|
||||
|
||||
.download-item__open-btn {
|
||||
background: rgba(29, 185, 84, 0.1);
|
||||
color: rgba(29, 185, 84, 0.6);
|
||||
border: 1px solid rgba(29, 185, 84, 0.2);
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
padding: 6px 12px;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Tab Management */
|
||||
.download-manager__tabs .tab-btn {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.download-manager__tabs .tab-btn.active {
|
||||
background: linear-gradient(to bottom,
|
||||
rgba(29, 185, 84, 0.2),
|
||||
rgba(29, 185, 84, 0.1));
|
||||
color: #1ed760;
|
||||
border-color: rgba(29, 185, 84, 0.4);
|
||||
}
|
||||
|
||||
.download-manager__tabs .tab-btn:hover:not(.active) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Queue Content */
|
||||
.download-queue {
|
||||
display: none;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.download-queue.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.download-queue__empty-message {
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 12px;
|
||||
padding: 20px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Scrollbar Styling for Download Queue */
|
||||
.download-queue::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.download-queue::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.download-queue::-webkit-scrollbar-thumb {
|
||||
background: rgba(29, 185, 84, 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.download-queue::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(29, 185, 84, 0.5);
|
||||
}
|
||||
Loading…
Reference in a new issue