feat: fix download-to-user matching, add cover art to downloads
- Fix seriesMap key (use Sonarr internal id, not tvdbId) - Fix Sonarr tag resolution (use tag map like Radarr) - Use sourceTitle for history record matching - Fall back to embedded movie/series objects when API timeouts - Add includeMovie/includeSeries params to queue/history API calls - Add coverArt field to all download responses (TMDB poster URLs) - Add cover art display to frontend download cards - Fix user-summary route to use instance config and tag maps
This commit is contained in:
480
public/app.js
Normal file
480
public/app.js
Normal file
@@ -0,0 +1,480 @@
|
||||
let currentUser = null;
|
||||
let downloads = [];
|
||||
let refreshInterval = null;
|
||||
let currentRefreshRate = 5000; // default 5 seconds
|
||||
|
||||
// Check authentication on load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
checkAuthentication();
|
||||
|
||||
document.getElementById('login-form').addEventListener('submit', handleLogin);
|
||||
document.getElementById('logout-btn').addEventListener('click', handleLogout);
|
||||
document.getElementById('refresh-rate').addEventListener('change', handleRefreshRateChange);
|
||||
});
|
||||
|
||||
function startAutoRefresh() {
|
||||
if (refreshInterval) clearInterval(refreshInterval);
|
||||
if (currentRefreshRate > 0) {
|
||||
refreshInterval = setInterval(() => fetchUserDownloads(false), currentRefreshRate);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRefreshRateChange(e) {
|
||||
const rate = parseInt(e.target.value);
|
||||
currentRefreshRate = rate;
|
||||
startAutoRefresh();
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
refreshInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAuthentication() {
|
||||
try {
|
||||
const response = await fetch('/api/auth/me');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.authenticated) {
|
||||
currentUser = data.user;
|
||||
showDashboard();
|
||||
fetchUserDownloads(true);
|
||||
startAutoRefresh();
|
||||
} else {
|
||||
showLogin();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Authentication check failed:', err);
|
||||
showLogin();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogin(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
currentUser = data.user;
|
||||
showDashboard();
|
||||
fetchUserDownloads(true);
|
||||
startAutoRefresh();
|
||||
} else {
|
||||
showLoginError(data.error || 'Login failed');
|
||||
}
|
||||
} catch (err) {
|
||||
showLoginError('Login failed. Please try again.');
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
stopAutoRefresh();
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST'
|
||||
});
|
||||
currentUser = null;
|
||||
downloads = [];
|
||||
showLogin();
|
||||
} catch (err) {
|
||||
console.error('Logout failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
document.getElementById('login-container').style.display = 'flex';
|
||||
document.getElementById('dashboard-container').style.display = 'none';
|
||||
hideLoginError();
|
||||
}
|
||||
|
||||
function showDashboard() {
|
||||
document.getElementById('login-container').style.display = 'none';
|
||||
document.getElementById('dashboard-container').style.display = 'block';
|
||||
document.getElementById('currentUser').textContent = currentUser.name || '-';
|
||||
}
|
||||
|
||||
function showLoginError(message) {
|
||||
const errorDiv = document.getElementById('login-error');
|
||||
errorDiv.textContent = message;
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
|
||||
function hideLoginError() {
|
||||
const errorDiv = document.getElementById('login-error');
|
||||
errorDiv.style.display = 'none';
|
||||
}
|
||||
|
||||
async function fetchUserDownloads(isInitialLoad = false) {
|
||||
if (isInitialLoad) {
|
||||
showLoading();
|
||||
}
|
||||
hideError();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/dashboard/user-downloads');
|
||||
const data = await response.json();
|
||||
|
||||
currentUser = data.user;
|
||||
downloads = data.downloads;
|
||||
|
||||
// Debug: log first download to see what fields are present
|
||||
if (downloads.length > 0) {
|
||||
console.log('[Dashboard] Download data:', JSON.stringify(downloads[0]));
|
||||
}
|
||||
|
||||
document.getElementById('currentUser').textContent = currentUser || '-';
|
||||
renderDownloads();
|
||||
} catch (err) {
|
||||
showError('Failed to fetch downloads. Make sure all services are configured.');
|
||||
console.error(err);
|
||||
} finally {
|
||||
if (isInitialLoad) {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderDownloads() {
|
||||
const downloadsList = document.getElementById('downloads-list');
|
||||
const noDownloads = document.getElementById('no-downloads');
|
||||
|
||||
if (downloads.length === 0) {
|
||||
noDownloads.style.display = 'block';
|
||||
downloadsList.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
noDownloads.style.display = 'none';
|
||||
|
||||
// Get existing cards
|
||||
const existingCards = new Map();
|
||||
downloadsList.querySelectorAll('.download-card').forEach(card => {
|
||||
existingCards.set(card.dataset.id, card);
|
||||
});
|
||||
|
||||
// Track which downloads we've processed
|
||||
const processedIds = new Set();
|
||||
|
||||
downloads.forEach(download => {
|
||||
const id = download.title;
|
||||
processedIds.add(id);
|
||||
|
||||
const existingCard = existingCards.get(id);
|
||||
if (existingCard) {
|
||||
// Update existing card
|
||||
updateDownloadCard(existingCard, download);
|
||||
} else {
|
||||
// Create new card
|
||||
const card = createDownloadCard(download);
|
||||
downloadsList.appendChild(card);
|
||||
}
|
||||
});
|
||||
|
||||
// Remove cards for downloads that no longer exist
|
||||
existingCards.forEach((card, id) => {
|
||||
if (!processedIds.has(id)) {
|
||||
card.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateDownloadCard(card, download) {
|
||||
// Update status
|
||||
const statusEl = card.querySelector('.download-status');
|
||||
if (statusEl && statusEl.textContent !== download.status) {
|
||||
statusEl.textContent = download.status;
|
||||
statusEl.className = `download-status ${download.status}`;
|
||||
}
|
||||
|
||||
// Update progress bar and missing pieces
|
||||
const progressContainer = card.querySelector('.progress-container');
|
||||
if (progressContainer && download.progress !== undefined) {
|
||||
const progressBar = progressContainer.querySelector('.progress-bar');
|
||||
const progressText = progressContainer.querySelector('.progress-text');
|
||||
const missingText = progressContainer.querySelector('.missing-text');
|
||||
|
||||
if (progressBar) {
|
||||
const downloaded = progressBar.querySelector('.downloaded');
|
||||
if (downloaded) {
|
||||
downloaded.style.width = download.progress + '%';
|
||||
}
|
||||
}
|
||||
|
||||
if (progressText) {
|
||||
progressText.textContent = download.progress + '%';
|
||||
}
|
||||
|
||||
if (missingText) {
|
||||
const totalMb = parseFloat(download.mb) || parseFloat(download.size);
|
||||
const missingMb = parseFloat(download.mbmissing) || 0;
|
||||
if (missingMb > 0 && totalMb > 0) {
|
||||
missingText.textContent = `(missing ${missingMb.toFixed(1)} of ${totalMb.toFixed(1)} MB)`;
|
||||
} else {
|
||||
missingText.textContent = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update speed
|
||||
const speedEl = card.querySelector('.detail-item[data-label="Speed"] .detail-value');
|
||||
if (speedEl && download.speed !== undefined) {
|
||||
speedEl.textContent = download.speed;
|
||||
}
|
||||
|
||||
// Update ETA
|
||||
const etaEl = card.querySelector('.detail-item[data-label="ETA"] .detail-value');
|
||||
if (etaEl && download.eta !== undefined) {
|
||||
etaEl.textContent = download.eta;
|
||||
}
|
||||
|
||||
// Update qBittorrent-specific fields
|
||||
if (download.qbittorrent) {
|
||||
const seedsEl = card.querySelector('.detail-item[data-label="Seeds"] .detail-value');
|
||||
if (seedsEl && download.seeds !== undefined) {
|
||||
seedsEl.textContent = download.seeds;
|
||||
}
|
||||
|
||||
const peersEl = card.querySelector('.detail-item[data-label="Peers"] .detail-value');
|
||||
if (peersEl && download.peers !== undefined) {
|
||||
peersEl.textContent = download.peers;
|
||||
}
|
||||
|
||||
const availabilityEl = card.querySelector('.detail-item[data-label="Availability"] .detail-value');
|
||||
if (availabilityEl && download.availability !== undefined) {
|
||||
availabilityEl.textContent = `${download.availability}%`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createDownloadCard(download) {
|
||||
const card = document.createElement('div');
|
||||
card.className = `download-card ${download.type}`;
|
||||
card.dataset.id = download.title;
|
||||
|
||||
// Cover art
|
||||
if (download.coverArt) {
|
||||
const coverDiv = document.createElement('div');
|
||||
coverDiv.className = 'download-cover';
|
||||
const coverImg = document.createElement('img');
|
||||
coverImg.src = download.coverArt;
|
||||
coverImg.alt = download.movieName || download.seriesName || download.title;
|
||||
coverImg.loading = 'lazy';
|
||||
coverDiv.appendChild(coverImg);
|
||||
card.appendChild(coverDiv);
|
||||
}
|
||||
|
||||
// Info wrapper
|
||||
const infoDiv = document.createElement('div');
|
||||
infoDiv.className = 'download-info';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'download-header';
|
||||
|
||||
const type = document.createElement('span');
|
||||
type.className = `download-type ${download.type}`;
|
||||
if (download.type === 'series') {
|
||||
type.textContent = '📺 Series';
|
||||
} else if (download.type === 'movie') {
|
||||
type.textContent = '🎬 Movie';
|
||||
} else if (download.type === 'torrent') {
|
||||
const instName = download.instanceName ? ` (${download.instanceName})` : '';
|
||||
type.textContent = `📥 Torrent${instName}`;
|
||||
} else {
|
||||
type.textContent = download.type;
|
||||
}
|
||||
|
||||
const status = document.createElement('span');
|
||||
status.className = `download-status ${download.status}`;
|
||||
status.textContent = download.status;
|
||||
|
||||
header.appendChild(type);
|
||||
header.appendChild(status);
|
||||
|
||||
const title = document.createElement('h3');
|
||||
title.className = 'download-title';
|
||||
title.textContent = download.title;
|
||||
|
||||
infoDiv.appendChild(header);
|
||||
infoDiv.appendChild(title);
|
||||
|
||||
if (download.seriesName) {
|
||||
const series = document.createElement('p');
|
||||
series.className = 'download-series';
|
||||
series.textContent = `Series: ${download.seriesName}`;
|
||||
infoDiv.appendChild(series);
|
||||
}
|
||||
|
||||
if (download.movieName) {
|
||||
const movie = document.createElement('p');
|
||||
movie.className = 'download-movie';
|
||||
movie.textContent = `Movie: ${download.movieName}`;
|
||||
infoDiv.appendChild(movie);
|
||||
}
|
||||
|
||||
const details = document.createElement('div');
|
||||
details.className = 'download-details';
|
||||
|
||||
const size = createDetailItem('Size', formatSize(download.size));
|
||||
details.appendChild(size);
|
||||
|
||||
if (download.progress !== undefined) {
|
||||
const progressItem = document.createElement('div');
|
||||
progressItem.className = 'detail-item progress-item';
|
||||
progressItem.dataset.label = 'Progress';
|
||||
|
||||
const labelSpan = document.createElement('span');
|
||||
labelSpan.className = 'detail-label';
|
||||
labelSpan.textContent = 'Progress';
|
||||
|
||||
const valueDiv = document.createElement('div');
|
||||
valueDiv.className = 'progress-container';
|
||||
|
||||
// Progress bar with segments
|
||||
const totalMb = parseFloat(download.mb) || parseFloat(download.size);
|
||||
const missingMb = parseFloat(download.mbmissing) || 0;
|
||||
const downloadedMb = totalMb - missingMb;
|
||||
const progressPercent = parseFloat(download.progress) || 0;
|
||||
const missingPercent = totalMb > 0 ? (missingMb / totalMb) * 100 : 0;
|
||||
|
||||
const progressBar = document.createElement('div');
|
||||
progressBar.className = 'progress-bar';
|
||||
|
||||
// Downloaded portion (green)
|
||||
if (progressPercent > 0) {
|
||||
const downloaded = document.createElement('div');
|
||||
downloaded.className = 'progress-segment downloaded';
|
||||
downloaded.style.width = progressPercent + '%';
|
||||
progressBar.appendChild(downloaded);
|
||||
}
|
||||
|
||||
valueDiv.appendChild(progressBar);
|
||||
|
||||
// Text showing percentage
|
||||
const progressText = document.createElement('span');
|
||||
progressText.className = 'progress-text';
|
||||
progressText.textContent = download.progress + '%';
|
||||
valueDiv.appendChild(progressText);
|
||||
|
||||
// Missing pieces text
|
||||
if (missingMb > 0 && totalMb > 0) {
|
||||
const missingText = document.createElement('span');
|
||||
missingText.className = 'missing-text';
|
||||
missingText.textContent = `(missing ${missingMb.toFixed(1)} of ${totalMb.toFixed(1)} MB)`;
|
||||
valueDiv.appendChild(missingText);
|
||||
}
|
||||
|
||||
progressItem.appendChild(labelSpan);
|
||||
progressItem.appendChild(valueDiv);
|
||||
details.appendChild(progressItem);
|
||||
}
|
||||
|
||||
if (download.speed) {
|
||||
const speed = createDetailItem('Speed', download.speed);
|
||||
details.appendChild(speed);
|
||||
}
|
||||
|
||||
if (download.eta) {
|
||||
const eta = createDetailItem('ETA', download.eta);
|
||||
details.appendChild(eta);
|
||||
}
|
||||
|
||||
// qBittorrent-specific fields
|
||||
if (download.qbittorrent) {
|
||||
if (download.seeds !== undefined) {
|
||||
const seeds = createDetailItem('Seeds', download.seeds);
|
||||
details.appendChild(seeds);
|
||||
}
|
||||
|
||||
if (download.peers !== undefined) {
|
||||
const peers = createDetailItem('Peers', download.peers);
|
||||
details.appendChild(peers);
|
||||
}
|
||||
|
||||
if (download.availability !== undefined) {
|
||||
const availability = createDetailItem('Availability', `${download.availability}%`);
|
||||
details.appendChild(availability);
|
||||
}
|
||||
}
|
||||
|
||||
if (download.completedAt) {
|
||||
const completed = createDetailItem('Completed', formatDate(download.completedAt));
|
||||
details.appendChild(completed);
|
||||
}
|
||||
|
||||
infoDiv.appendChild(details);
|
||||
card.appendChild(infoDiv);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function createDetailItem(label, value) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'detail-item';
|
||||
item.dataset.label = label;
|
||||
|
||||
const labelSpan = document.createElement('span');
|
||||
labelSpan.className = 'detail-label';
|
||||
labelSpan.textContent = label;
|
||||
|
||||
const valueSpan = document.createElement('span');
|
||||
valueSpan.className = 'detail-value';
|
||||
valueSpan.textContent = value;
|
||||
|
||||
item.appendChild(labelSpan);
|
||||
item.appendChild(valueSpan);
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
function formatSize(size) {
|
||||
if (!size) return 'N/A';
|
||||
// If already a formatted string (e.g., "21.5 GB"), return as-is
|
||||
if (typeof size === 'string') {
|
||||
return size;
|
||||
}
|
||||
// If it's a number (bytes), format it
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(size) / Math.log(1024));
|
||||
return Math.round(size / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return 'N/A';
|
||||
return new Date(dateString).toLocaleString();
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
const errorDiv = document.getElementById('error-message');
|
||||
errorDiv.textContent = message;
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
|
||||
function hideError() {
|
||||
const errorDiv = document.getElementById('error-message');
|
||||
errorDiv.style.display = 'none';
|
||||
}
|
||||
|
||||
function showLoading() {
|
||||
const loading = document.getElementById('loading');
|
||||
loading.style.display = 'block';
|
||||
}
|
||||
|
||||
function hideLoading() {
|
||||
const loading = document.getElementById('loading');
|
||||
loading.style.display = 'none';
|
||||
}
|
||||
73
public/index.html
Normal file
73
public/index.html
Normal file
@@ -0,0 +1,73 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>sofarr - Your Downloads Dashboard</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<!-- Login Form -->
|
||||
<div id="login-container" class="login-container" style="display: none;">
|
||||
<div class="login-box">
|
||||
<h2>Login to Emby</h2>
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Username:</label>
|
||||
<input type="text" id="username" name="username" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password:</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit" class="login-btn">Login</button>
|
||||
</form>
|
||||
<div id="login-error" class="error-message" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<div id="dashboard-container" class="dashboard-container" style="display: none;">
|
||||
<header class="app-header">
|
||||
<h1>sofarr</h1>
|
||||
<div class="header-controls">
|
||||
<div class="refresh-control">
|
||||
<label for="refresh-rate">Refresh:</label>
|
||||
<select id="refresh-rate">
|
||||
<option value="1000">1s</option>
|
||||
<option value="5000" selected>5s</option>
|
||||
<option value="10000">10s</option>
|
||||
<option value="0">Off</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<span class="user-label">Current User:</span>
|
||||
<span class="user-name" id="currentUser">-</span>
|
||||
<button id="logout-btn" class="logout-btn">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="error-message" class="error-message" style="display: none;"></div>
|
||||
|
||||
<div id="loading" class="loading" style="display: none;">Loading downloads...</div>
|
||||
|
||||
<div class="downloads-container">
|
||||
<h2>Your Downloads</h2>
|
||||
<div id="no-downloads" class="no-downloads" style="display: none;">
|
||||
<p>No downloads found for your user.</p>
|
||||
<p>Make sure your shows and movies are tagged with your username in Sonarr/Radarr.</p>
|
||||
</div>
|
||||
<div id="downloads-list" class="downloads-list"></div>
|
||||
</div>
|
||||
|
||||
<footer class="app-footer">
|
||||
<p>Ensure your media is tagged with your username in Sonarr/Radarr to match downloads to users.</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
412
public/style.css
Normal file
412
public/style.css
Normal file
@@ -0,0 +1,412 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
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: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
color: #333;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: #f0f0f0;
|
||||
padding: 10px 20px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
padding: 5px 15px;
|
||||
background: #f44336;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background: #d32f2f;
|
||||
}
|
||||
|
||||
.user-label {
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
color: #667eea;
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #fee;
|
||||
color: #c33;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
border-left: 4px solid #c33;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: white;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.downloads-container {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.downloads-container h2 {
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.no-downloads {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.no-downloads p {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.downloads-list {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.download-card {
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.download-cover {
|
||||
flex-shrink: 0;
|
||||
width: 80px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.download-cover img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.download-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.download-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.download-card.series {
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
|
||||
.download-card.movie {
|
||||
border-left: 4px solid #f093fb;
|
||||
}
|
||||
|
||||
.download-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.download-type {
|
||||
padding: 5px 15px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.download-type.series {
|
||||
background: #e8eaf6;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.download-type.movie {
|
||||
background: #fce4ec;
|
||||
color: #f093fb;
|
||||
}
|
||||
|
||||
.download-type.torrent {
|
||||
background: #e0f2f1;
|
||||
color: #26a69a;
|
||||
}
|
||||
|
||||
.download-status {
|
||||
padding: 5px 15px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.download-status.downloading {
|
||||
background: #e8f5e9;
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.download-status.completed {
|
||||
background: #e3f2fd;
|
||||
color: #2196f3;
|
||||
}
|
||||
|
||||
.download-status.failed {
|
||||
background: #ffebee;
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.download-title {
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.download-series,
|
||||
.download-movie {
|
||||
color: #666;
|
||||
margin-bottom: 15px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.download-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 15px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: #999;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.app-footer p {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-box h2 {
|
||||
color: #333;
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 5px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
background: #5568d3;
|
||||
}
|
||||
|
||||
/* Progress bar with missing pieces */
|
||||
.progress-item {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
background: #ffebee;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
border: 1px solid #ffcdd2;
|
||||
}
|
||||
|
||||
.progress-segment {
|
||||
height: 100%;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-segment.downloaded {
|
||||
background: linear-gradient(90deg, #4caf50 0%, #66bb6a 100%);
|
||||
float: left;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.missing-text {
|
||||
color: #f44336;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Refresh control styles */
|
||||
.header-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.refresh-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.refresh-control label {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.refresh-control select {
|
||||
padding: 5px 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.download-details {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user