1733 lines
85 KiB
JavaScript
1733 lines
85 KiB
JavaScript
document.addEventListener('DOMContentLoaded', () => {
|
|
|
|
// --- INIT ICONS ---
|
|
if (typeof lucide !== 'undefined') {
|
|
lucide.createIcons();
|
|
}
|
|
|
|
// --- UTILS ---
|
|
const dom = (id) => document.getElementById(id);
|
|
const show = (el) => el && el.classList.remove('hidden');
|
|
const hide = (el) => el && el.classList.add('hidden');
|
|
|
|
const showToast = (msg) => {
|
|
const t = dom('toast');
|
|
if (!t) return;
|
|
t.textContent = msg;
|
|
show(t);
|
|
setTimeout(() => hide(t), 3000);
|
|
};
|
|
window.showToast = showToast;
|
|
|
|
const showConfirmModal = (title, message, options = {}) => {
|
|
return new Promise((resolve) => {
|
|
const confirmText = options.confirmText || 'Confirmer';
|
|
const cancelText = options.cancelText || 'Annuler';
|
|
const isDestructive = options.isDestructive !== false;
|
|
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'modal-overlay';
|
|
overlay.style.zIndex = '30000';
|
|
overlay.innerHTML = `
|
|
<div class="modal-content" style="max-width: 400px; text-align: center; animation: modalFadeIn 0.2s ease-out;">
|
|
<div style="margin-bottom: 1rem; color: ${isDestructive ? 'var(--primary)' : '#60a5fa'};">
|
|
<i data-lucide="${isDestructive ? 'alert-triangle' : 'help-circle'}" style="width: 48px; height: 48px; margin: 0 auto;"></i>
|
|
</div>
|
|
<h3 style="margin-bottom: 0.75rem; font-size: 1.25rem; font-weight: 700; color: white;">${title}</h3>
|
|
<p style="font-size: 0.9rem; color: var(--text-sec); margin-bottom: 1.5rem; line-height: 1.4;">${message}</p>
|
|
|
|
<div style="display: flex; flex-direction: column; gap: 8px;">
|
|
<button id="confirm-yes-btn" class="${isDestructive ? 'btn-primary' : 'btn-success'}" style="width: 100%; display: flex; justify-content: center; align-items: center; gap: 8px; padding: 12px;">
|
|
${confirmText}
|
|
</button>
|
|
<button id="confirm-no-btn" class="btn-action" style="width: 100%; background: transparent; border: 1px solid var(--border); color: var(--text-sec); justify-content: center; display: flex; align-items: center; padding: 12px;">
|
|
${cancelText}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(overlay);
|
|
if (window.lucide) {
|
|
window.lucide.createIcons({ root: overlay });
|
|
}
|
|
|
|
const cleanUp = (value) => {
|
|
overlay.style.opacity = '0';
|
|
overlay.style.transition = 'opacity 0.2s ease';
|
|
setTimeout(() => {
|
|
overlay.remove();
|
|
}, 200);
|
|
resolve(value);
|
|
};
|
|
|
|
overlay.querySelector('#confirm-yes-btn').onclick = () => cleanUp(true);
|
|
overlay.querySelector('#confirm-no-btn').onclick = () => cleanUp(false);
|
|
|
|
overlay.onclick = (e) => {
|
|
if (e.target === overlay) cleanUp(false);
|
|
};
|
|
});
|
|
};
|
|
|
|
const updateSiteStatusUI = (isOffline, message) => {
|
|
let banner = dom('offline-banner');
|
|
if (!banner) {
|
|
banner = document.createElement('div');
|
|
banner.id = 'offline-banner';
|
|
banner.style = "background: #e50914; color: white; text-align: center; padding: 10px; font-weight: 700; position: sticky; top: 0; z-index: 9999; display: none; font-size: 0.9rem;";
|
|
document.body.prepend(banner);
|
|
}
|
|
if (isOffline) {
|
|
banner.textContent = `⚠️ SITE SOURCE INDISPONIBLE : ${message || 'Vérification en cours...'}`;
|
|
banner.style.display = 'block';
|
|
} else {
|
|
banner.style.display = 'none';
|
|
}
|
|
};
|
|
|
|
const apiCall = async (endpoint, method = 'GET', body = null) => {
|
|
if (!endpoint.startsWith('/api')) {
|
|
endpoint = endpoint.startsWith('/') ? `/api${endpoint}` : `/api/${endpoint}`;
|
|
}
|
|
try {
|
|
const opts = {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
};
|
|
if (body) opts.body = JSON.stringify(body);
|
|
|
|
const res = await fetch(endpoint, opts);
|
|
const text = await res.text();
|
|
|
|
if (!res.ok) {
|
|
let err = `Erreur ${res.status}`;
|
|
try { err = JSON.parse(text).error || err; } catch (e) { }
|
|
throw new Error(err);
|
|
}
|
|
return text ? JSON.parse(text) : {};
|
|
} catch (e) {
|
|
console.error(`API ${endpoint}:`, e);
|
|
throw e;
|
|
}
|
|
};
|
|
|
|
// --- STATE & LOOPS MANAGER ---
|
|
const state = {
|
|
downloadInterval: null,
|
|
trendingData: { films: [], series: [], recent: [] },
|
|
activeSources: [],
|
|
availableSources: [], // Dynamically populated from /status
|
|
sourceLabels: {},
|
|
};
|
|
|
|
// --- GESTION INTELLIGENTE DES BOUCLES ---
|
|
|
|
const getPref = (key, defaultValue) => {
|
|
if (state.currentUser && state.currentUser.preferences && state.currentUser.preferences[key] !== undefined) {
|
|
return state.currentUser.preferences[key];
|
|
}
|
|
const local = localStorage.getItem(key);
|
|
if (local !== null) return local === 'true' ? true : (local === 'false' ? false : local);
|
|
return defaultValue;
|
|
};
|
|
|
|
const setPref = async (key, value) => {
|
|
localStorage.setItem(key, value);
|
|
if (state.currentUser) {
|
|
if (!state.currentUser.preferences) state.currentUser.preferences = {};
|
|
state.currentUser.preferences[key] = value;
|
|
}
|
|
try {
|
|
await apiCall('/api/preferences', 'POST', { key, value });
|
|
} catch (e) {
|
|
console.error('Erreur sauvegarde pref', e);
|
|
}
|
|
};
|
|
// 1. Gestion Téléchargements (Actif seulement sur l'onglet)
|
|
const startDownloadLoop = () => {
|
|
if (state.downloadInterval) clearInterval(state.downloadInterval);
|
|
loadDownloads(); // Appel immédiat
|
|
state.downloadInterval = setInterval(loadDownloads, 5000); // Mise à jour toutes les 5s
|
|
console.log("Flux Téléchargement : ACTIVÉ");
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// La boucle des téléchargements a été déplacée à la fin du fichier.
|
|
|
|
// --- LOGIN --- (handled in login.ejs inline script, not here)
|
|
|
|
const checkSession = async () => {
|
|
try {
|
|
const res = await apiCall('/check-session');
|
|
if (res.isLoggedIn) {
|
|
// Stocker les infos user pour usage dans l'app
|
|
state.currentUser = res.user || null;
|
|
if (dom('login-form')) {
|
|
const allowedPages = ['/trending', '/recent', '/search', '/downloads', '/manual', '/settings'];
|
|
const savedPage = localStorage.getItem('defaultPage') || '/trending';
|
|
window.location.href = allowedPages.includes(savedPage) ? savedPage : '/trending';
|
|
} else {
|
|
initApp();
|
|
}
|
|
} else {
|
|
if (!dom('login-form')) {
|
|
window.location.href = '/login';
|
|
}
|
|
}
|
|
} catch (e) { }
|
|
};
|
|
|
|
const initApp = async () => {
|
|
hide(dom('login-overlay'));
|
|
show(dom('app-container'));
|
|
|
|
const getCookie = (name) => {
|
|
const value = `; ${document.cookie}`;
|
|
const parts = value.split(`; ${name}=`);
|
|
if (parts.length === 2) return parts.pop().split(';').shift();
|
|
return null;
|
|
};
|
|
|
|
const showMustChangePasswordOverlay = () => {
|
|
const existing = document.getElementById('change-password-overlay');
|
|
if (existing) return;
|
|
|
|
const overlay = document.createElement('div');
|
|
overlay.id = 'change-password-overlay';
|
|
overlay.innerHTML = `
|
|
<div class="change-password-box">
|
|
<h2>Sécurité requise</h2>
|
|
<p>Votre compte utilise un mot de passe temporaire ou réinitialisé. Vous devez définir un nouveau mot de passe sécurisé pour continuer.</p>
|
|
|
|
<div class="password-requirements" style="text-align: left;">
|
|
<strong>Exigences du mot de passe :</strong>
|
|
<ul style="margin-top: 8px;">
|
|
<li id="req-length" class="invalid" style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem;"><span class="icon-holder"><i data-lucide="x" style="width:14px;height:14px;"></i></span> Au moins 8 caractères</li>
|
|
<li id="req-upper" class="invalid" style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem;"><span class="icon-holder"><i data-lucide="x" style="width:14px;height:14px;"></i></span> Au moins une majuscule (A-Z)</li>
|
|
<li id="req-number" class="invalid" style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem;"><span class="icon-holder"><i data-lucide="x" style="width:14px;height:14px;"></i></span> Au moins un chiffre (0-9)</li>
|
|
<li id="req-special" class="invalid" style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem;"><span class="icon-holder"><i data-lucide="x" style="width:14px;height:14px;"></i></span> Au moins un caractère spécial</li>
|
|
<li id="req-match" class="invalid" style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem;"><span class="icon-holder"><i data-lucide="x" style="width:14px;height:14px;"></i></span> Mots de passe identiques</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<form id="force-change-pw-form" style="margin-top: 1rem;">
|
|
<div style="margin-bottom: 1rem; text-align: left;">
|
|
<label for="force-new-pw" style="display: block; margin-bottom: 6px;">Nouveau mot de passe</label>
|
|
<div class="password-container">
|
|
<input type="password" id="force-new-pw" required autocomplete="new-password">
|
|
<button type="button" class="toggle-password" tabindex="-1">
|
|
<i data-lucide="eye" class="eye-icon"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-bottom: 1.25rem; text-align: left;">
|
|
<label for="force-confirm-pw" style="display: block; margin-bottom: 6px;">Confirmer le mot de passe</label>
|
|
<div class="password-container">
|
|
<input type="password" id="force-confirm-pw" required autocomplete="new-password">
|
|
<button type="button" class="toggle-password" tabindex="-1">
|
|
<i data-lucide="eye" class="eye-icon"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="force-pw-error" style="color: #ef4444; font-size: 0.85rem; display: none; font-weight: 600; margin-bottom: 1rem; text-align: left;"></div>
|
|
|
|
<button type="submit" id="btn-force-pw-submit" class="btn-primary" style="width: 100%; display: flex; justify-content: center; align-items: center; gap: 8px;" disabled>
|
|
Mettre à jour le mot de passe
|
|
</button>
|
|
|
|
<button type="button" id="btn-force-logout" class="btn-action" style="width: 100%; background: transparent; border: 1px solid var(--border); color: var(--text-sec); margin-top: 0.5rem; justify-content: center; display: flex; align-items: center; gap: 8px;">
|
|
Se déconnecter
|
|
</button>
|
|
</form>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(overlay);
|
|
if (window.lucide) {
|
|
window.lucide.createIcons({ root: overlay });
|
|
}
|
|
|
|
const newPwInput = overlay.querySelector('#force-new-pw');
|
|
const confirmPwInput = overlay.querySelector('#force-confirm-pw');
|
|
const form = overlay.querySelector('#force-change-pw-form');
|
|
const errorDiv = overlay.querySelector('#force-pw-error');
|
|
const submitBtn = overlay.querySelector('#btn-force-pw-submit');
|
|
const logoutBtn = overlay.querySelector('#btn-force-logout');
|
|
|
|
const validateInputs = () => {
|
|
if (!window.AuthHelpers) return;
|
|
const val = newPwInput.value;
|
|
const confirmVal = confirmPwInput.value;
|
|
|
|
const statuses = AuthHelpers.validateComplexity(val, confirmVal);
|
|
AuthHelpers.updateRequirementsUI(overlay, statuses);
|
|
|
|
submitBtn.disabled = !statuses.allValid;
|
|
};
|
|
|
|
newPwInput.addEventListener('input', validateInputs);
|
|
confirmPwInput.addEventListener('input', validateInputs);
|
|
|
|
if (window.AuthHelpers) {
|
|
AuthHelpers.initPasswordToggles(overlay);
|
|
}
|
|
|
|
logoutBtn.onclick = async () => {
|
|
try {
|
|
await apiCall('/logout', 'POST');
|
|
window.location.href = '/login';
|
|
} catch (e) {
|
|
window.location.href = '/login';
|
|
}
|
|
};
|
|
|
|
form.onsubmit = async (e) => {
|
|
e.preventDefault();
|
|
errorDiv.style.display = 'none';
|
|
errorDiv.textContent = '';
|
|
|
|
try {
|
|
const res = await apiCall('/change-password', 'POST', { newPassword: newPwInput.value });
|
|
if (res.success) {
|
|
showToast('✓ Mot de passe mis à jour avec succès !');
|
|
overlay.remove();
|
|
if (state.currentUser) {
|
|
state.currentUser.mustChangePassword = false;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
errorDiv.textContent = err.message || "Une erreur est survenue.";
|
|
errorDiv.style.display = 'block';
|
|
}
|
|
};
|
|
};
|
|
|
|
// --- Forced Password Change Checker ---
|
|
if (state.currentUser && state.currentUser.mustChangePassword) {
|
|
showMustChangePasswordOverlay();
|
|
return; // Bloquer la suite de l'initialisation de l'application
|
|
}
|
|
|
|
// --- Auth Error Toast Checker ---
|
|
const authError = getCookie('authError');
|
|
if (authError) {
|
|
// Delete the cookie
|
|
document.cookie = "authError=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
|
showToast(decodeURIComponent(authError).replace(/\+/g, ' '));
|
|
}
|
|
|
|
// --- JDownloader Toggle ---
|
|
const toggleJd = document.getElementById('toggle-jd');
|
|
if (toggleJd) {
|
|
toggleJd.checked = getPref('useJD', false);
|
|
toggleJd.addEventListener('change', (e) => setPref('useJD', e.target.checked));
|
|
}
|
|
|
|
// --- Default Preferred Page ---
|
|
const selectDefaultPage = document.getElementById('select-default-page');
|
|
if (selectDefaultPage) {
|
|
const setCookie = (name, value, days = 365) => {
|
|
const date = new Date();
|
|
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
|
|
document.cookie = `${name}=${value}; expires=${date.toUTCString()}; path=/`;
|
|
};
|
|
|
|
const savedPage = getPref('defaultPage', '/trending');
|
|
selectDefaultPage.value = savedPage;
|
|
|
|
// Sync cookie
|
|
setCookie('defaultPage', savedPage);
|
|
|
|
selectDefaultPage.addEventListener('change', (e) => {
|
|
const newPage = e.target.value;
|
|
setCookie('defaultPage', newPage);
|
|
setPref('defaultPage', newPage);
|
|
showToast('Page par défaut mise à jour');
|
|
});
|
|
}
|
|
|
|
// --- Source Detection & Dynamic Options ---
|
|
try {
|
|
const statusData = await apiCall('/status');
|
|
state.activeSources = statusData.activeSources || [];
|
|
state.availableSources = statusData.availableSources || [];
|
|
state.sourceLabels = statusData.sourceLabels || {};
|
|
renderSourcesUI();
|
|
updateLocalDbFiltersVisibility();
|
|
} catch(e) { console.error('Erreur détection source:', e); }
|
|
|
|
loadTrending();
|
|
lucide.createIcons();
|
|
document.querySelectorAll('input[name="trending-type"]').forEach(radio => {
|
|
radio.addEventListener('change', renderTrending);
|
|
});
|
|
|
|
// --- HEARTBEAT : Détection source down + Refresh Tendances ---
|
|
let wasOffline = false;
|
|
const checkSourceStatus = async () => {
|
|
try {
|
|
const s = await apiCall('/status');
|
|
updateSiteStatusUI(s.isOffline, s.message);
|
|
state.activeSources = s.activeSources || [];
|
|
state.sourceLabels = s.sourceLabels || {};
|
|
|
|
const searchInput = dom('search-input');
|
|
const searchBtn = dom('btn-search-trigger');
|
|
if (searchInput) searchInput.disabled = s.isOffline;
|
|
if (searchBtn) searchBtn.disabled = s.isOffline;
|
|
|
|
if (!s.isOffline && (wasOffline || (!state.trendingData.films.length && !state.trendingData.series.length))) {
|
|
console.log('[Heartbeat] Site source en ligne, rechargement des tendances...');
|
|
loadTrending();
|
|
}
|
|
wasOffline = s.isOffline;
|
|
updateLocalDbFiltersVisibility();
|
|
} catch (e) {
|
|
wasOffline = true;
|
|
updateSiteStatusUI(true, 'Connexion au serveur perdue...');
|
|
}
|
|
};
|
|
|
|
setInterval(checkSourceStatus, 30000);
|
|
|
|
// ========================= ADMIN USER MANAGEMENT =========================
|
|
const adminUsersList = dom('admin-users-list');
|
|
if (adminUsersList && state.currentUser && state.currentUser.role === 'admin') {
|
|
|
|
const loadAdminUsers = async () => {
|
|
try {
|
|
const users = await apiCall('/admin/users');
|
|
adminUsersList.innerHTML = '';
|
|
|
|
users.forEach(user => {
|
|
const isCurrentUser = state.currentUser && state.currentUser.username === user.username;
|
|
const roleColor = user.role === 'admin' ? 'var(--primary)' : '#22c55e';
|
|
const roleLabel = user.role === 'admin' ? 'Admin' : 'Utilisateur';
|
|
|
|
const item = document.createElement('div');
|
|
item.className = 'admin-user-item';
|
|
item.style.cssText = 'display: flex; align-items: center; justify-content: space-between; padding: 12px 14px; background: var(--bg-main); border: 1px solid var(--border); border-radius: 10px;';
|
|
|
|
item.innerHTML = `
|
|
<div style="display: flex; align-items: center; gap: 10px;">
|
|
<div style="width: 36px; height: 36px; border-radius: 50%; background: ${user.role === 'admin' ? 'rgba(229,9,20,0.15)' : 'rgba(34,197,94,0.15)'}; display: flex; align-items: center; justify-content: center;">
|
|
<i data-lucide="${user.role === 'admin' ? 'shield' : 'user'}" style="width: 16px; height: 16px; color: ${roleColor};"></i>
|
|
</div>
|
|
<div>
|
|
<div style="font-weight: 600; font-size: 0.9rem;">${user.username}${isCurrentUser ? ' <span style="font-size: 0.7rem; color: var(--text-sec);">(vous)</span>' : ''}</div>
|
|
<div style="font-size: 0.75rem; color: ${roleColor}; font-weight: 500;">${roleLabel}</div>
|
|
</div>
|
|
</div>
|
|
<div style="display: flex; gap: 6px;">
|
|
<button class="admin-reset-pw-btn" data-id="${user.id}" data-name="${user.username}" title="Réinitialiser le mot de passe" style="background: rgba(96,165,250,0.2); border: 1px solid rgba(96,165,250,0.4); color: #60a5fa; padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 0.8rem;">
|
|
<i data-lucide="key" style="width: 14px; height: 14px;"></i>
|
|
</button>
|
|
${!isCurrentUser ? `<button class="admin-delete-user-btn" data-id="${user.id}" data-name="${user.username}" title="Supprimer" style="background: rgba(239,68,68,0.15); border: 1px solid rgba(239,68,68,0.3); color: #ef4444; padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 0.8rem;">
|
|
<i data-lucide="trash-2" style="width: 14px; height: 14px;"></i>
|
|
</button>` : ''}
|
|
</div>
|
|
`;
|
|
adminUsersList.appendChild(item);
|
|
});
|
|
|
|
// Re-init Lucide icons for the newly added elements
|
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
|
|
|
// Bind delete buttons
|
|
document.querySelectorAll('.admin-delete-user-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const userId = btn.dataset.id;
|
|
const userName = btn.dataset.name;
|
|
|
|
const confirmed = await showConfirmModal(
|
|
"Supprimer l'utilisateur",
|
|
`Êtes-vous sûr de vouloir supprimer définitivement l'utilisateur "${userName}" ? Cette action est irréversible.`,
|
|
{ confirmText: 'Supprimer', cancelText: 'Annuler', isDestructive: true }
|
|
);
|
|
if (!confirmed) return;
|
|
|
|
try {
|
|
await apiCall(`/admin/users/${userId}`, 'DELETE');
|
|
showToast(`Utilisateur "${userName}" supprimé.`);
|
|
loadAdminUsers();
|
|
} catch (err) {
|
|
showToast('Erreur: ' + err.message);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Bind reset password buttons
|
|
document.querySelectorAll('.admin-reset-pw-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const userId = btn.dataset.id;
|
|
const userName = btn.dataset.name;
|
|
|
|
const confirmed = await showConfirmModal(
|
|
"Réinitialiser le mot de passe",
|
|
`Voulez-vous réinitialiser le mot de passe de l'utilisateur "${userName}" ? Un nouveau mot de passe aléatoire temporaire sera généré.`,
|
|
{ confirmText: 'Réinitialiser', cancelText: 'Annuler', isDestructive: false }
|
|
);
|
|
if (!confirmed) return;
|
|
|
|
try {
|
|
const result = await apiCall(`/admin/users/${userId}/reset-password`, 'POST');
|
|
if (result.generatedPassword) {
|
|
showPasswordModal(result.generatedPassword);
|
|
}
|
|
} catch (err) {
|
|
showToast('Erreur: ' + err.message);
|
|
}
|
|
});
|
|
});
|
|
|
|
} catch (err) {
|
|
console.error('[Admin] Erreur chargement users:', err);
|
|
}
|
|
};
|
|
|
|
// Show password modal helper
|
|
const showPasswordModal = (password) => {
|
|
const modal = dom('admin-password-modal');
|
|
const pwDisplay = dom('admin-generated-password');
|
|
if (!modal || !pwDisplay) return;
|
|
|
|
pwDisplay.textContent = password;
|
|
show(modal);
|
|
modal.style.display = ''; // Clear inline styles if any
|
|
|
|
const copyBtn = dom('admin-copy-password-btn');
|
|
const closeBtn = dom('admin-close-modal-btn');
|
|
|
|
const closeModal = () => { hide(modal); };
|
|
|
|
if (copyBtn) {
|
|
copyBtn.onclick = () => {
|
|
navigator.clipboard.writeText(password).then(() => {
|
|
copyBtn.textContent = '✓ Copié !';
|
|
setTimeout(() => { copyBtn.textContent = 'Copier le mot de passe'; }, 2000);
|
|
}).catch(() => {
|
|
showToast('Erreur clipboard. Sélectionnez et copiez manuellement.');
|
|
});
|
|
};
|
|
}
|
|
if (closeBtn) closeBtn.onclick = closeModal;
|
|
};
|
|
|
|
// Add user button
|
|
const addUserBtn = dom('admin-add-user-btn');
|
|
if (addUserBtn) {
|
|
addUserBtn.addEventListener('click', async () => {
|
|
const usernameInput = dom('admin-new-username');
|
|
const roleSelect = dom('admin-new-role');
|
|
if (!usernameInput) return;
|
|
|
|
const username = usernameInput.value.trim();
|
|
if (!username) {
|
|
showToast("Nom d'utilisateur requis.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await apiCall('/admin/users', 'POST', {
|
|
username: username,
|
|
role: roleSelect ? roleSelect.value : 'user'
|
|
});
|
|
|
|
if (result.success && result.generatedPassword) {
|
|
usernameInput.value = '';
|
|
showPasswordModal(result.generatedPassword);
|
|
loadAdminUsers();
|
|
}
|
|
} catch (err) {
|
|
showToast('Erreur: ' + err.message);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Initial load
|
|
loadAdminUsers();
|
|
}
|
|
};
|
|
|
|
// --- SOURCE MANAGEMENT ---
|
|
function renderSourcesUI() {
|
|
const container = dom('sources-sortable-container');
|
|
if (!container) return;
|
|
|
|
container.innerHTML = '';
|
|
|
|
// Sort available sources so that active ones appear first in their priority order
|
|
const allSources = [...state.activeSources];
|
|
state.availableSources.forEach(sourceName => {
|
|
if (!allSources.includes(sourceName)) {
|
|
allSources.push(sourceName);
|
|
}
|
|
});
|
|
|
|
const updateWarning = () => {
|
|
const warningEl = dom('primary-source-warning');
|
|
if (warningEl) {
|
|
// Find first active source
|
|
const firstActive = state.activeSources[0];
|
|
if (firstActive === 'hydracker') {
|
|
warningEl.style.display = 'flex';
|
|
} else {
|
|
warningEl.style.display = 'none';
|
|
}
|
|
}
|
|
};
|
|
|
|
allSources.forEach(sourceName => {
|
|
const item = document.createElement('div');
|
|
item.className = 'sortable-source-item';
|
|
item.setAttribute('draggable', 'true');
|
|
item.dataset.name = sourceName;
|
|
|
|
const isActive = state.activeSources.includes(sourceName);
|
|
const isPrimary = state.activeSources[0] === sourceName;
|
|
const displayName = state.sourceLabels[sourceName] || sourceName.toUpperCase();
|
|
|
|
item.innerHTML = `
|
|
<div style="display: flex; align-items: center; gap: 12px; min-width: 0; flex: 1;">
|
|
<div class="drag-handle" title="Faites glisser pour réordonner">
|
|
<i data-lucide="grip-vertical" style="width: 18px; height: 18px;"></i>
|
|
</div>
|
|
<div style="width: 10px; height: 10px; border-radius: 50%; background: ${isActive ? 'var(--success)' : '#4b5563'}; flex-shrink: 0;"></div>
|
|
<span style="font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 250px;">${displayName}</span>
|
|
${isPrimary ? '<span class="primary-badge" style="font-size: 0.7rem; background: var(--primary); color: white; padding: 2px 6px; border-radius: 4px; font-weight: 700; flex-shrink: 0;">Principale</span>' : ''}
|
|
</div>
|
|
<div style="display: flex; align-items: center; gap: 8px;">
|
|
<input type="checkbox" value="${sourceName}" ${isActive ? 'checked' : ''} style="width: 20px; height: 20px; cursor: pointer;">
|
|
</div>
|
|
`;
|
|
|
|
// Drag and drop event listeners
|
|
item.addEventListener('dragstart', (e) => {
|
|
item.classList.add('dragging');
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
});
|
|
|
|
item.addEventListener('dragend', async () => {
|
|
item.classList.remove('dragging');
|
|
|
|
// Rebuild the priority order from the DOM
|
|
const items = Array.from(container.querySelectorAll('.sortable-source-item'));
|
|
const newOrderedSources = items.map(el => el.dataset.name);
|
|
|
|
// Extract active sources in their new order
|
|
const newActiveSources = newOrderedSources.filter(name => {
|
|
const checkbox = container.querySelector(`[data-name="${name}"] input[type="checkbox"]`);
|
|
return checkbox && checkbox.checked;
|
|
});
|
|
|
|
await updateActiveSources(newActiveSources);
|
|
});
|
|
|
|
// Toggle switch logic
|
|
const checkbox = item.querySelector('input[type="checkbox"]');
|
|
checkbox.addEventListener('change', async () => {
|
|
// To maintain order, we rebuild the list order from the DOM
|
|
const items = Array.from(container.querySelectorAll('.sortable-source-item'));
|
|
const newOrderedSources = items.map(el => el.dataset.name);
|
|
|
|
const newActiveSources = newOrderedSources.filter(name => {
|
|
const cb = container.querySelector(`[data-name="${name}"] input[type="checkbox"]`);
|
|
return cb && cb.checked;
|
|
});
|
|
|
|
await updateActiveSources(newActiveSources);
|
|
});
|
|
|
|
container.appendChild(item);
|
|
});
|
|
|
|
// Dragover logic on container to allow real-time visual sorting
|
|
container.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
const draggingEl = container.querySelector('.dragging');
|
|
if (!draggingEl) return;
|
|
|
|
const siblings = Array.from(container.querySelectorAll('.sortable-source-item:not(.dragging)'));
|
|
|
|
const nextSibling = siblings.find(sibling => {
|
|
const rect = sibling.getBoundingClientRect();
|
|
const boxCenter = rect.top + rect.height / 2;
|
|
return e.clientY < boxCenter;
|
|
});
|
|
|
|
if (nextSibling) {
|
|
container.insertBefore(draggingEl, nextSibling);
|
|
} else {
|
|
container.appendChild(draggingEl);
|
|
}
|
|
});
|
|
|
|
updateWarning();
|
|
lucide.createIcons(); // Reactivate icons for newly injected HTML
|
|
}
|
|
|
|
// --- UI UPDATES ---
|
|
function updateLocalDbFiltersVisibility() {
|
|
const hasLocalDb = state.activeSources.includes('localdb');
|
|
document.querySelectorAll('.localdb-filter').forEach(el => {
|
|
if (hasLocalDb) {
|
|
el.classList.remove('hidden');
|
|
} else {
|
|
el.classList.add('hidden');
|
|
// If a hidden radio is checked, default back to 'film'
|
|
const radio = el.querySelector('input[type="radio"]');
|
|
if (radio && radio.checked) {
|
|
const defaultRadio = document.querySelector('input[name="search-type"][value="film"]');
|
|
if (defaultRadio) defaultRadio.checked = true;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async function updateActiveSources(sources) {
|
|
try {
|
|
const res = await apiCall('/set-sources', 'POST', { sources });
|
|
state.activeSources = res.activeSources;
|
|
renderSourcesUI();
|
|
updateLocalDbFiltersVisibility();
|
|
showToast('Sources mises à jour');
|
|
loadTrending();
|
|
} catch(e) {
|
|
showToast('Erreur sources: ' + e.message);
|
|
renderSourcesUI(); // Revert UI
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- DOWNLOADS (LIST) ---
|
|
const loadDownloads = async () => {
|
|
const list = dom('downloads-list');
|
|
if (!list) return;
|
|
|
|
try {
|
|
const data = await apiCall('/download-status');
|
|
list.innerHTML = '';
|
|
|
|
if (!data || !data.length) {
|
|
list.innerHTML = `
|
|
<div class="empty-state-modern">
|
|
<i data-lucide="hard-drive-download"></i>
|
|
<p>Aucun téléchargement actif</p>
|
|
</div>`;
|
|
lucide.createIcons();
|
|
return;
|
|
}
|
|
|
|
data.forEach(dl => {
|
|
const item = document.createElement('div');
|
|
item.className = 'dl-card';
|
|
const isDone = dl.percent >= 100;
|
|
const barColor = isDone ? 'var(--success)' : 'var(--accent)';
|
|
|
|
item.innerHTML = `
|
|
<div class="dl-icon">
|
|
<i data-lucide="${isDone ? 'check-circle' : 'loader-2'}" class="${!isDone ? 'spin-slow' : ''}"></i>
|
|
</div>
|
|
<div class="dl-content">
|
|
<div class="dl-header">
|
|
<span class="dl-title">${dl.name}</span>
|
|
<span class="dl-percentage">${Math.round(dl.percent)}%</span>
|
|
</div>
|
|
<div class="dl-bar-bg">
|
|
<div class="dl-bar-fill" style="width: ${dl.percent}%; background: ${barColor};"></div>
|
|
</div>
|
|
<div class="dl-status-text">${isDone ? 'Terminé' : 'Téléchargement en cours...'}</div>
|
|
</div>
|
|
<button class="btn-jd-delete" title="Supprimer de JDownloader" style="background: none; border: none; color: var(--text-sec); cursor: pointer; padding: 8px; margin-left: 8px; border-radius: 8px; transition: all 0.2s;">
|
|
<i data-lucide="trash-2" style="width: 18px; height: 18px;"></i>
|
|
</button>
|
|
`;
|
|
|
|
// Attach delete handler
|
|
const deleteBtn = item.querySelector('.btn-jd-delete');
|
|
deleteBtn.onmouseover = () => { deleteBtn.style.color = '#ef4444'; deleteBtn.style.background = 'rgba(239,68,68,0.1)'; };
|
|
deleteBtn.onmouseout = () => { deleteBtn.style.color = 'var(--text-sec)'; deleteBtn.style.background = 'none'; };
|
|
deleteBtn.onclick = async (e) => {
|
|
e.stopPropagation();
|
|
const confirmed = await showConfirmModal(
|
|
"Supprimer de JDownloader",
|
|
`Êtes-vous sûr de vouloir supprimer "${dl.name}" de JDownloader ?`,
|
|
{ confirmText: 'Supprimer', cancelText: 'Annuler', isDestructive: true }
|
|
);
|
|
if (!confirmed) return;
|
|
try {
|
|
await apiCall('/jd/remove-link', 'POST', { linkIds: [dl.uuid] });
|
|
showToast(`🗑️ ${dl.name} supprimé`);
|
|
loadDownloads();
|
|
} catch (err) {
|
|
showToast('Erreur : ' + err.message);
|
|
}
|
|
};
|
|
|
|
list.appendChild(item);
|
|
});
|
|
lucide.createIcons();
|
|
} catch (e) { }
|
|
};
|
|
|
|
const btnRefreshDownloads = dom('btn-refresh-downloads');
|
|
if (btnRefreshDownloads) {
|
|
btnRefreshDownloads.onclick = loadDownloads;
|
|
}
|
|
|
|
|
|
// --- UTILS: BLOCKING LOADER & STATE HELPERS ---
|
|
|
|
|
|
|
|
const toggleBlockingLoader = (show, msg = "Traitement en cours...") => {
|
|
let loader = document.getElementById('blocking-loader');
|
|
if (!loader) {
|
|
loader = document.createElement('div');
|
|
loader.id = 'blocking-loader';
|
|
loader.className = 'hidden';
|
|
loader.innerHTML = `<div class="loader"></div><p id="blocking-msg"></p>`;
|
|
document.body.appendChild(loader);
|
|
}
|
|
if (show) {
|
|
document.getElementById('blocking-msg').textContent = msg;
|
|
loader.classList.remove('hidden');
|
|
} else {
|
|
loader.classList.add('hidden');
|
|
}
|
|
};
|
|
|
|
// --- PROXY IMAGE HELPER ---
|
|
// Passe les URLs HTTPS des posters par le proxy serveur pour éviter le blocage mixed-content
|
|
const proxyImageUrl = (url) => {
|
|
if (!url) return '';
|
|
// Si l'image est déjà locale ou en HTTP sur notre domaine, pas besoin de proxy
|
|
if (url.startsWith('/') || url.startsWith('data:')) return url;
|
|
// Proxy les URLs HTTPS via le serveur
|
|
if (url.startsWith('https://')) {
|
|
return `/api/proxy-image?url=${encodeURIComponent(url)}`;
|
|
}
|
|
return url;
|
|
};
|
|
|
|
// --- TRENDING & CARDS ---
|
|
const createCard = (movie) => {
|
|
const div = document.createElement('div');
|
|
div.className = 'card';
|
|
|
|
const posterSrc = proxyImageUrl(movie.image);
|
|
|
|
// Build subtitle: quality + lang for ZT, year for Hydracker
|
|
let subtitle = movie.year || '';
|
|
let cleanedTitle = movie.title;
|
|
|
|
// Clean ZT titles: "Show Name - Saison X [Quality]" -> "Show Name - Saison X"
|
|
if (movie.source === 'zt') {
|
|
const parts = [];
|
|
|
|
// Extract quality info from title if present: "Title [1080p]" -> "Title"
|
|
const titleQualityMatch = cleanedTitle.match(/(.*)\s+\[([^\]]+)\]$/);
|
|
if (titleQualityMatch) {
|
|
cleanedTitle = titleQualityMatch[1].trim();
|
|
const qualityFromTitle = titleQualityMatch[2].trim();
|
|
if (!movie.quality) movie.quality = qualityFromTitle;
|
|
}
|
|
|
|
if (movie.quality) parts.push(movie.quality);
|
|
if (movie.lang) parts.push(movie.lang);
|
|
subtitle = parts.join(' — ') || '';
|
|
}
|
|
|
|
const typeBadge = movie.type === 'series' || movie.type === 'anime' ? `<span class="type-badge">${movie.type === 'anime' ? 'Anime' : 'Série'}</span>` : '';
|
|
|
|
div.innerHTML = `
|
|
<div class="poster-container">
|
|
<img src="${posterSrc}" loading="lazy" alt="${cleanedTitle}" onerror="this.style.display='none'">
|
|
<div class="source-badge" style="position: absolute; top: 8px; right: 8px; background: rgba(0,0,0,0.7); color: white; padding: 2px 8px; border-radius: 4px; font-size: 0.7rem; font-weight: 700; text-transform: uppercase; backdrop-filter: blur(4px); border: 1px solid rgba(255,255,255,0.1);">
|
|
${movie.source}
|
|
</div>
|
|
</div>
|
|
<div class="card-info">
|
|
<div class="card-title">${cleanedTitle}</div>
|
|
<div class="card-year">${typeBadge} ${subtitle}</div>
|
|
</div>
|
|
`;
|
|
div.addEventListener('click', () => handleSelection(movie));
|
|
return div;
|
|
};
|
|
|
|
|
|
const renderTrending = () => {
|
|
const grid = dom('trending-grid');
|
|
if (!grid) return;
|
|
|
|
const type = document.querySelector('input[name="trending-type"]:checked').value;
|
|
const itemsToDisplay = type === 'film' ? state.trendingData.films : state.trendingData.series;
|
|
|
|
grid.innerHTML = '';
|
|
|
|
if (!itemsToDisplay || !itemsToDisplay.length) {
|
|
if (dom('offline-banner') && dom('offline-banner').textContent.includes('Aucune source configurée')) {
|
|
grid.innerHTML = `
|
|
<div style="grid-column: 1/-1; text-align: center; padding: 4rem 2rem; color: var(--text-sec);">
|
|
<i data-lucide="settings-2" style="width: 48px; height: 48px; margin-bottom: 1.5rem; opacity: 0.5;"></i>
|
|
<h3 style="color: white; margin-bottom: 0.5rem;">Aucune source configurée</h3>
|
|
<p style="margin-bottom: 2rem; max-width: 400px; margin-left: auto; margin-right: auto;">
|
|
Pour afficher du contenu, activez au moins une source dans les paramètres. ZT est recommandé par défaut.
|
|
</p>
|
|
<button class="btn-primary" onclick="document.querySelector('[data-target=\'section-settings\']').click()" style="padding: 10px 24px;">
|
|
Aller aux Paramètres
|
|
</button>
|
|
</div>`;
|
|
lucide.createIcons();
|
|
} else {
|
|
grid.innerHTML = '<p style="padding:1rem">Aucune tendance trouvée.</p>';
|
|
}
|
|
return;
|
|
}
|
|
|
|
itemsToDisplay.forEach(m => grid.appendChild(createCard(m)));
|
|
};
|
|
|
|
const renderRecent = () => {
|
|
const grid = dom('recent-grid');
|
|
if (!grid) return;
|
|
|
|
const itemsToDisplay = state.trendingData.recent || [];
|
|
|
|
grid.innerHTML = '';
|
|
|
|
if (!itemsToDisplay || !itemsToDisplay.length) {
|
|
grid.innerHTML = '<p style="padding:1rem; opacity:0.7;">Aucun ajout récent trouvé.</p>';
|
|
return;
|
|
}
|
|
|
|
itemsToDisplay.forEach(m => grid.appendChild(createCard(m)));
|
|
};
|
|
|
|
const loadTrending = async () => {
|
|
const trendingGrid = dom('trending-grid');
|
|
const recentGrid = dom('recent-grid');
|
|
if (!trendingGrid && !recentGrid) return;
|
|
|
|
try {
|
|
const data = await apiCall('/api/trending');
|
|
|
|
// Mise à jour de la bannière si le site est down
|
|
updateSiteStatusUI(data.isSiteOffline, data.siteOfflineMessage);
|
|
|
|
state.trendingData = data;
|
|
|
|
// Si le serveur n'a pas encore fini de scrapper au démarrage
|
|
if (data.films.length === 0 && data.series.length === 0 && (!data.recent || data.recent.length === 0)) {
|
|
if (trendingGrid) {
|
|
trendingGrid.innerHTML = `
|
|
<div style="grid-column: 1/-1; text-align: center; padding: 3rem; color: var(--text-sec);">
|
|
<div class="loader"></div>
|
|
<p>Le serveur prépare les tendances, un instant...</p>
|
|
</div>`;
|
|
}
|
|
if (recentGrid) {
|
|
recentGrid.innerHTML = `
|
|
<div style="grid-column: 1/-1; text-align: center; padding: 3rem; color: var(--text-sec);">
|
|
<div class="loader"></div>
|
|
<p>Le serveur prépare les ajouts récents, un instant...</p>
|
|
</div>`;
|
|
}
|
|
setTimeout(loadTrending, 3000); // On réessaye dans 3 secondes
|
|
return;
|
|
}
|
|
|
|
renderTrending();
|
|
renderRecent();
|
|
} catch (e) {
|
|
console.error("Erreur tendances/récents:", e);
|
|
if (trendingGrid) {
|
|
trendingGrid.innerHTML = '<p style="padding:1rem; color: #e50914;">⚠️ Erreur de liaison avec le serveur.</p>';
|
|
}
|
|
if (recentGrid) {
|
|
recentGrid.innerHTML = '<p style="padding:1rem; color: #e50914;">⚠️ Erreur de liaison avec le serveur.</p>';
|
|
}
|
|
}
|
|
};
|
|
|
|
document.querySelectorAll('input[name="trending-type"]').forEach(radio => {
|
|
radio.addEventListener('change', renderTrending);
|
|
});
|
|
|
|
// --- SEARCH ---
|
|
const searchInput = dom('search-input');
|
|
const searchBtn = dom('btn-search-trigger');
|
|
const searchRadios = document.querySelectorAll('input[name="search-type"]');
|
|
let searchTimeout = null;
|
|
|
|
const performSearch = async () => {
|
|
const q = searchInput ? searchInput.value.trim() : '';
|
|
const grid = dom('search-results');
|
|
if (!q) {
|
|
if (grid) grid.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
// ZT requires min 4 characters
|
|
if (state.activeSources.includes('zt') && state.activeSources.length === 1 && q.length < 4) {
|
|
if (grid) grid.innerHTML = '<p style="padding:1rem; opacity:0.7;">Minimum 4 caractères pour la recherche sur ZT.</p>';
|
|
return;
|
|
}
|
|
|
|
const typeEl = document.querySelector('input[name="search-type"]:checked');
|
|
const type = typeEl ? typeEl.value : 'film';
|
|
|
|
if (grid) grid.innerHTML = '<div class="loader-wrapper"><div class="loader"></div></div>';
|
|
|
|
try {
|
|
const res = await apiCall('/search', 'POST', { title: q, mediaType: type });
|
|
// Vérification si la recherche n'a pas changé entre temps
|
|
if (searchInput.value.trim() !== q) return;
|
|
|
|
if (grid) {
|
|
grid.innerHTML = '';
|
|
if (!res || !res.length) {
|
|
if (dom('offline-banner') && dom('offline-banner').textContent.includes('Aucune source configurée')) {
|
|
grid.innerHTML = '<p style="padding:1rem; opacity:0.7;">Veuillez configurer une source dans les paramètres.</p>';
|
|
} else {
|
|
grid.innerHTML = '<p style="padding:1rem; opacity:0.7;">Aucun résultat.</p>';
|
|
}
|
|
}
|
|
else res.forEach(m => grid.appendChild(createCard(m)));
|
|
}
|
|
} catch (e) {
|
|
if (grid) grid.innerHTML = `<p style="padding:1rem; color:#ef4444;">Erreur: ${e.message}</p>`;
|
|
}
|
|
};
|
|
|
|
if (searchInput) {
|
|
// Recherche instantanée avec debounce
|
|
searchInput.addEventListener('input', () => {
|
|
if (searchTimeout) clearTimeout(searchTimeout);
|
|
searchTimeout = setTimeout(() => {
|
|
performSearch();
|
|
}, 500); // 500ms d'attente
|
|
});
|
|
|
|
// Entrée pour valider immédiatement
|
|
searchInput.addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
if (searchTimeout) clearTimeout(searchTimeout);
|
|
performSearch();
|
|
}
|
|
});
|
|
}
|
|
|
|
if (searchBtn) {
|
|
searchBtn.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
if (searchTimeout) clearTimeout(searchTimeout);
|
|
performSearch();
|
|
});
|
|
}
|
|
|
|
// Relancer la recherche si on change de filtre (Film/Série)
|
|
searchRadios.forEach(radio => {
|
|
radio.addEventListener('change', () => {
|
|
if (searchInput && searchInput.value.trim().length > 0) {
|
|
if (searchTimeout) clearTimeout(searchTimeout);
|
|
performSearch();
|
|
}
|
|
});
|
|
});
|
|
|
|
// --- MODAL ---
|
|
const handleSelection = async (movie) => {
|
|
showModal(movie.title, '<div class="loader-wrapper"><div class="loader"></div></div>');
|
|
try {
|
|
let ep = '/select-movie';
|
|
if (movie.source === 'hydracker' && movie.hrefPath && movie.hrefPath.includes('download')) {
|
|
ep = '/select-trending';
|
|
}
|
|
const data = await apiCall(ep, 'POST', { hrefPath: movie.hrefPath || '', title: movie.title, type: movie.type, source: movie.source });
|
|
// Ajout du 3ème argument : movie.source
|
|
renderModalOptions(data, movie.title, movie.source);
|
|
} catch (e) {
|
|
dom('modal-body').innerHTML = `<p style="color:red">${e.message}</p>`;
|
|
}
|
|
};
|
|
|
|
function parseSizeToMB(sizeStr) {
|
|
if (!sizeStr || sizeStr === 'N/A') return 0;
|
|
const match = sizeStr.match(/([\d.,]+)\s*(gb|go|mb|mo|ko|kb|tb|to)/i);
|
|
if (!match) return 0;
|
|
let size = parseFloat(match[1].replace(',', '.'));
|
|
const unit = match[2].toLowerCase();
|
|
if (unit.includes('gb') || unit.includes('go')) size *= 1024;
|
|
else if (unit.includes('tb')) size *= 1024 * 1024;
|
|
else if (unit.includes('kb') || unit.includes('ko')) size /= 1024;
|
|
return size;
|
|
}
|
|
|
|
const getQualityRank = (qualityString) => {
|
|
const lower = qualityString.toLowerCase();
|
|
if (lower.includes("ultra hdlight") && lower.includes("x265")) return 1;
|
|
if (lower.includes("1080p") && lower.includes("x265") || lower.includes("1080p light") || lower.includes("x265")) return 2;
|
|
return 3;
|
|
};
|
|
|
|
const renderModalOptions = (data, currentTitle = '', source = '') => {
|
|
const body = dom('modal-body');
|
|
body.innerHTML = '';
|
|
|
|
const selectedIds = new Set();
|
|
const updateBatchUI = () => {
|
|
const count = selectedIds.size;
|
|
const container = document.getElementById('batch-download-container');
|
|
if (container) {
|
|
container.style.display = count > 0 ? 'block' : 'none';
|
|
const countSpan = document.getElementById('batch-count');
|
|
if (countSpan) countSpan.textContent = count;
|
|
}
|
|
};
|
|
|
|
// --- 0. TYPE DETECTION ---
|
|
const hasSaisonLabel = data.seasons && data.seasons.some(s => s.label.toLowerCase().includes('saison'));
|
|
const hasNumericEpisodes = data.clientOptions && data.clientOptions.some(q => {
|
|
if (!q.episode) return false;
|
|
const lowEp = q.episode.toLowerCase();
|
|
if (lowEp.includes('saison complète') || lowEp.includes('intégrale') || lowEp.includes('pack')) return false;
|
|
// Si c'est un nombre ou contient "Ep", c'est une série
|
|
return /^\d+$/.test(q.episode) || lowEp.includes('ep');
|
|
});
|
|
const isActuallySeries = hasSaisonLabel || hasNumericEpisodes;
|
|
|
|
|
|
// --- 1. SAISONS / VERSIONS ---
|
|
if (data.seasons && data.seasons.length > 0) {
|
|
const h4 = document.createElement('h4');
|
|
h4.textContent = isActuallySeries ? "Saisons disponibles" : "Qualités & Versions";
|
|
h4.className = "modal-subtitle";
|
|
body.appendChild(h4);
|
|
|
|
const seasonsWrapper = document.createElement('div');
|
|
seasonsWrapper.className = 'seasons-wrapper';
|
|
|
|
data.seasons.forEach(s => {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'version-tab'; // using version-tab for a clean look
|
|
btn.innerHTML = `<span>${s.label}</span>`;
|
|
btn.title = s.label;
|
|
|
|
btn.onclick = async () => {
|
|
body.innerHTML = '<div class="loader-wrapper"><div class="loader"></div></div>';
|
|
try {
|
|
const res = await apiCall('/select-season', 'POST', { seasonValue: s.value });
|
|
res.seasons = data.seasons;
|
|
|
|
const baseTitle = currentTitle.split(' - ')[0];
|
|
const newTitle = (s.label.toLowerCase().includes('saison') || s.label.toLowerCase().includes('intégrale'))
|
|
? `${baseTitle} - ${s.label}`
|
|
: currentTitle;
|
|
|
|
const titleEl = dom('modal-title');
|
|
if (titleEl) titleEl.textContent = newTitle;
|
|
console.log(`[Modal] Titre mis à jour : ${newTitle}`);
|
|
|
|
renderModalOptions(res, newTitle, source);
|
|
} catch (e) {
|
|
body.innerHTML = `<p style="color:red; padding:1rem;">Erreur: ${e.message}</p>`;
|
|
}
|
|
};
|
|
|
|
seasonsWrapper.appendChild(btn);
|
|
});
|
|
body.appendChild(seasonsWrapper);
|
|
|
|
const sep = document.createElement('hr');
|
|
sep.className = 'modal-sep';
|
|
body.appendChild(sep);
|
|
}
|
|
|
|
|
|
// --- 2. FILES ---
|
|
if (!data.clientOptions || !data.clientOptions.length) {
|
|
body.innerHTML += '<p class="empty-msg">Aucun fichier disponible.</p>';
|
|
return;
|
|
}
|
|
|
|
const MAX_FILM_SIZE_MB = 45360;
|
|
|
|
const enriched = data.clientOptions.map(q => {
|
|
const lowEp = q.episode ? q.episode.toLowerCase() : '';
|
|
const isFullSeason = lowEp.includes('saison complète') || lowEp.includes('intégrale') || lowEp.includes('pack') ? 1 : 0;
|
|
return {
|
|
...q,
|
|
sizeVal: parseSizeToMB(q.size),
|
|
rank: getQualityRank(q.quality),
|
|
isFullSeason
|
|
};
|
|
})
|
|
.filter(q => {
|
|
if (!isActuallySeries) return q.sizeVal <= MAX_FILM_SIZE_MB;
|
|
return true;
|
|
}).sort((a, b) => {
|
|
if (a.isFullSeason !== b.isFullSeason) return b.isFullSeason - a.isFullSeason;
|
|
if (isActuallySeries) {
|
|
const epA = parseInt(String(a.episode || '').replace(/\D/g, '')) || 0;
|
|
const epB = parseInt(String(b.episode || '').replace(/\D/g, '')) || 0;
|
|
if (epA !== epB) return epA - epB;
|
|
}
|
|
if (a.rank !== b.rank) return a.rank - b.rank;
|
|
return a.sizeVal - b.sizeVal;
|
|
});
|
|
|
|
if (enriched.length === 0) {
|
|
body.innerHTML += '<p class="empty-msg">Aucun fichier disponible.</p>';
|
|
return;
|
|
}
|
|
|
|
// --- 2a. BUILD VERSION TABS ---
|
|
// Gather unique versions from quality field
|
|
const allVersions = [...new Set(enriched.map(q => q.quality || 'Inconnu'))];
|
|
|
|
let activeVersion = allVersions[0];
|
|
|
|
const h4files = document.createElement('h4');
|
|
h4files.textContent = "Fichiers Disponibles";
|
|
h4files.className = "modal-subtitle";
|
|
body.appendChild(h4files);
|
|
|
|
// Version filter tabs (only if >1 version)
|
|
const versionTabsWrap = document.createElement('div');
|
|
versionTabsWrap.className = 'version-tabs';
|
|
body.appendChild(versionTabsWrap);
|
|
|
|
const filesContainer = document.createElement('div');
|
|
filesContainer.className = 'files-list';
|
|
body.appendChild(filesContainer);
|
|
|
|
const renderFiles = (version) => {
|
|
filesContainer.innerHTML = '';
|
|
const toShow = allVersions.length > 1
|
|
? enriched.filter(q => (q.quality || 'Inconnu') === version)
|
|
: enriched;
|
|
|
|
if (toShow.length === 0) {
|
|
filesContainer.innerHTML = '<p class="empty-msg">Aucun fichier pour cette version.</p>';
|
|
return;
|
|
}
|
|
|
|
// Group by host
|
|
const hostGroups = new Map();
|
|
toShow.forEach(q => {
|
|
let hostKey = q.host || 'inconnu';
|
|
if (!hostGroups.has(hostKey)) hostGroups.set(hostKey, []);
|
|
hostGroups.get(hostKey).push(q);
|
|
});
|
|
|
|
hostGroups.forEach((items, hostName) => {
|
|
// Host Header
|
|
const hostHeader = document.createElement('div');
|
|
hostHeader.className = "season-group-title";
|
|
hostHeader.style.marginTop = "1rem";
|
|
|
|
// Friendly host name
|
|
const hostDisplay = hostName
|
|
.replace(/\.(png|jpg|webp|gif)$/i, '')
|
|
.replace(/[-_]/g, ' ')
|
|
.toUpperCase();
|
|
|
|
hostHeader.textContent = hostDisplay;
|
|
filesContainer.appendChild(hostHeader);
|
|
|
|
// Sort items by episode number
|
|
items.sort((a, b) => {
|
|
if (a.isFullSeason && !b.isFullSeason) return -1;
|
|
if (!a.isFullSeason && b.isFullSeason) return 1;
|
|
|
|
const numA = a.episode ? parseFloat(a.episode.toString().replace(/[^0-9.]/g, '')) || 0 : 0;
|
|
const numB = b.episode ? parseFloat(b.episode.toString().replace(/[^0-9.]/g, '')) || 0 : 0;
|
|
return numA - numB;
|
|
});
|
|
|
|
const itemsGrid = document.createElement('div');
|
|
itemsGrid.className = isActuallySeries ? "seasons-grid" : "files-list";
|
|
filesContainer.appendChild(itemsGrid);
|
|
|
|
items.forEach((q, idx) => {
|
|
const row = document.createElement('div');
|
|
let specialClass = '';
|
|
let rankIcon = '';
|
|
|
|
if (q.isFullSeason) { specialClass = 'quality-gold'; rankIcon = '📦'; }
|
|
else if (q.rank === 1) { specialClass = 'quality-gold'; rankIcon = '⭐'; }
|
|
else if (q.rank === 2) { specialClass = 'quality-blue'; rankIcon = '✨'; }
|
|
|
|
const episodeLabel = q.episode
|
|
? (q.isFullSeason || !/^\d+$/.test(q.episode) ? q.episode : `Ep. ${q.episode}`)
|
|
: "Télécharger";
|
|
|
|
// On vérifie si la source est Hydracker
|
|
const isHydracker = source === 'hydracker';
|
|
|
|
let badgesHtml = '<div style="display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-top: 6px;">';
|
|
if (isActuallySeries && q.size && q.size !== 'N/A') {
|
|
badgesHtml += `<span class="badge-size">💾 ${q.size}</span>`;
|
|
}
|
|
if (q.langs && q.langs.length) {
|
|
q.langs.forEach(l => {
|
|
if (l && l !== 'unknown') badgesHtml += `<span class="badge-lang">🎧 ${l.replace('Subs:', '💬').replace('Audio:', '🎧')}</span>`;
|
|
});
|
|
}
|
|
if (q.subs && q.subs.length) {
|
|
q.subs.forEach(s => {
|
|
if (s && s !== 'Inconnu') badgesHtml += `<span class="badge-sub">💬 ${s}</span>`;
|
|
});
|
|
}
|
|
badgesHtml += '</div>';
|
|
if (q.releaseName) {
|
|
badgesHtml += `<div style="font-size: 0.7rem; color: var(--text-sec); margin-top: 4px; word-break: break-all;">${q.releaseName}</div>`;
|
|
}
|
|
|
|
if (isActuallySeries) {
|
|
row.className = `quality-pill ${specialClass}`;
|
|
row.style.display = 'flex';
|
|
row.style.justifyContent = 'space-between';
|
|
row.style.alignItems = 'center';
|
|
row.style.padding = "8px 14px";
|
|
row.style.cursor = "pointer";
|
|
row.style.gap = "20px";
|
|
|
|
let buttonsHtml = '';
|
|
if (isHydracker) {
|
|
buttonsHtml = `
|
|
<div style="display: flex; gap: 6px; align-items: center;">
|
|
<button class="btn-action btn-movix" title="Lien Direct (Movix)" style="background: rgba(139, 92, 246, 0.2); border: 1px solid rgba(139, 92, 246, 0.5); color: #8b5cf6; border-radius: 6px; padding: 4px 8px; cursor: pointer; display: flex; align-items: center; transition: all 0.2s;">
|
|
<i data-lucide="zap" style="width: 16px; height: 16px;"></i>
|
|
</button>
|
|
<button class="btn-action btn-dl" title="Télécharger classique" style="background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1); color: white; border-radius: 6px; padding: 4px 8px; cursor: pointer; display: flex; align-items: center; transition: all 0.2s;">
|
|
<i data-lucide="download" style="width: 16px; height: 16px;"></i>
|
|
</button>
|
|
</div>
|
|
`;
|
|
} else {
|
|
buttonsHtml = `
|
|
<button class="btn-action btn-dl" title="Télécharger" style="background: transparent; border: none; color: white; cursor: pointer; display: flex; align-items: center;">
|
|
<i data-lucide="download" style="width: 18px; height: 18px;"></i>
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
const isChecked = selectedIds.has(q.id);
|
|
row.innerHTML = `
|
|
<div style="display: flex; flex-direction: column; flex: 1;">
|
|
<div style="display: flex; align-items: center;">
|
|
<div class="batch-checkbox" data-id="${q.id}" style="cursor: pointer; display: flex; align-items: center; color: ${isChecked ? 'var(--primary)' : 'var(--text-sec)'}; margin-right: 8px;">
|
|
<i data-lucide="${isChecked ? 'check-square' : 'square'}" style="width: 18px; height: 18px;"></i>
|
|
</div>
|
|
${rankIcon ? `<span style="margin-right:5px">${rankIcon}</span>` : ''}
|
|
<span style="font-weight: 600;">${episodeLabel}</span>
|
|
</div>
|
|
${badgesHtml}
|
|
</div>
|
|
${buttonsHtml}
|
|
`;
|
|
} else {
|
|
row.className = `file-btn ${specialClass}`;
|
|
row.style.cursor = "pointer";
|
|
row.style.display = 'flex';
|
|
row.style.justifyContent = 'space-between';
|
|
row.style.alignItems = 'center';
|
|
|
|
const mirrorLabel = items.length > 1 ? ` <span class="mirror-tag">Miroir ${idx + 1}</span>` : '';
|
|
|
|
let buttonsHtml = '';
|
|
if (isHydracker) {
|
|
buttonsHtml = `
|
|
<div class="file-btn-right" style="display: flex; gap: 8px; align-items: center;">
|
|
<button class="btn-action btn-movix" title="Lien Direct (Movix)" style="background: rgba(139, 92, 246, 0.15); border: 1px solid #8b5cf6; color: #8b5cf6; padding: 6px 12px; border-radius: 6px; cursor: pointer; display: flex; align-items: center; gap: 6px; font-weight: 600; transition: all 0.2s;">
|
|
<i data-lucide="zap" style="width: 16px; height: 16px;"></i> Movix
|
|
</button>
|
|
<button class="btn-action btn-dl" title="Télécharger classique" style="background: rgba(255, 255, 255, 0.1); border: none; color: white; padding: 6px 12px; border-radius: 6px; cursor: pointer; display: flex; align-items: center; transition: all 0.2s;">
|
|
<i data-lucide="download" style="width: 16px; height: 16px;"></i>
|
|
</button>
|
|
</div>
|
|
`;
|
|
} else {
|
|
buttonsHtml = `
|
|
<div class="file-btn-right">
|
|
<button class="btn-action btn-dl" title="Télécharger" style="background: transparent; border: none; color: white; cursor: pointer;">
|
|
<i data-lucide="download" class="dl-icon-btn"></i>
|
|
</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
const isChecked = selectedIds.has(q.id);
|
|
row.innerHTML = `
|
|
<div class="file-btn-left" style="display: flex; flex-direction: column; width: 100%; padding-right: 15px;">
|
|
<div style="display: flex; align-items: center; justify-content: space-between; width: 100%;">
|
|
<div class="file-host" style="display: flex; align-items: center;">
|
|
<div class="batch-checkbox" data-id="${q.id}" style="cursor: pointer; display: flex; align-items: center; color: ${isChecked ? 'var(--primary)' : 'var(--text-sec)'}; margin-right: 8px;">
|
|
<i data-lucide="${isChecked ? 'check-square' : 'square'}" style="width: 18px; height: 18px;"></i>
|
|
</div>
|
|
${rankIcon ? `<span class="rank-icon">${rankIcon}</span>` : ''}
|
|
<span class="host-name">${hostDisplay}</span>
|
|
${mirrorLabel}
|
|
${q.episode ? `<span class="episode-tag">${q.episode}</span>` : ''}
|
|
</div>
|
|
<div class="file-size" style="margin-left: 15px; flex-shrink: 0;">${q.size && q.size !== 'N/A' ? q.size : 'Taille inconnue'}</div>
|
|
</div>
|
|
${badgesHtml}
|
|
</div>
|
|
${buttonsHtml}
|
|
`;
|
|
}
|
|
|
|
const chk = row.querySelector('.batch-checkbox');
|
|
if (chk) {
|
|
chk.onclick = (e) => {
|
|
e.stopPropagation();
|
|
if (selectedIds.has(q.id)) {
|
|
selectedIds.delete(q.id);
|
|
chk.innerHTML = '<i data-lucide="square" style="width: 18px; height: 18px;"></i>';
|
|
chk.style.color = 'var(--text-sec)';
|
|
} else {
|
|
selectedIds.add(q.id);
|
|
chk.innerHTML = '<i data-lucide="check-square" style="width: 18px; height: 18px;"></i>';
|
|
chk.style.color = 'var(--primary)';
|
|
}
|
|
lucide.createIcons({ root: chk });
|
|
updateBatchUI();
|
|
};
|
|
}
|
|
|
|
// On attache l'event listener Movix UNIQUEMENT si on est sur Hydracker
|
|
if (isHydracker) {
|
|
row.querySelector('.btn-movix').onclick = async (e) => {
|
|
e.stopPropagation();
|
|
toggleBlockingLoader(true, "Débridage Movix en cours...");
|
|
try {
|
|
const result = await apiCall(`/movix-decode/${q.id}`, 'GET');
|
|
toggleBlockingLoader(false);
|
|
|
|
if (result.link) {
|
|
let toggleEl = document.getElementById('toggle-jd');
|
|
let useJD = toggleEl ? toggleEl.checked : getPref('useJD', false);
|
|
|
|
if (useJD) {
|
|
showToast('Lien Movix envoyé à JDownloader !');
|
|
await apiCall('/jd/add', 'POST', { link: result.link, packageName: q.releaseName || 'Movix Download', isSeries: isActuallySeries });
|
|
} else {
|
|
showDirectLinkModal('Lien Direct Movix', `
|
|
<div class="direct-link-box">
|
|
<p style="margin-bottom: 12px; color: var(--text-sec); font-size: 0.9rem;">Voici votre lien direct rapide :</p>
|
|
<div style="position: relative; width: 100%; margin-bottom: 1.25rem;">
|
|
<input type="text" value="${result.link}" readonly id="direct-link-input" class="select-on-click"
|
|
style="width: 100%; background: #0a0a0a; border: 1px solid var(--border); border-radius: 10px; color: #ffffff; padding: 14px 44px 14px 16px; font-family: monospace; font-size: 0.95rem; text-align: left; box-shadow: inset 0 2px 4px rgba(0,0,0,0.8); outline: none;">
|
|
<button class="copy-btn-target" data-copy-target="direct-link-input" style="position: absolute; right: 12px; top: 50%; transform: translateY(-50%); background: none; border: none; color: var(--text-sec); cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 4px;"
|
|
title="Copier le lien">
|
|
<i data-lucide="copy" style="width: 18px; height: 18px;"></i>
|
|
</button>
|
|
</div>
|
|
<div class="btn-group" style="display: flex; gap: 10px;">
|
|
<button class="btn-action copy-btn-target" data-copy-target="direct-link-input" style="flex: 1; background: #262626; border: 1px solid var(--border); color: white; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px; font-weight: 600; border-radius: 10px; cursor: pointer; transition: all 0.2s;">
|
|
<i data-lucide="copy" style="width: 16px; height: 16px;"></i> Copier le lien
|
|
</button>
|
|
<a href="${result.link}" target="_blank" class="btn-success" style="flex: 1; text-align: center; text-decoration: none; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px; font-weight: 600; border-radius: 10px; cursor: pointer; transition: all 0.2s;">
|
|
<i data-lucide="external-link" style="width: 16px; height: 16px;"></i> Ouvrir le lien
|
|
</a>
|
|
</div>
|
|
</div>
|
|
`);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
toggleBlockingLoader(false);
|
|
showToast("Erreur Movix: " + err.message);
|
|
show(dom('modal-overlay'));
|
|
}
|
|
};
|
|
}
|
|
|
|
// L'event listener classique fonctionne pour toutes les sources
|
|
row.onclick = async (e) => {
|
|
e.stopPropagation();
|
|
let toggleEl = document.getElementById('toggle-jd');
|
|
let useJD = toggleEl ? toggleEl.checked : getPref('useJD', false);
|
|
let forcedDirect = (source === 'ztnews');
|
|
if (forcedDirect) {
|
|
useJD = false;
|
|
}
|
|
toggleBlockingLoader(true, "Récupération du lien...");
|
|
try {
|
|
const result = await apiCall('/get-link', 'POST', { chosenId: q.id, useJD });
|
|
toggleBlockingLoader(false);
|
|
|
|
if (useJD) {
|
|
showToast('Lien envoyé à JDownloader !');
|
|
} else {
|
|
if (forcedDirect) {
|
|
showToast("⚠️ Cloudflare Turnstile actif. Résolution manuelle requise.");
|
|
}
|
|
const titleText = forcedDirect ? 'Lien Direct (Protection Cloudflare)' : 'Lien Direct';
|
|
const descText = forcedDirect
|
|
? 'Ce lien est protégé par Cloudflare. Cliquez sur "Ouvrir" pour le déverrouiller manuellement :'
|
|
: 'Voici votre lien direct rapide :';
|
|
|
|
showDirectLinkModal(titleText, `
|
|
<div class="direct-link-box">
|
|
<p style="margin-bottom: 12px; color: var(--text-sec); font-size: 0.9rem;">${descText}</p>
|
|
<div style="position: relative; width: 100%; margin-bottom: 1.25rem;">
|
|
<input type="text" value="${result.link}" readonly id="direct-link-input-2" class="select-on-click"
|
|
style="width: 100%; background: #0a0a0a; border: 1px solid var(--border); border-radius: 10px; color: #ffffff; padding: 14px 44px 14px 16px; font-family: monospace; font-size: 0.95rem; text-align: left; box-shadow: inset 0 2px 4px rgba(0,0,0,0.8); outline: none;">
|
|
</div>
|
|
<div class="btn-group" style="display: flex; gap: 10px;">
|
|
<button class="btn-action copy-btn-target" data-copy-target="direct-link-input-2" style="flex: 1; background: #262626; border: 1px solid var(--border); color: white; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px; font-weight: 600; border-radius: 10px; cursor: pointer; transition: all 0.2s;">
|
|
<i data-lucide="copy" style="width: 16px; height: 16px;"></i> Copier le lien
|
|
</button>
|
|
<a href="${result.link}" target="_blank" class="btn-success" style="flex: 1; text-align: center; text-decoration: none; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px; font-weight: 600; border-radius: 10px; cursor: pointer; transition: all 0.2s;">
|
|
<i data-lucide="external-link" style="width: 16px; height: 16px;"></i> Ouvrir le lien
|
|
</a>
|
|
</div>
|
|
</div>
|
|
`);
|
|
}
|
|
} catch (err) {
|
|
toggleBlockingLoader(false);
|
|
showToast("Erreur: " + err.message);
|
|
show(dom('modal-overlay'));
|
|
}
|
|
};
|
|
|
|
row.querySelectorAll('.btn-action').forEach(b => {
|
|
b.addEventListener('mouseover', () => b.style.transform = 'scale(1.05)');
|
|
b.addEventListener('mouseout', () => b.style.transform = 'scale(1)');
|
|
});
|
|
|
|
itemsGrid.appendChild(row);
|
|
});
|
|
});
|
|
|
|
|
|
lucide.createIcons();
|
|
};
|
|
|
|
if (allVersions.length > 1) {
|
|
allVersions.forEach(version => {
|
|
const tab = document.createElement('button');
|
|
tab.className = 'version-tab';
|
|
tab.textContent = version;
|
|
if (version === activeVersion) tab.classList.add('active');
|
|
tab.onclick = () => {
|
|
activeVersion = version;
|
|
versionTabsWrap.querySelectorAll('.version-tab').forEach(t => t.classList.remove('active'));
|
|
tab.classList.add('active');
|
|
renderFiles(version);
|
|
};
|
|
versionTabsWrap.appendChild(tab);
|
|
});
|
|
}
|
|
|
|
renderFiles(activeVersion);
|
|
|
|
const batchDownloadContainer = document.createElement('div');
|
|
batchDownloadContainer.id = 'batch-download-container';
|
|
batchDownloadContainer.style = 'display: none; padding-top: 1rem; border-top: 1px solid var(--border); margin-top: 1rem;';
|
|
batchDownloadContainer.innerHTML = `
|
|
<button class="btn-primary" id="btn-batch-download" style="width: 100%; display: flex; justify-content: center; align-items: center; gap: 8px;">
|
|
<i data-lucide="download"></i> Télécharger la sélection (<span id="batch-count">0</span>)
|
|
</button>
|
|
`;
|
|
body.appendChild(batchDownloadContainer);
|
|
|
|
const btnBatch = batchDownloadContainer.querySelector('#btn-batch-download');
|
|
btnBatch.onclick = async () => {
|
|
if (selectedIds.size === 0) return;
|
|
let toggleEl = document.getElementById('toggle-jd');
|
|
let useJD = toggleEl ? toggleEl.checked : getPref('useJD', false);
|
|
let forcedDirect = (source === 'ztnews');
|
|
if (forcedDirect) {
|
|
useJD = false;
|
|
}
|
|
toggleBlockingLoader(true, "Récupération des liens...");
|
|
try {
|
|
const result = await apiCall('/get-links-batch', 'POST', { chosenIds: Array.from(selectedIds), useJD });
|
|
toggleBlockingLoader(false);
|
|
|
|
if (useJD) {
|
|
showToast(result.message || 'Liens envoyés à JDownloader !');
|
|
} else {
|
|
if (forcedDirect) {
|
|
showToast("⚠️ Cloudflare Turnstile actif. Résolution manuelle requise.");
|
|
}
|
|
const titleText = forcedDirect ? 'Liens Directs (Protection Cloudflare)' : 'Liens Directs';
|
|
const descText = forcedDirect
|
|
? 'Ces liens sont protégés par Cloudflare. Copiez-les pour les ouvrir manuellement :'
|
|
: 'Voici vos liens directs :';
|
|
|
|
showDirectLinkModal(titleText, `
|
|
<div class="direct-link-box">
|
|
<p style="margin-bottom: 12px; color: var(--text-sec); font-size: 0.9rem;">${descText}</p>
|
|
<textarea readonly id="direct-links-input" class="select-on-click"
|
|
style="width: 100%; height: 150px; background: #0a0a0a; border: 1px solid var(--border); color: #ffffff; border-radius: 10px; margin-bottom: 1.25rem; padding: 14px; font-family: monospace; font-size: 0.95rem; white-space: pre; resize: none; box-shadow: inset 0 2px 4px rgba(0,0,0,0.8); outline: none;">${(result.links || []).join('\n')}</textarea>
|
|
<div class="btn-group" style="display: flex; gap: 10px;">
|
|
<button class="btn-action copy-btn-target" data-copy-target="direct-links-input" style="flex: 1; background: #262626; border: 1px solid var(--border); color: white; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px; font-weight: 600; border-radius: 10px; cursor: pointer; transition: all 0.2s;">
|
|
<i data-lucide="copy" style="width: 16px; height: 16px;"></i> Tout copier
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`);
|
|
}
|
|
} catch (err) {
|
|
toggleBlockingLoader(false);
|
|
showToast("Erreur: " + err.message);
|
|
show(dom('modal-overlay'));
|
|
}
|
|
};
|
|
|
|
updateBatchUI();
|
|
lucide.createIcons({ root: batchDownloadContainer });
|
|
};
|
|
|
|
|
|
const showModal = (title, content) => {
|
|
dom('modal-title').textContent = title;
|
|
dom('modal-body').innerHTML = content;
|
|
show(dom('modal-overlay'));
|
|
};
|
|
|
|
const showDirectLinkModal = (title, content) => {
|
|
const existing = document.getElementById('direct-link-modal-overlay');
|
|
if (existing) existing.remove();
|
|
|
|
const overlay = document.createElement('div');
|
|
overlay.id = 'direct-link-modal-overlay';
|
|
overlay.className = 'modal-overlay';
|
|
overlay.style.zIndex = '10001';
|
|
overlay.innerHTML = `
|
|
<div class="modal-content" style="max-width: 500px;">
|
|
<div class="modal-header">
|
|
<h3>${title}</h3>
|
|
<button id="direct-modal-close"><i data-lucide="x"></i></button>
|
|
</div>
|
|
<div class="modal-body">${content}</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(overlay);
|
|
lucide.createIcons({ root: overlay });
|
|
show(overlay);
|
|
|
|
// CSP Safe Event Listeners
|
|
const selects = overlay.querySelectorAll('.select-on-click');
|
|
selects.forEach(el => el.addEventListener('click', function() { this.select(); }));
|
|
|
|
const copyBtns = overlay.querySelectorAll('.copy-btn-target');
|
|
copyBtns.forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const targetId = btn.getAttribute('data-copy-target');
|
|
const targetEl = document.getElementById(targetId);
|
|
if (targetEl) {
|
|
navigator.clipboard.writeText(targetEl.value)
|
|
.then(() => showToast('Copié !'))
|
|
.catch(() => showToast('Erreur de copie !'));
|
|
}
|
|
});
|
|
});
|
|
|
|
const closeBtn = overlay.querySelector('#direct-modal-close');
|
|
if (closeBtn) {
|
|
closeBtn.addEventListener('click', () => {
|
|
hide(overlay);
|
|
setTimeout(() => overlay.remove(), 300);
|
|
});
|
|
}
|
|
};
|
|
|
|
const modalClose = dom('modal-close');
|
|
if (modalClose) {
|
|
modalClose.onclick = () => {
|
|
hide(dom('modal-overlay'));
|
|
};
|
|
}
|
|
|
|
const btnLogout = dom('btn-logout');
|
|
if (btnLogout) {
|
|
btnLogout.onclick = async () => {
|
|
await apiCall('/logout', 'POST');
|
|
window.location.href = '/login';
|
|
};
|
|
}
|
|
|
|
// --- MANUAL ADD TO JD ---
|
|
const btnManualSubmit = dom('btn-manual-submit');
|
|
if (btnManualSubmit) {
|
|
const typeOptions = document.querySelectorAll('.manual-type-selector .type-option');
|
|
const manualTypeInput = dom('manual-type');
|
|
typeOptions.forEach(opt => {
|
|
opt.onclick = () => {
|
|
typeOptions.forEach(o => o.classList.remove('active'));
|
|
opt.classList.add('active');
|
|
if (manualTypeInput) {
|
|
manualTypeInput.value = opt.dataset.type;
|
|
}
|
|
};
|
|
});
|
|
|
|
btnManualSubmit.onclick = async () => {
|
|
const title = dom('manual-title').value.trim();
|
|
const linksText = dom('manual-links').value.trim();
|
|
const typeValue = manualTypeInput ? manualTypeInput.value : 'film';
|
|
const isSeries = typeValue === 'series';
|
|
|
|
if (!title) {
|
|
showToast("⚠️ Veuillez entrer un titre.");
|
|
return;
|
|
}
|
|
if (!linksText) {
|
|
showToast("⚠️ Veuillez ajouter au moins un lien.");
|
|
return;
|
|
}
|
|
|
|
toggleBlockingLoader(true, "Envoi des liens à JDownloader...");
|
|
try {
|
|
const res = await apiCall('/jd/add', 'POST', {
|
|
links: linksText,
|
|
packageName: title,
|
|
isSeries: isSeries
|
|
});
|
|
toggleBlockingLoader(false);
|
|
if (res.success) {
|
|
showToast(`✅ ${res.count} lien(s) envoyé(s) à JDownloader !`);
|
|
dom('manual-title').value = '';
|
|
dom('manual-links').value = '';
|
|
|
|
// Reset selector style
|
|
typeOptions.forEach(o => o.classList.remove('active'));
|
|
if (typeOptions[0]) typeOptions[0].classList.add('active');
|
|
if (manualTypeInput) manualTypeInput.value = 'film';
|
|
|
|
setTimeout(() => { window.location.href = '/downloads'; }, 1000);
|
|
} else {
|
|
showToast("⚠️ Échec de l'envoi.");
|
|
}
|
|
} catch (err) {
|
|
toggleBlockingLoader(false);
|
|
showToast("Erreur: " + err.message);
|
|
}
|
|
};
|
|
}
|
|
|
|
// Start
|
|
checkSession();
|
|
|
|
// --- DOWNLOAD AUTOMATION ---
|
|
if (dom('downloads-list')) {
|
|
startDownloadLoop();
|
|
}
|
|
}); |