Keep the page chrome sync helpers in shell-bridge.js so React and legacy routing share one implementation. This preserves the sidebar breadcrumb and discover download bar behavior without shadowing the legacy shell helpers in init.js.
2428 lines
95 KiB
JavaScript
2428 lines
95 KiB
JavaScript
// INITIALIZATION
|
|
// ===============================
|
|
let navigationEpoch = 0;
|
|
|
|
function notifyPageWillChange(nextPageId) {
|
|
const fromPageId = typeof currentPage === 'string' ? currentPage : null;
|
|
if (fromPageId === nextPageId) return;
|
|
|
|
window.dispatchEvent(
|
|
new CustomEvent(PAGE_WILL_CHANGE_EVENT, {
|
|
detail: {
|
|
fromPageId,
|
|
toPageId: nextPageId,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
// ---- Accent Color System ----
|
|
|
|
function getAccentFallbackColors() {
|
|
let accent = localStorage.getItem('soulsync-accent') || '#1db954';
|
|
if (!/^#[0-9a-fA-F]{6}$/.test(accent)) accent = '#1db954';
|
|
// Compute a lighter variant for the second color
|
|
const r = parseInt(accent.slice(1, 3), 16), g = parseInt(accent.slice(3, 5), 16), b = parseInt(accent.slice(5, 7), 16);
|
|
const lighter = '#' + [Math.min(r + 20, 255), Math.min(g + 30, 255), Math.min(b + 12, 255)]
|
|
.map(v => v.toString(16).padStart(2, '0')).join('');
|
|
return [accent, lighter];
|
|
}
|
|
|
|
function applyAccentColor(hex) {
|
|
// Validate hex format — reject corrupt values
|
|
if (typeof hex !== 'string' || !/^#[0-9a-fA-F]{6}$/.test(hex)) {
|
|
hex = '#1db954'; // fallback to default
|
|
}
|
|
// Convert hex to RGB
|
|
const r = parseInt(hex.slice(1, 3), 16);
|
|
const g = parseInt(hex.slice(3, 5), 16);
|
|
const b = parseInt(hex.slice(5, 7), 16);
|
|
|
|
// Convert RGB to HSL
|
|
const rn = r / 255, gn = g / 255, bn = b / 255;
|
|
const max = Math.max(rn, gn, bn), min = Math.min(rn, gn, bn);
|
|
const l = (max + min) / 2;
|
|
let h = 0, s = 0;
|
|
if (max !== min) {
|
|
const d = max - min;
|
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
|
|
else if (max === gn) h = ((bn - rn) / d + 2) / 6;
|
|
else h = ((rn - gn) / d + 4) / 6;
|
|
}
|
|
|
|
// Compute light variant: +16% lightness
|
|
const lightL = Math.min(l + 0.16, 0.95);
|
|
// Compute neon variant: high lightness + boosted saturation
|
|
const neonL = Math.min(l + 0.30, 0.95);
|
|
const neonS = Math.min(s + 0.1, 1.0);
|
|
|
|
function hslToRgb(h, s, l) {
|
|
if (s === 0) { const v = Math.round(l * 255); return [v, v, v]; }
|
|
const hue2rgb = (p, q, t) => {
|
|
if (t < 0) t += 1; if (t > 1) t -= 1;
|
|
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
|
if (t < 1 / 2) return q;
|
|
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
|
return p;
|
|
};
|
|
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
const p = 2 * l - q;
|
|
return [Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
|
|
Math.round(hue2rgb(p, q, h) * 255),
|
|
Math.round(hue2rgb(p, q, h - 1 / 3) * 255)];
|
|
}
|
|
|
|
const light = hslToRgb(h, s, lightL);
|
|
const neon = hslToRgb(h, neonS, neonL);
|
|
|
|
const root = document.documentElement.style;
|
|
root.setProperty('--accent-rgb', `${r}, ${g}, ${b}`);
|
|
root.setProperty('--accent-light-rgb', `${light[0]}, ${light[1]}, ${light[2]}`);
|
|
root.setProperty('--accent-neon-rgb', `${neon[0]}, ${neon[1]}, ${neon[2]}`);
|
|
|
|
// Store for instant restore on next page load
|
|
localStorage.setItem('soulsync-accent', hex);
|
|
|
|
// Update preview swatch if it exists
|
|
const swatch = document.getElementById('accent-preview-swatch');
|
|
if (swatch) swatch.style.background = hex;
|
|
}
|
|
|
|
function applyParticlesSetting(enabled) {
|
|
const canvas = document.getElementById('page-particles-canvas');
|
|
if (canvas) canvas.style.display = enabled ? '' : 'none';
|
|
if (window.pageParticles) {
|
|
if (enabled) {
|
|
const activePage = document.querySelector('.page.active');
|
|
if (activePage) {
|
|
window.pageParticles.setPage(activePage.id.replace('-page', ''));
|
|
}
|
|
} else {
|
|
window.pageParticles.stop();
|
|
}
|
|
}
|
|
window._particlesEnabled = enabled;
|
|
localStorage.setItem('soulsync-particles', String(enabled));
|
|
}
|
|
|
|
function applyWorkerOrbsSetting(enabled) {
|
|
window._workerOrbsEnabled = enabled;
|
|
localStorage.setItem('soulsync-worker-orbs', String(enabled));
|
|
if (window.workerOrbs) {
|
|
if (enabled) {
|
|
const activePage = document.querySelector('.page.active');
|
|
if (activePage && activePage.id === 'dashboard-page') {
|
|
window.workerOrbs.setPage('dashboard');
|
|
}
|
|
} else {
|
|
window.workerOrbs.setPage('_disabled');
|
|
}
|
|
}
|
|
}
|
|
|
|
function initAccentColorListeners() {
|
|
const presetSelect = document.getElementById('accent-preset');
|
|
const customGroup = document.getElementById('custom-color-group');
|
|
const customPicker = document.getElementById('accent-custom-color');
|
|
if (!presetSelect) return;
|
|
|
|
presetSelect.addEventListener('change', () => {
|
|
const val = presetSelect.value;
|
|
if (val === 'custom') {
|
|
if (customGroup) customGroup.style.display = '';
|
|
if (customPicker) applyAccentColor(customPicker.value);
|
|
} else {
|
|
if (customGroup) customGroup.style.display = 'none';
|
|
applyAccentColor(val);
|
|
}
|
|
});
|
|
|
|
if (customPicker) {
|
|
customPicker.addEventListener('input', () => {
|
|
applyAccentColor(customPicker.value);
|
|
});
|
|
}
|
|
|
|
// Particles toggle — apply immediately on change
|
|
const particlesCheckbox = document.getElementById('particles-enabled');
|
|
if (particlesCheckbox) {
|
|
particlesCheckbox.addEventListener('change', () => {
|
|
applyParticlesSetting(particlesCheckbox.checked);
|
|
});
|
|
}
|
|
|
|
// Worker orbs toggle — apply immediately on change
|
|
const workerOrbsCheckbox = document.getElementById('worker-orbs-enabled');
|
|
if (workerOrbsCheckbox) {
|
|
workerOrbsCheckbox.addEventListener('change', () => {
|
|
applyWorkerOrbsSetting(workerOrbsCheckbox.checked);
|
|
});
|
|
}
|
|
|
|
// Reduce effects toggle — apply immediately on change
|
|
const reduceEffectsCheckbox = document.getElementById('reduce-effects-enabled');
|
|
if (reduceEffectsCheckbox) {
|
|
reduceEffectsCheckbox.addEventListener('change', () => {
|
|
applyReduceEffects(reduceEffectsCheckbox.checked);
|
|
});
|
|
}
|
|
}
|
|
|
|
function applyReduceEffects(enabled) {
|
|
if (enabled) {
|
|
document.body.classList.add('reduce-effects');
|
|
} else {
|
|
document.body.classList.remove('reduce-effects');
|
|
}
|
|
localStorage.setItem('soulsync-reduce-effects', enabled ? '1' : '0');
|
|
}
|
|
|
|
// Bootstrap accent and reduce-effects from localStorage instantly (prevents flash)
|
|
(function () {
|
|
if (localStorage.getItem('soulsync-reduce-effects') === '1') {
|
|
document.body.classList.add('reduce-effects');
|
|
}
|
|
const saved = localStorage.getItem('soulsync-accent');
|
|
if (saved) applyAccentColor(saved);
|
|
// Bootstrap particles setting from localStorage
|
|
const particlesSaved = localStorage.getItem('soulsync-particles');
|
|
if (particlesSaved === 'false') {
|
|
window._particlesEnabled = false;
|
|
const canvas = document.getElementById('page-particles-canvas');
|
|
if (canvas) canvas.style.display = 'none';
|
|
}
|
|
// Bootstrap worker orbs setting from localStorage
|
|
const workerOrbsSaved = localStorage.getItem('soulsync-worker-orbs');
|
|
if (workerOrbsSaved === 'false') {
|
|
window._workerOrbsEnabled = false;
|
|
}
|
|
})();
|
|
|
|
// ── Profile System ─────────────────────────────────────────────
|
|
let currentProfile = null;
|
|
const PROFILE_CONTEXT_CHANGED_EVENT = 'ss:webui-profile-context-changed';
|
|
|
|
function notifyProfileContextChanged() {
|
|
window.dispatchEvent(new CustomEvent(PROFILE_CONTEXT_CHANGED_EVENT));
|
|
}
|
|
|
|
function setCurrentProfile(profile) {
|
|
currentProfile = profile;
|
|
updateProfileIndicator();
|
|
notifyProfileContextChanged();
|
|
}
|
|
|
|
// Temporary compatibility shim until existing profile rows are migrated to
|
|
// the current page ids.
|
|
const LEGACY_PROFILE_PAGE_ALIASES = {
|
|
downloads: 'search',
|
|
artists: 'search',
|
|
};
|
|
|
|
function normalizeProfilePageId(pageId) {
|
|
return LEGACY_PROFILE_PAGE_ALIASES[pageId] || pageId;
|
|
}
|
|
|
|
function normalizeProfilePageList(pageIds) {
|
|
if (!Array.isArray(pageIds)) return pageIds;
|
|
return pageIds.map(normalizeProfilePageId);
|
|
}
|
|
|
|
function getProfileHomePage() {
|
|
if (!currentProfile) return 'dashboard';
|
|
if (currentProfile.home_page) return normalizeProfilePageId(currentProfile.home_page);
|
|
return currentProfile.is_admin ? 'dashboard' : 'discover';
|
|
}
|
|
|
|
function isPageAllowed(pageId) {
|
|
if (!currentProfile) return true;
|
|
if (currentProfile.id === 1) return true;
|
|
const normalizedPageId = normalizeProfilePageId(pageId);
|
|
if (normalizedPageId === 'help' || normalizedPageId === 'issues') return true;
|
|
if (normalizedPageId === 'settings') return currentProfile.is_admin;
|
|
if (normalizedPageId === 'artist-detail') {
|
|
const ap = normalizeProfilePageList(currentProfile.allowed_pages);
|
|
if (!ap) return true;
|
|
return ap.includes('library') || ap.includes('search');
|
|
}
|
|
const ap = normalizeProfilePageList(currentProfile.allowed_pages);
|
|
if (!ap) return true; // null = all pages
|
|
if (ap.includes(normalizedPageId)) return true;
|
|
return false;
|
|
}
|
|
|
|
function canDownload() {
|
|
if (!currentProfile) return true;
|
|
if (currentProfile.id === 1) return true;
|
|
return currentProfile.can_download !== false && currentProfile.can_download !== 0;
|
|
}
|
|
|
|
function getCurrentProfileContext() {
|
|
if (!currentProfile) return null;
|
|
return {
|
|
profileId: currentProfile.id,
|
|
isAdmin: !!currentProfile.is_admin,
|
|
};
|
|
}
|
|
|
|
function activatePage(pageId, options = {}) {
|
|
const forceReload = options.forceReload === true;
|
|
const pageElement = document.getElementById(`${pageId}-page`);
|
|
const isPageVisible = pageElement ? pageElement.classList.contains('active') : false;
|
|
|
|
if (!forceReload && pageId === currentPage && isPageVisible) return;
|
|
|
|
showLegacyPage(pageId);
|
|
setActivePageChrome(pageId);
|
|
loadPageData(pageId);
|
|
}
|
|
|
|
function renderProfileAvatar(el, profile) {
|
|
// Renders avatar as image (if avatar_url set) or colored initial fallback
|
|
// Preserves existing classes, ensures 'profile-avatar' is present
|
|
if (!el.classList.contains('profile-avatar') && !el.classList.contains('profile-indicator-avatar') && !el.classList.contains('profile-pin-avatar')) {
|
|
el.className = 'profile-avatar';
|
|
}
|
|
el.style.background = profile.avatar_color || '#6366f1';
|
|
el.textContent = '';
|
|
if (profile.avatar_url) {
|
|
const img = document.createElement('img');
|
|
img.src = profile.avatar_url;
|
|
img.alt = profile.name;
|
|
img.className = 'profile-avatar-img';
|
|
img.onerror = () => {
|
|
img.remove();
|
|
el.textContent = profile.name.charAt(0).toUpperCase();
|
|
};
|
|
el.appendChild(img);
|
|
} else {
|
|
el.textContent = profile.name.charAt(0).toUpperCase();
|
|
}
|
|
}
|
|
|
|
async function initProfileSystem() {
|
|
try {
|
|
// Check if a session already has a profile selected
|
|
const currentRes = await fetch('/api/profiles/current');
|
|
const currentData = await currentRes.json();
|
|
if (currentData.success && currentData.profile) {
|
|
setCurrentProfile(currentData.profile);
|
|
|
|
// Check if launch PIN is required
|
|
if (currentData.launch_pin_required) {
|
|
showLaunchPinScreen();
|
|
return false; // Defer app init until PIN verified
|
|
}
|
|
|
|
return true; // Profile already selected, skip picker
|
|
}
|
|
|
|
// Fetch all profiles
|
|
const res = await fetch('/api/profiles');
|
|
const data = await res.json();
|
|
const profiles = data.profiles || [];
|
|
|
|
if (profiles.length === 0) {
|
|
// No profiles yet — auto-select admin profile 1
|
|
await selectProfile(1);
|
|
return true;
|
|
}
|
|
|
|
if (profiles.length === 1) {
|
|
// Only one profile — always auto-select (PIN only matters with multiple profiles)
|
|
await selectProfile(profiles[0].id);
|
|
|
|
// Re-check for launch PIN after auto-select
|
|
const recheck = await fetch('/api/profiles/current');
|
|
const recheckData = await recheck.json();
|
|
if (recheckData.launch_pin_required) {
|
|
showLaunchPinScreen();
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Multiple profiles or PIN required — show picker
|
|
showProfilePicker(profiles);
|
|
return false; // App init deferred until profile selected
|
|
} catch (e) {
|
|
console.error('Profile init error:', e);
|
|
return true; // Fall through to normal init
|
|
}
|
|
}
|
|
|
|
// ── Launch PIN Lock Screen ─────────────────────────────────────────────
|
|
|
|
function showLaunchPinScreen() {
|
|
const overlay = document.getElementById('launch-pin-overlay');
|
|
if (!overlay) return;
|
|
overlay.style.display = 'flex';
|
|
|
|
const input = document.getElementById('launch-pin-input');
|
|
const submit = document.getElementById('launch-pin-submit');
|
|
const error = document.getElementById('launch-pin-error');
|
|
|
|
input.value = '';
|
|
error.style.display = 'none';
|
|
setTimeout(() => input.focus(), 100);
|
|
|
|
const doSubmit = async () => {
|
|
const pin = input.value.trim();
|
|
if (!pin) return;
|
|
|
|
submit.disabled = true;
|
|
submit.textContent = 'Verifying...';
|
|
|
|
try {
|
|
const res = await fetch('/api/profiles/verify-launch-pin', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ pin })
|
|
});
|
|
const data = await res.json();
|
|
|
|
if (data.success) {
|
|
// Server session flag set by verify endpoint — consumed on next /api/profiles/current call
|
|
overlay.style.display = 'none';
|
|
initApp(); // Now safe to load the full app
|
|
} else {
|
|
error.textContent = data.error || 'Invalid PIN';
|
|
error.style.display = 'block';
|
|
input.value = '';
|
|
input.focus();
|
|
// Shake animation
|
|
overlay.querySelector('.launch-pin-container').classList.add('shake');
|
|
setTimeout(() => overlay.querySelector('.launch-pin-container').classList.remove('shake'), 500);
|
|
}
|
|
} catch (e) {
|
|
error.textContent = 'Connection error';
|
|
error.style.display = 'block';
|
|
}
|
|
|
|
submit.disabled = false;
|
|
submit.textContent = 'Unlock';
|
|
};
|
|
|
|
// Remove old listeners to prevent stacking
|
|
const newSubmit = submit.cloneNode(true);
|
|
submit.parentNode.replaceChild(newSubmit, submit);
|
|
newSubmit.addEventListener('click', doSubmit);
|
|
|
|
input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') doSubmit();
|
|
});
|
|
}
|
|
|
|
// ── Security Settings Helpers ──────────────────────────────────────────
|
|
|
|
async function saveSecurityPin() {
|
|
const pin = document.getElementById('security-new-pin').value;
|
|
const confirm = document.getElementById('security-confirm-pin').value;
|
|
const msg = document.getElementById('security-pin-msg');
|
|
|
|
if (!pin || pin.length < 4) {
|
|
msg.textContent = 'PIN must be at least 4 characters';
|
|
msg.style.display = 'block';
|
|
msg.style.color = '#ff5252';
|
|
return;
|
|
}
|
|
if (pin !== confirm) {
|
|
msg.textContent = 'PINs do not match';
|
|
msg.style.display = 'block';
|
|
msg.style.color = '#ff5252';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch('/api/profiles/1/set-pin', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ pin })
|
|
});
|
|
const data = await res.json();
|
|
|
|
if (data.success) {
|
|
msg.textContent = 'PIN saved! You can now enable the lock screen.';
|
|
msg.style.color = '#4caf50';
|
|
msg.style.display = 'block';
|
|
|
|
// Update UI — hide setup, show change, enable toggle
|
|
document.getElementById('security-pin-setup').style.display = 'none';
|
|
document.getElementById('security-change-pin-section').style.display = 'block';
|
|
document.getElementById('security-require-pin').disabled = false;
|
|
|
|
// Clear inputs
|
|
document.getElementById('security-new-pin').value = '';
|
|
document.getElementById('security-confirm-pin').value = '';
|
|
} else {
|
|
msg.textContent = data.error || 'Failed to save PIN';
|
|
msg.style.color = '#ff5252';
|
|
msg.style.display = 'block';
|
|
}
|
|
} catch (e) {
|
|
msg.textContent = 'Connection error';
|
|
msg.style.color = '#ff5252';
|
|
msg.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
function handleSecurityPinToggle(checkbox) {
|
|
// If trying to enable but no PIN, show the setup section
|
|
if (checkbox.checked) {
|
|
const setupSection = document.getElementById('security-pin-setup');
|
|
if (setupSection.style.display !== 'none' || checkbox.disabled) {
|
|
checkbox.checked = false;
|
|
setupSection.style.display = 'block';
|
|
document.getElementById('security-new-pin').focus();
|
|
return;
|
|
}
|
|
}
|
|
// Auto-save this setting
|
|
saveSettings(true);
|
|
}
|
|
|
|
function showChangeSecurityPin() {
|
|
document.getElementById('security-pin-setup').style.display = 'block';
|
|
document.getElementById('security-new-pin').focus();
|
|
}
|
|
|
|
// ── Forgot PIN Recovery ────────────────────────────────────────────────
|
|
|
|
function showForgotPinView() {
|
|
document.getElementById('launch-pin-entry').style.display = 'none';
|
|
document.getElementById('launch-pin-recovery').style.display = 'block';
|
|
document.getElementById('launch-recovery-input').value = '';
|
|
document.getElementById('launch-recovery-error').style.display = 'none';
|
|
setTimeout(() => document.getElementById('launch-recovery-input').focus(), 100);
|
|
}
|
|
|
|
function showPinEntryView() {
|
|
document.getElementById('launch-pin-recovery').style.display = 'none';
|
|
document.getElementById('launch-pin-entry').style.display = 'block';
|
|
setTimeout(() => document.getElementById('launch-pin-input').focus(), 100);
|
|
}
|
|
|
|
async function submitRecoveryCredential() {
|
|
const input = document.getElementById('launch-recovery-input');
|
|
const error = document.getElementById('launch-recovery-error');
|
|
const btn = document.getElementById('launch-recovery-submit');
|
|
const credential = input.value.trim();
|
|
|
|
if (!credential) return;
|
|
|
|
btn.disabled = true;
|
|
btn.textContent = 'Verifying...';
|
|
error.style.display = 'none';
|
|
|
|
try {
|
|
const res = await fetch('/api/profiles/reset-pin-via-credential', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ credential })
|
|
});
|
|
const data = await res.json();
|
|
|
|
if (data.success) {
|
|
sessionStorage.setItem('soulsync_pin_ok', '1');
|
|
document.getElementById('launch-pin-overlay').style.display = 'none';
|
|
initApp();
|
|
setTimeout(() => showToast('PIN cleared. You can set a new one in Settings → Advanced.', 'success'), 1000);
|
|
} else {
|
|
error.textContent = data.error || 'Credential not recognized';
|
|
error.style.display = 'block';
|
|
input.value = '';
|
|
input.focus();
|
|
document.getElementById('launch-pin-container').classList.add('shake');
|
|
setTimeout(() => document.getElementById('launch-pin-container').classList.remove('shake'), 500);
|
|
}
|
|
} catch (e) {
|
|
error.textContent = 'Connection error';
|
|
error.style.display = 'block';
|
|
}
|
|
|
|
btn.disabled = false;
|
|
btn.textContent = 'Verify & Reset PIN';
|
|
}
|
|
|
|
// ── Profile PIN Forgot Recovery ────────────────────────────────────────
|
|
function showProfileForgotPin() {
|
|
const dialog = document.getElementById('profile-pin-dialog');
|
|
const content = dialog.querySelector('.profile-pin-content');
|
|
|
|
// Store the profile ID we're recovering for
|
|
const profileName = document.getElementById('profile-pin-name').textContent;
|
|
|
|
// Replace dialog content with recovery form
|
|
content.dataset.prevHtml = content.innerHTML;
|
|
content.innerHTML = `
|
|
<p style="color:#fff;font-size:14px;font-weight:600;margin-bottom:4px">Reset PIN for ${profileName}</p>
|
|
<p style="color:rgba(255,255,255,0.5);font-size:12px;margin-bottom:12px">Enter any configured API credential<br>(Spotify secret, Plex token, etc.)</p>
|
|
<input type="password" id="profile-recovery-input" class="profile-pin-input" maxlength="200" placeholder="Paste API credential" autocomplete="off">
|
|
<div class="profile-pin-buttons">
|
|
<button id="profile-recovery-cancel" class="profile-pin-cancel">Back</button>
|
|
<button id="profile-recovery-submit" class="profile-pin-submit">Verify & Reset</button>
|
|
</div>
|
|
<p id="profile-recovery-error" class="profile-pin-error" style="display:none"></p>
|
|
`;
|
|
setTimeout(() => document.getElementById('profile-recovery-input').focus(), 100);
|
|
|
|
document.getElementById('profile-recovery-cancel').onclick = () => {
|
|
content.innerHTML = content.dataset.prevHtml;
|
|
};
|
|
|
|
document.getElementById('profile-recovery-submit').onclick = async () => {
|
|
const input = document.getElementById('profile-recovery-input');
|
|
const error = document.getElementById('profile-recovery-error');
|
|
const credential = input.value.trim();
|
|
if (!credential) return;
|
|
|
|
const btn = document.getElementById('profile-recovery-submit');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Verifying...';
|
|
error.style.display = 'none';
|
|
|
|
try {
|
|
const res = await fetch('/api/profiles/reset-pin-via-credential', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ credential, profile_id: dialog._profileId || 1 })
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
dialog.style.display = 'none';
|
|
content.innerHTML = content.dataset.prevHtml;
|
|
showToast('PIN cleared. You can set a new one in Settings.', 'success');
|
|
// Re-try selecting the profile (now PIN-free)
|
|
if (dialog._profileId) selectProfile(dialog._profileId);
|
|
} else {
|
|
error.textContent = data.error || 'Credential not recognized';
|
|
error.style.display = 'block';
|
|
input.value = '';
|
|
input.focus();
|
|
}
|
|
} catch (e) {
|
|
error.textContent = 'Connection error';
|
|
error.style.display = 'block';
|
|
}
|
|
btn.disabled = false;
|
|
btn.textContent = 'Verify & Reset';
|
|
};
|
|
|
|
document.getElementById('profile-recovery-input').onkeydown = (e) => {
|
|
if (e.key === 'Enter') document.getElementById('profile-recovery-submit').click();
|
|
};
|
|
}
|
|
|
|
function showProfilePicker(profiles, canCancel = false) {
|
|
const overlay = document.getElementById('profile-picker-overlay');
|
|
const grid = document.getElementById('profile-picker-grid');
|
|
const actions = document.getElementById('profile-picker-actions');
|
|
|
|
grid.innerHTML = '';
|
|
profiles.forEach(p => {
|
|
const card = document.createElement('div');
|
|
card.className = 'profile-picker-card';
|
|
const avatarEl = document.createElement('div');
|
|
renderProfileAvatar(avatarEl, p);
|
|
card.appendChild(avatarEl);
|
|
const nameEl = document.createElement('span');
|
|
nameEl.className = 'profile-name';
|
|
nameEl.textContent = p.name;
|
|
card.appendChild(nameEl);
|
|
if (p.is_admin) {
|
|
const badge = document.createElement('span');
|
|
badge.className = 'profile-badge';
|
|
badge.textContent = 'Admin';
|
|
card.appendChild(badge);
|
|
}
|
|
card.onclick = () => handleProfileClick(p);
|
|
grid.appendChild(card);
|
|
});
|
|
|
|
// Show actions: admin sees "Manage Profiles", non-admin sees "My Profile" (when they have a profile selected)
|
|
const isAdmin = currentProfile ? currentProfile.is_admin : false;
|
|
const manageBtn = document.getElementById('manage-profiles-btn');
|
|
if (isAdmin) {
|
|
actions.style.display = '';
|
|
if (manageBtn) {
|
|
manageBtn.textContent = 'Manage Profiles';
|
|
// Reset onclick to admin handler (initProfileManagement sets this, but re-affirm here)
|
|
manageBtn.onclick = () => {
|
|
document.getElementById('profile-manage-panel').style.display = 'flex';
|
|
loadProfileManageList();
|
|
};
|
|
}
|
|
} else if (currentProfile && canCancel) {
|
|
// Non-admin with an active profile: show "My Profile" to edit own settings
|
|
actions.style.display = '';
|
|
if (manageBtn) {
|
|
manageBtn.textContent = 'My Profile';
|
|
manageBtn.onclick = () => showSelfEditForm();
|
|
}
|
|
} else {
|
|
actions.style.display = 'none';
|
|
}
|
|
|
|
// Show/remove cancel button when opened from sidebar indicator
|
|
let cancelBtn = overlay.querySelector('.profile-picker-cancel');
|
|
if (cancelBtn) cancelBtn.remove();
|
|
if (canCancel) {
|
|
cancelBtn = document.createElement('button');
|
|
cancelBtn.className = 'profile-picker-cancel';
|
|
cancelBtn.textContent = 'Cancel';
|
|
cancelBtn.onclick = () => hideProfilePicker();
|
|
actions.parentElement.appendChild(cancelBtn);
|
|
}
|
|
|
|
overlay.style.display = 'flex';
|
|
document.querySelector('.main-container').style.display = 'none';
|
|
}
|
|
|
|
async function handleProfileClick(profile) {
|
|
// Fetch profile count — PIN only matters with multiple profiles
|
|
let profileCount = 1;
|
|
try {
|
|
const r = await fetch('/api/profiles');
|
|
const d = await r.json();
|
|
profileCount = (d.profiles || []).length;
|
|
} catch (e) { }
|
|
|
|
if (profile.has_pin && profileCount > 1) {
|
|
showPinDialog(profile);
|
|
} else {
|
|
const wasSwitching = !!currentProfile;
|
|
await selectProfile(profile.id);
|
|
if (wasSwitching) {
|
|
window.location.reload();
|
|
return;
|
|
}
|
|
hideProfilePicker();
|
|
initApp();
|
|
}
|
|
}
|
|
|
|
function showPinDialog(profile) {
|
|
const dialog = document.getElementById('profile-pin-dialog');
|
|
const avatar = document.getElementById('profile-pin-avatar');
|
|
const nameEl = document.getElementById('profile-pin-name');
|
|
const input = document.getElementById('profile-pin-input');
|
|
const errorEl = document.getElementById('profile-pin-error');
|
|
|
|
renderProfileAvatar(avatar, profile);
|
|
nameEl.textContent = profile.name;
|
|
input.value = '';
|
|
errorEl.style.display = 'none';
|
|
dialog._profileId = profile.id;
|
|
dialog.style.display = 'flex';
|
|
setTimeout(() => input.focus(), 100);
|
|
|
|
const submit = document.getElementById('profile-pin-submit');
|
|
const cancel = document.getElementById('profile-pin-cancel');
|
|
|
|
const wasSwitching = !!currentProfile;
|
|
const handleSubmit = async () => {
|
|
const pin = input.value;
|
|
if (!pin) return;
|
|
try {
|
|
const res = await fetch('/api/profiles/select', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ profile_id: profile.id, pin })
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
cleanup();
|
|
if (wasSwitching) {
|
|
window.location.reload();
|
|
return;
|
|
}
|
|
dialog.style.display = 'none';
|
|
hideProfilePicker();
|
|
setCurrentProfile(data.profile);
|
|
initApp();
|
|
return;
|
|
} else {
|
|
errorEl.textContent = data.error || 'Invalid PIN';
|
|
errorEl.style.display = '';
|
|
input.value = '';
|
|
input.focus();
|
|
}
|
|
} catch (e) {
|
|
errorEl.textContent = 'Connection error';
|
|
errorEl.style.display = '';
|
|
}
|
|
cleanup();
|
|
};
|
|
|
|
const handleCancel = () => {
|
|
dialog.style.display = 'none';
|
|
cleanup();
|
|
};
|
|
|
|
const handleKeydown = (e) => {
|
|
if (e.key === 'Enter') handleSubmit();
|
|
if (e.key === 'Escape') handleCancel();
|
|
};
|
|
|
|
const cleanup = () => {
|
|
submit.removeEventListener('click', handleSubmit);
|
|
cancel.removeEventListener('click', handleCancel);
|
|
input.removeEventListener('keydown', handleKeydown);
|
|
};
|
|
|
|
submit.addEventListener('click', handleSubmit);
|
|
cancel.addEventListener('click', handleCancel);
|
|
input.addEventListener('keydown', handleKeydown);
|
|
}
|
|
|
|
async function selectProfile(profileId) {
|
|
try {
|
|
const oldProfileId = currentProfile ? currentProfile.id : null;
|
|
const res = await fetch('/api/profiles/select', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ profile_id: profileId })
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
setCurrentProfile(data.profile);
|
|
// Join profile-scoped WebSocket room for watchlist/wishlist count updates
|
|
if (socket && socket.connected) {
|
|
socket.emit('profile:join', { profile_id: profileId, old_profile_id: oldProfileId });
|
|
}
|
|
// Invalidate ListenBrainz cache on profile switch (each profile has their own playlists)
|
|
_invalidateListenBrainzCache();
|
|
}
|
|
return data.success;
|
|
} catch (e) {
|
|
console.error('Error selecting profile:', e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function hideProfilePicker() {
|
|
document.getElementById('profile-picker-overlay').style.display = 'none';
|
|
document.querySelector('.main-container').style.display = 'flex';
|
|
}
|
|
|
|
function updateProfileIndicator() {
|
|
const indicator = document.getElementById('profile-indicator');
|
|
if (!currentProfile || !indicator) return;
|
|
|
|
const avatar = document.getElementById('profile-indicator-avatar');
|
|
const name = document.getElementById('profile-indicator-name');
|
|
|
|
renderProfileAvatar(avatar, currentProfile);
|
|
name.textContent = currentProfile.name;
|
|
indicator.style.display = 'flex';
|
|
|
|
indicator.onclick = async () => {
|
|
const res = await fetch('/api/profiles');
|
|
const data = await res.json();
|
|
if (data.profiles && data.profiles.length > 0) {
|
|
showProfilePicker(data.profiles, true);
|
|
}
|
|
};
|
|
|
|
// Filter sidebar pages based on profile permissions
|
|
document.querySelectorAll('.nav-button[data-page]').forEach(btn => {
|
|
const page = btn.getAttribute('data-page');
|
|
if (page === 'hydrabase') return; // Managed by dev mode toggle
|
|
if (page === 'settings') {
|
|
// Settings always gated by is_admin
|
|
btn.style.display = currentProfile.is_admin ? '' : 'none';
|
|
} else if (page === 'help' || page === 'issues') {
|
|
btn.style.display = ''; // Always visible
|
|
} else if (currentProfile.id === 1) {
|
|
btn.style.display = ''; // Root admin sees all
|
|
} else {
|
|
const ap = currentProfile.allowed_pages;
|
|
btn.style.display = (!ap || ap.includes(page)) ? '' : 'none';
|
|
}
|
|
});
|
|
|
|
// Toggle download capability
|
|
if (canDownload()) {
|
|
document.body.classList.remove('downloads-disabled');
|
|
} else {
|
|
document.body.classList.add('downloads-disabled');
|
|
}
|
|
}
|
|
|
|
// =====================
|
|
// PERSONAL SETTINGS MODAL
|
|
// =====================
|
|
|
|
async function openPersonalSettings() {
|
|
const overlay = document.getElementById('personal-settings-overlay');
|
|
if (!overlay) return;
|
|
overlay.style.display = 'flex';
|
|
|
|
const body = document.getElementById('personal-settings-body');
|
|
body.innerHTML = '<div style="text-align:center;padding:20px;color:rgba(255,255,255,0.4);">Loading...</div>';
|
|
|
|
try {
|
|
// Load all per-profile service data in parallel
|
|
const [lbRes, spotifyRes] = await Promise.all([
|
|
fetch('/api/profiles/me/listenbrainz'),
|
|
fetch('/api/profiles/me/spotify'),
|
|
]);
|
|
const lbData = await lbRes.json();
|
|
const spotifyData = await spotifyRes.json();
|
|
|
|
body.innerHTML = '';
|
|
const isNonAdmin = currentProfile && !currentProfile.is_admin;
|
|
|
|
if (isNonAdmin) {
|
|
// Tabbed layout for non-admin with multiple sections
|
|
const tabs = [
|
|
{ id: 'music', label: 'Music Services' },
|
|
{ id: 'server', label: 'Server' },
|
|
{ id: 'scrobble', label: 'Scrobbling' },
|
|
];
|
|
const tabBar = document.createElement('div');
|
|
tabBar.className = 'ps-tabbar';
|
|
tabs.forEach((t, i) => {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'ps-tab' + (i === 0 ? ' active' : '');
|
|
btn.textContent = t.label;
|
|
btn.onclick = () => {
|
|
tabBar.querySelectorAll('.ps-tab').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
body.querySelectorAll('.ps-tab-content').forEach(c => c.classList.remove('active'));
|
|
const target = document.getElementById(`ps-tab-${t.id}`);
|
|
if (target) target.classList.add('active');
|
|
};
|
|
tabBar.appendChild(btn);
|
|
});
|
|
body.appendChild(tabBar);
|
|
|
|
// Music Services tab
|
|
const musicTab = document.createElement('div');
|
|
musicTab.id = 'ps-tab-music';
|
|
musicTab.className = 'ps-tab-content active';
|
|
renderPersonalSettingsSpotify(musicTab, spotifyData);
|
|
renderPersonalSettingsTidal(musicTab);
|
|
body.appendChild(musicTab);
|
|
|
|
// Server tab
|
|
const serverTab = document.createElement('div');
|
|
serverTab.id = 'ps-tab-server';
|
|
serverTab.className = 'ps-tab-content';
|
|
serverTab.innerHTML = '<div style="text-align:center;padding:20px;color:rgba(255,255,255,0.3);">Loading libraries...</div>';
|
|
body.appendChild(serverTab);
|
|
// Load server libraries async (don't block modal)
|
|
fetch('/api/profiles/me/server-library').then(r => r.json()).then(libData => {
|
|
serverTab.innerHTML = '';
|
|
renderPersonalSettingsServerLibrary(serverTab, libData);
|
|
}).catch(() => {
|
|
serverTab.innerHTML = '';
|
|
renderPersonalSettingsServerLibrary(serverTab, {});
|
|
});
|
|
|
|
// Scrobbling tab
|
|
const scrobbleTab = document.createElement('div');
|
|
scrobbleTab.id = 'ps-tab-scrobble';
|
|
scrobbleTab.className = 'ps-tab-content';
|
|
body.appendChild(scrobbleTab);
|
|
// Render LB into the scrobble tab
|
|
const origBody = body;
|
|
renderPersonalSettingsLB(lbData, scrobbleTab);
|
|
} else {
|
|
// Admin: just ListenBrainz, no tabs
|
|
const content = document.createElement('div');
|
|
content.style.padding = '18px 22px 22px';
|
|
body.appendChild(content);
|
|
renderPersonalSettingsLB(lbData, content);
|
|
}
|
|
} catch (e) {
|
|
body.innerHTML = '<div style="color:#ef4444;padding:16px;">Failed to load settings</div>';
|
|
}
|
|
}
|
|
|
|
function closePersonalSettings() {
|
|
const overlay = document.getElementById('personal-settings-overlay');
|
|
if (overlay) overlay.style.display = 'none';
|
|
}
|
|
|
|
function renderPersonalSettingsSpotify(body, data) {
|
|
const hasCreds = data.has_credentials;
|
|
const clientId = data.client_id || '';
|
|
|
|
let contentHtml;
|
|
if (hasCreds) {
|
|
contentHtml = `
|
|
<div class="ps-connected-info">
|
|
<div class="ps-connected-icon">🟢</div>
|
|
<div class="ps-connected-details">
|
|
<div class="ps-connected-username">Credentials configured</div>
|
|
<div class="ps-connected-server">Client ID: ${escapeHtml(clientId.substring(0, 8))}...</div>
|
|
<div class="ps-connected-source">Personal Spotify app</div>
|
|
</div>
|
|
</div>
|
|
<div class="ps-actions">
|
|
<button class="ps-btn ps-btn-primary" onclick="authenticatePersonalSpotify()">🔐 Authenticate</button>
|
|
<button class="ps-btn ps-btn-danger" onclick="disconnectPersonalSpotify()">Remove</button>
|
|
</div>
|
|
`;
|
|
} else {
|
|
contentHtml = `
|
|
<div class="ps-form-group">
|
|
<label>Client ID</label>
|
|
<input type="text" id="ps-spotify-client-id" placeholder="Your Spotify Client ID">
|
|
</div>
|
|
<div class="ps-form-group">
|
|
<label>Client Secret</label>
|
|
<input type="password" id="ps-spotify-client-secret" placeholder="Your Spotify Client Secret">
|
|
</div>
|
|
<div class="ps-form-group">
|
|
<label>Redirect URI <span style="font-weight:400;color:rgba(255,255,255,0.3)">(optional)</span></label>
|
|
<input type="text" id="ps-spotify-redirect-uri" placeholder="http://127.0.0.1:8888/callback">
|
|
<div class="ps-help-text">
|
|
Create an app at <a href="https://developer.spotify.com/dashboard" target="_blank">developer.spotify.com</a> and add the redirect URI
|
|
</div>
|
|
</div>
|
|
<div id="ps-spotify-result"></div>
|
|
<div class="ps-actions">
|
|
<button class="ps-btn ps-btn-primary" onclick="savePersonalSpotify()">Save Credentials</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
const section = document.createElement('div');
|
|
section.id = 'ps-spotify-section';
|
|
section.innerHTML = `
|
|
<div class="ps-section">
|
|
<div class="ps-section-header">
|
|
<h4 class="ps-section-title">Spotify</h4>
|
|
<span class="ps-connection-badge ${hasCreds ? 'connected' : 'disconnected'}">
|
|
<span class="ps-connection-dot"></span>
|
|
${hasCreds ? 'Configured' : 'Not configured'}
|
|
</span>
|
|
</div>
|
|
<div class="ps-help-text" style="margin-bottom:12px;">
|
|
Connect your own Spotify account to see your playlists instead of the admin's.
|
|
</div>
|
|
${contentHtml}
|
|
</div>
|
|
`;
|
|
|
|
const existing = document.getElementById('ps-spotify-section');
|
|
if (existing) existing.replaceWith(section);
|
|
else body.appendChild(section);
|
|
}
|
|
|
|
async function savePersonalSpotify() {
|
|
const clientId = document.getElementById('ps-spotify-client-id')?.value?.trim();
|
|
const clientSecret = document.getElementById('ps-spotify-client-secret')?.value?.trim();
|
|
const redirectUri = document.getElementById('ps-spotify-redirect-uri')?.value?.trim();
|
|
const resultEl = document.getElementById('ps-spotify-result');
|
|
|
|
if (!clientId || !clientSecret) {
|
|
if (resultEl) resultEl.innerHTML = '<div style="color:#ef4444;font-size:12px;margin-top:8px;">Client ID and Secret are required</div>';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch('/api/profiles/me/spotify', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ client_id: clientId, client_secret: clientSecret, redirect_uri: redirectUri })
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
showToast('Spotify credentials saved', 'success');
|
|
openPersonalSettings(); // Reload to show connected state
|
|
} else {
|
|
if (resultEl) resultEl.innerHTML = `<div style="color:#ef4444;font-size:12px;margin-top:8px;">${data.error || 'Failed to save'}</div>`;
|
|
}
|
|
} catch (e) {
|
|
if (resultEl) resultEl.innerHTML = '<div style="color:#ef4444;font-size:12px;margin-top:8px;">Network error</div>';
|
|
}
|
|
}
|
|
|
|
async function authenticatePersonalSpotify() {
|
|
// Trigger OAuth flow with profile_id in state so callback knows which profile
|
|
window.open('/auth/spotify?profile_id=' + (currentProfile?.id || ''), '_blank');
|
|
}
|
|
|
|
function renderPersonalSettingsTidal(body) {
|
|
const section = document.createElement('div');
|
|
section.id = 'ps-tidal-section';
|
|
section.innerHTML = `
|
|
<div class="ps-section">
|
|
<div class="ps-section-header">
|
|
<h4 class="ps-section-title">Tidal</h4>
|
|
</div>
|
|
<div class="ps-help-text" style="margin-bottom:12px;">
|
|
Connect your own Tidal account to see your playlists. Uses the admin's Tidal app credentials.
|
|
</div>
|
|
<div class="ps-actions">
|
|
<button class="ps-btn ps-btn-primary" onclick="authenticatePersonalTidal()">🔐 Authenticate Tidal</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
const existing = document.getElementById('ps-tidal-section');
|
|
if (existing) existing.replaceWith(section);
|
|
else body.appendChild(section);
|
|
}
|
|
|
|
function authenticatePersonalTidal() {
|
|
window.open('/auth/tidal?profile_id=' + (currentProfile?.id || ''), '_blank');
|
|
}
|
|
|
|
async function renderPersonalSettingsServerLibrary(container, profileData) {
|
|
const section = document.createElement('div');
|
|
section.id = 'ps-server-library-section';
|
|
|
|
// Detect which server is active
|
|
let serverType = 'none';
|
|
let libraries = [];
|
|
let users = [];
|
|
const currentLib = profileData || {};
|
|
|
|
try {
|
|
// Try each server type to find the active one
|
|
const plexRes = await fetch('/api/plex/music-libraries');
|
|
if (plexRes.ok) {
|
|
const plexData = await plexRes.json();
|
|
if (plexData.libraries && plexData.libraries.length > 0) {
|
|
serverType = 'plex';
|
|
libraries = plexData.libraries;
|
|
}
|
|
}
|
|
} catch (e) { }
|
|
|
|
if (serverType === 'none') {
|
|
try {
|
|
const jellyRes = await fetch('/api/jellyfin/music-libraries');
|
|
if (jellyRes.ok) {
|
|
const jellyData = await jellyRes.json();
|
|
if (jellyData.libraries && jellyData.libraries.length > 0) {
|
|
serverType = 'jellyfin';
|
|
libraries = jellyData.libraries;
|
|
users = jellyData.users || [];
|
|
}
|
|
}
|
|
} catch (e) { }
|
|
}
|
|
|
|
if (serverType === 'none') {
|
|
section.innerHTML = `
|
|
<div class="ps-section">
|
|
<div class="ps-section-header">
|
|
<h4 class="ps-section-title">Media Server</h4>
|
|
</div>
|
|
<div class="ps-help-text">No media server connected. Ask your admin to configure Plex, Jellyfin, or Navidrome in Settings.</div>
|
|
</div>
|
|
`;
|
|
} else if (serverType === 'plex') {
|
|
const selectedLib = currentLib.plex_library_id || '';
|
|
const optionsHtml = libraries.map(lib => {
|
|
const name = lib.name || lib.title || lib;
|
|
const val = typeof lib === 'string' ? lib : (lib.name || lib.title);
|
|
return `<option value="${escapeHtml(val)}" ${val === selectedLib ? 'selected' : ''}>${escapeHtml(val)}</option>`;
|
|
}).join('');
|
|
|
|
section.innerHTML = `
|
|
<div class="ps-section">
|
|
<div class="ps-section-header">
|
|
<h4 class="ps-section-title">Plex Library</h4>
|
|
<span class="ps-connection-badge ${selectedLib ? 'connected' : 'disconnected'}">
|
|
<span class="ps-connection-dot"></span>
|
|
${selectedLib ? 'Custom' : 'Default'}
|
|
</span>
|
|
</div>
|
|
<div class="ps-help-text" style="margin-bottom:12px;">Choose which Plex music library your playlists sync to.</div>
|
|
<div class="ps-form-group">
|
|
<label>Music Library</label>
|
|
<select id="ps-plex-library-select">
|
|
<option value="">Use admin default</option>
|
|
${optionsHtml}
|
|
</select>
|
|
</div>
|
|
<div class="ps-actions">
|
|
<button class="ps-btn ps-btn-primary" onclick="savePersonalServerLibrary()">Save</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
} else if (serverType === 'jellyfin') {
|
|
const selectedUser = currentLib.jellyfin_user_id || '';
|
|
const selectedLib = currentLib.jellyfin_library_id || '';
|
|
|
|
const userOpts = users.map(u => {
|
|
const uid = u.id || u.Id;
|
|
const uname = u.name || u.Name;
|
|
return `<option value="${escapeHtml(uid)}" ${uid === selectedUser ? 'selected' : ''}>${escapeHtml(uname)}</option>`;
|
|
}).join('');
|
|
|
|
const libOpts = libraries.map(lib => {
|
|
const lid = lib.key || lib.id || lib.Id;
|
|
const lname = lib.name || lib.Name || lib.title;
|
|
return `<option value="${escapeHtml(lid)}" ${lid === selectedLib ? 'selected' : ''}>${escapeHtml(lname)}</option>`;
|
|
}).join('');
|
|
|
|
section.innerHTML = `
|
|
<div class="ps-section">
|
|
<div class="ps-section-header">
|
|
<h4 class="ps-section-title">Jellyfin</h4>
|
|
<span class="ps-connection-badge ${selectedUser || selectedLib ? 'connected' : 'disconnected'}">
|
|
<span class="ps-connection-dot"></span>
|
|
${selectedUser || selectedLib ? 'Custom' : 'Default'}
|
|
</span>
|
|
</div>
|
|
<div class="ps-help-text" style="margin-bottom:12px;">Choose which Jellyfin user and library your playlists sync to.</div>
|
|
${users.length ? `<div class="ps-form-group"><label>User</label><select id="ps-jellyfin-user-select"><option value="">Use admin default</option>${userOpts}</select></div>` : ''}
|
|
<div class="ps-form-group">
|
|
<label>Music Library</label>
|
|
<select id="ps-jellyfin-library-select">
|
|
<option value="">Use admin default</option>
|
|
${libOpts}
|
|
</select>
|
|
</div>
|
|
<div class="ps-actions">
|
|
<button class="ps-btn ps-btn-primary" onclick="savePersonalServerLibrary()">Save</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
const existing = document.getElementById('ps-server-library-section');
|
|
if (existing) existing.replaceWith(section);
|
|
else container.appendChild(section);
|
|
}
|
|
|
|
async function savePersonalServerLibrary() {
|
|
try {
|
|
const plexSelect = document.getElementById('ps-plex-library-select');
|
|
const jellyUserSelect = document.getElementById('ps-jellyfin-user-select');
|
|
const jellyLibSelect = document.getElementById('ps-jellyfin-library-select');
|
|
|
|
if (plexSelect) {
|
|
await fetch('/api/profiles/me/server-library', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ server_type: 'plex', library_id: plexSelect.value || null })
|
|
});
|
|
}
|
|
if (jellyUserSelect || jellyLibSelect) {
|
|
await fetch('/api/profiles/me/server-library', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
server_type: 'jellyfin',
|
|
user_id: jellyUserSelect?.value || null,
|
|
library_id: jellyLibSelect?.value || null
|
|
})
|
|
});
|
|
}
|
|
|
|
showToast('Server library settings saved', 'success');
|
|
} catch (e) {
|
|
showToast('Error saving settings', 'error');
|
|
}
|
|
}
|
|
|
|
async function disconnectPersonalSpotify() {
|
|
try {
|
|
const res = await fetch('/api/profiles/me/spotify', { method: 'DELETE' });
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
showToast('Spotify credentials removed — using shared config', 'info');
|
|
openPersonalSettings(); // Reload
|
|
}
|
|
} catch (e) {
|
|
showToast('Error removing credentials', 'error');
|
|
}
|
|
}
|
|
|
|
function renderPersonalSettingsLB(data, container) {
|
|
const body = container || document.getElementById('personal-settings-body');
|
|
const connected = data.connected;
|
|
const username = data.username || '';
|
|
const baseUrl = data.base_url || '';
|
|
const source = data.source || 'global';
|
|
|
|
const tokenFormHtml = `
|
|
<div class="ps-form-group">
|
|
<label>User Token</label>
|
|
<input type="password" id="ps-lb-token" placeholder="Paste your ListenBrainz token">
|
|
</div>
|
|
<div class="ps-form-group">
|
|
<label>Server URL <span style="font-weight:400;color:rgba(255,255,255,0.3)">(optional)</span></label>
|
|
<input type="text" id="ps-lb-base-url" placeholder="Leave empty for official (api.listenbrainz.org)">
|
|
<div class="ps-help-text">
|
|
Get your token from <a href="https://listenbrainz.org/profile/" target="_blank">listenbrainz.org/profile</a>
|
|
</div>
|
|
</div>
|
|
<div id="ps-lb-result"></div>
|
|
<div class="ps-actions">
|
|
<button class="ps-btn ps-btn-secondary" onclick="testPersonalListenBrainz()">Test</button>
|
|
<button class="ps-btn ps-btn-primary" onclick="connectPersonalListenBrainz()">Connect</button>
|
|
</div>
|
|
`;
|
|
|
|
let contentHtml;
|
|
if (connected && source === 'profile') {
|
|
// Personal token — show connected state with Disconnect
|
|
const serverDisplay = baseUrl ? baseUrl.replace(/\/1$/, '').replace(/^https?:\/\//, '') : 'api.listenbrainz.org';
|
|
contentHtml = `
|
|
<div class="ps-connected-info">
|
|
<div class="ps-connected-icon">🧠</div>
|
|
<div class="ps-connected-details">
|
|
<div class="ps-connected-username">Connected as ${escapeHtml(username)}</div>
|
|
<div class="ps-connected-server">${escapeHtml(serverDisplay)}</div>
|
|
<div class="ps-connected-source">Personal token</div>
|
|
</div>
|
|
</div>
|
|
<div class="ps-actions">
|
|
<button class="ps-btn ps-btn-danger" onclick="disconnectPersonalListenBrainz()">Disconnect</button>
|
|
</div>
|
|
`;
|
|
} else if (connected && source === 'global') {
|
|
// Using admin's shared token — show status + option to set own token
|
|
const serverDisplay = baseUrl ? baseUrl.replace(/\/1$/, '').replace(/^https?:\/\//, '') : 'api.listenbrainz.org';
|
|
contentHtml = `
|
|
<div class="ps-connected-info">
|
|
<div class="ps-connected-icon">🧠</div>
|
|
<div class="ps-connected-details">
|
|
<div class="ps-connected-username">Connected as ${escapeHtml(username)}</div>
|
|
<div class="ps-connected-server">${escapeHtml(serverDisplay)}</div>
|
|
<div class="ps-connected-source">Using shared token from Settings</div>
|
|
</div>
|
|
</div>
|
|
<div style="margin-top:14px;padding-top:14px;border-top:1px solid rgba(255,255,255,0.06);">
|
|
<div style="font-size:11px;color:rgba(255,255,255,0.45);margin-bottom:10px;">Set your own token to use a different ListenBrainz account:</div>
|
|
${tokenFormHtml}
|
|
</div>
|
|
`;
|
|
} else {
|
|
// Not connected at all
|
|
contentHtml = tokenFormHtml;
|
|
}
|
|
|
|
const section = document.createElement('div');
|
|
section.id = 'ps-listenbrainz-section';
|
|
section.innerHTML = `
|
|
<div class="ps-section">
|
|
<div class="ps-section-header">
|
|
<h4 class="ps-section-title">ListenBrainz</h4>
|
|
<span class="ps-connection-badge ${connected ? 'connected' : 'disconnected'}">
|
|
<span class="ps-connection-dot"></span>
|
|
${connected ? 'Connected' : 'Not connected'}
|
|
</span>
|
|
</div>
|
|
${contentHtml}
|
|
</div>
|
|
`;
|
|
// Replace existing or append
|
|
const existing = document.getElementById('ps-listenbrainz-section');
|
|
if (existing) existing.replaceWith(section);
|
|
else body.appendChild(section);
|
|
}
|
|
|
|
async function testPersonalListenBrainz() {
|
|
const token = document.getElementById('ps-lb-token')?.value?.trim();
|
|
const baseUrl = document.getElementById('ps-lb-base-url')?.value?.trim() || '';
|
|
const resultEl = document.getElementById('ps-lb-result');
|
|
if (!token) {
|
|
if (resultEl) resultEl.innerHTML = '<div class="ps-inline-result error">Please enter a token</div>';
|
|
return;
|
|
}
|
|
if (resultEl) resultEl.innerHTML = '<div class="ps-inline-result" style="color:rgba(255,255,255,0.5);">Testing...</div>';
|
|
try {
|
|
const res = await fetch('/api/profiles/me/listenbrainz/test', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ token, base_url: baseUrl })
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
resultEl.innerHTML = `<div class="ps-inline-result success">Valid token — ${escapeHtml(data.username)}</div>`;
|
|
} else {
|
|
resultEl.innerHTML = `<div class="ps-inline-result error">${escapeHtml(data.error || 'Invalid token')}</div>`;
|
|
}
|
|
} catch (e) {
|
|
resultEl.innerHTML = '<div class="ps-inline-result error">Connection failed</div>';
|
|
}
|
|
}
|
|
|
|
async function connectPersonalListenBrainz() {
|
|
const token = document.getElementById('ps-lb-token')?.value?.trim();
|
|
const baseUrl = document.getElementById('ps-lb-base-url')?.value?.trim() || '';
|
|
const resultEl = document.getElementById('ps-lb-result');
|
|
if (!token) {
|
|
if (resultEl) resultEl.innerHTML = '<div class="ps-inline-result error">Please enter a token</div>';
|
|
return;
|
|
}
|
|
// Disable buttons during connect
|
|
document.querySelectorAll('.ps-actions .ps-btn').forEach(b => b.disabled = true);
|
|
if (resultEl) resultEl.innerHTML = '<div class="ps-inline-result" style="color:rgba(255,255,255,0.5);">Connecting...</div>';
|
|
try {
|
|
const res = await fetch('/api/profiles/me/listenbrainz', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ token, base_url: baseUrl })
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
showToast(`Connected to ListenBrainz as ${data.username}`, 'success');
|
|
// Re-render as connected
|
|
renderPersonalSettingsLB({ connected: true, username: data.username, base_url: baseUrl, source: 'profile' });
|
|
// Refresh LB playlists on discover page
|
|
_invalidateListenBrainzCache();
|
|
if (typeof initializeListenBrainzTabs === 'function') {
|
|
initializeListenBrainzTabs();
|
|
}
|
|
} else {
|
|
resultEl.innerHTML = `<div class="ps-inline-result error">${escapeHtml(data.error || 'Connection failed')}</div>`;
|
|
document.querySelectorAll('.ps-actions .ps-btn').forEach(b => b.disabled = false);
|
|
}
|
|
} catch (e) {
|
|
resultEl.innerHTML = '<div class="ps-inline-result error">Connection failed</div>';
|
|
document.querySelectorAll('.ps-actions .ps-btn').forEach(b => b.disabled = false);
|
|
}
|
|
}
|
|
|
|
async function disconnectPersonalListenBrainz() {
|
|
try {
|
|
await fetch('/api/profiles/me/listenbrainz', { method: 'DELETE' });
|
|
showToast('ListenBrainz disconnected', 'info');
|
|
// Re-render as disconnected — re-fetch to check if global fallback exists
|
|
const res = await fetch('/api/profiles/me/listenbrainz');
|
|
const data = await res.json();
|
|
renderPersonalSettingsLB(data);
|
|
// Refresh LB playlists on discover page
|
|
_invalidateListenBrainzCache();
|
|
if (typeof initializeListenBrainzTabs === 'function') {
|
|
initializeListenBrainzTabs();
|
|
}
|
|
} catch (e) {
|
|
showToast('Failed to disconnect', 'error');
|
|
}
|
|
}
|
|
|
|
function _invalidateListenBrainzCache() {
|
|
if (typeof listenbrainzPlaylistsLoaded !== 'undefined') listenbrainzPlaylistsLoaded = false;
|
|
if (typeof listenbrainzPlaylistsCache !== 'undefined') {
|
|
try { Object.keys(listenbrainzPlaylistsCache).forEach(k => delete listenbrainzPlaylistsCache[k]); } catch (e) { }
|
|
}
|
|
if (typeof listenbrainzTracksCache !== 'undefined') {
|
|
try { Object.keys(listenbrainzTracksCache).forEach(k => delete listenbrainzTracksCache[k]); } catch (e) { }
|
|
}
|
|
}
|
|
|
|
const PROFILE_PAGE_LABELS = {
|
|
dashboard: 'Dashboard',
|
|
sync: 'Sync',
|
|
search: 'Search',
|
|
discover: 'Discover',
|
|
watchlist: 'Watchlist',
|
|
wishlist: 'Wishlist',
|
|
automations: 'Automations',
|
|
'active-downloads': 'Downloads',
|
|
library: 'Library',
|
|
stats: 'Listening Stats',
|
|
'playlist-explorer': 'Playlist Explorer',
|
|
import: 'Import',
|
|
tools: 'Tools',
|
|
hydrabase: 'Hydrabase',
|
|
issues: 'Issues',
|
|
help: 'Help & Docs',
|
|
settings: 'Settings',
|
|
'artist-detail': 'Artist Detail',
|
|
};
|
|
|
|
function getProfilePageLabel(pageId) {
|
|
return PROFILE_PAGE_LABELS[pageId] || pageId.split('-').map(part => part ? part[0].toUpperCase() + part.slice(1) : part).join(' ');
|
|
}
|
|
|
|
function getProfilePageSelectOptions(profileSettings = {}) {
|
|
const options = [];
|
|
const seen = new Set();
|
|
const homeSelect = document.getElementById('new-profile-home-page');
|
|
const normalizedHomePage = normalizeProfilePageId(profileSettings.home_page);
|
|
|
|
if (homeSelect) {
|
|
homeSelect.querySelectorAll('option').forEach(option => {
|
|
if (!option.value || seen.has(option.value)) return;
|
|
options.push({
|
|
value: option.value,
|
|
label: option.textContent?.trim() || getProfilePageLabel(option.value),
|
|
});
|
|
seen.add(option.value);
|
|
});
|
|
}
|
|
|
|
if (normalizedHomePage && !seen.has(normalizedHomePage)) {
|
|
options.push({
|
|
value: normalizedHomePage,
|
|
label: getProfilePageLabel(normalizedHomePage),
|
|
});
|
|
seen.add(normalizedHomePage);
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
function getProfilePageAccessOptions(profileSettings = {}) {
|
|
const options = [];
|
|
const seen = new Set();
|
|
const allowedSet = Array.isArray(profileSettings.allowed_pages)
|
|
? new Set(normalizeProfilePageList(profileSettings.allowed_pages))
|
|
: null;
|
|
const accessContainer = document.getElementById('new-profile-allowed-pages');
|
|
|
|
if (accessContainer) {
|
|
accessContainer.querySelectorAll('input[type="checkbox"]').forEach(cb => {
|
|
if (seen.has(cb.value)) return;
|
|
options.push({
|
|
value: cb.value,
|
|
label: cb.parentElement?.textContent?.trim() || getProfilePageLabel(cb.value),
|
|
checked: cb.disabled ? true : (allowedSet ? allowedSet.has(cb.value) : true),
|
|
disabled: cb.disabled,
|
|
});
|
|
seen.add(cb.value);
|
|
});
|
|
}
|
|
|
|
if (allowedSet) {
|
|
allowedSet.forEach(pageId => {
|
|
if (seen.has(pageId)) return;
|
|
options.push({
|
|
value: pageId,
|
|
label: getProfilePageLabel(pageId),
|
|
checked: true,
|
|
disabled: false,
|
|
});
|
|
seen.add(pageId);
|
|
});
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
function initProfileManagement() {
|
|
const manageBtn = document.getElementById('manage-profiles-btn');
|
|
const closeBtn = document.getElementById('profile-manage-close');
|
|
const createBtn = document.getElementById('create-profile-btn');
|
|
const adminPinBtn = document.getElementById('set-admin-pin-btn');
|
|
|
|
if (manageBtn) {
|
|
manageBtn.onclick = () => {
|
|
document.getElementById('profile-manage-panel').style.display = 'flex';
|
|
loadProfileManageList();
|
|
};
|
|
}
|
|
|
|
if (closeBtn) {
|
|
closeBtn.onclick = () => {
|
|
document.getElementById('profile-manage-panel').style.display = 'none';
|
|
// Refresh picker — keep cancel button if user already has a profile selected
|
|
const hasCancel = !!currentProfile;
|
|
fetch('/api/profiles').then(r => r.json()).then(d => {
|
|
showProfilePicker(d.profiles || [], hasCancel);
|
|
});
|
|
};
|
|
}
|
|
|
|
// Color picker
|
|
let selectedColor = '#6366f1';
|
|
document.querySelectorAll('.profile-color-swatch').forEach(swatch => {
|
|
swatch.onclick = () => {
|
|
document.querySelectorAll('.profile-color-swatch').forEach(s => s.classList.remove('selected'));
|
|
swatch.classList.add('selected');
|
|
selectedColor = swatch.dataset.color;
|
|
};
|
|
});
|
|
// Select first by default
|
|
const firstSwatch = document.querySelector('.profile-color-swatch');
|
|
if (firstSwatch) firstSwatch.classList.add('selected');
|
|
|
|
if (createBtn) {
|
|
createBtn.onclick = async () => {
|
|
const name = document.getElementById('new-profile-name').value.trim();
|
|
const avatarUrl = document.getElementById('new-profile-avatar-url').value.trim();
|
|
const pin = document.getElementById('new-profile-pin').value;
|
|
if (!name) return;
|
|
|
|
// Collect profile settings
|
|
const homePage = document.getElementById('new-profile-home-page').value || null;
|
|
const pageCheckboxes = document.querySelectorAll('#new-profile-allowed-pages input[type="checkbox"]:not(:disabled)');
|
|
const allChecked = Array.from(pageCheckboxes).every(cb => cb.checked);
|
|
const allowedPages = allChecked ? null : Array.from(pageCheckboxes).filter(cb => cb.checked).map(cb => cb.value);
|
|
const canDl = document.getElementById('new-profile-can-download').checked;
|
|
|
|
const res = await fetch('/api/profiles', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
name, avatar_color: selectedColor,
|
|
avatar_url: avatarUrl || undefined,
|
|
pin: pin || undefined,
|
|
home_page: homePage,
|
|
allowed_pages: allowedPages,
|
|
can_download: canDl
|
|
})
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
document.getElementById('new-profile-name').value = '';
|
|
document.getElementById('new-profile-avatar-url').value = '';
|
|
document.getElementById('new-profile-pin').value = '';
|
|
document.getElementById('new-profile-home-page').value = '';
|
|
pageCheckboxes.forEach(cb => cb.checked = true);
|
|
document.getElementById('new-profile-can-download').checked = true;
|
|
loadProfileManageList();
|
|
// Show admin PIN section if >1 profiles and admin has no PIN
|
|
checkAdminPinRequired();
|
|
} else {
|
|
alert(data.error || 'Failed to create profile');
|
|
}
|
|
};
|
|
}
|
|
|
|
if (adminPinBtn) {
|
|
adminPinBtn.onclick = async () => {
|
|
const pin = document.getElementById('admin-pin-input').value;
|
|
if (!pin || pin.length < 1) return;
|
|
// Find admin profile
|
|
const res = await fetch('/api/profiles');
|
|
const data = await res.json();
|
|
const admin = (data.profiles || []).find(p => p.is_admin);
|
|
if (!admin) return;
|
|
|
|
try {
|
|
const pinRes = await fetch(`/api/profiles/${admin.id}/set-pin`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ pin })
|
|
});
|
|
const pinData = await pinRes.json();
|
|
if (!pinData.success) {
|
|
alert(pinData.error || 'Failed to set PIN');
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
alert('Connection error');
|
|
return;
|
|
}
|
|
document.getElementById('admin-pin-input').value = '';
|
|
document.getElementById('admin-pin-section').style.display = 'none';
|
|
loadProfileManageList();
|
|
};
|
|
}
|
|
}
|
|
|
|
async function loadProfileManageList() {
|
|
const list = document.getElementById('profile-manage-list');
|
|
const res = await fetch('/api/profiles');
|
|
const data = await res.json();
|
|
const profiles = data.profiles || [];
|
|
|
|
list.innerHTML = '';
|
|
profiles.forEach(p => {
|
|
const item = document.createElement('div');
|
|
item.className = 'profile-manage-item';
|
|
|
|
const av = document.createElement('div');
|
|
renderProfileAvatar(av, p);
|
|
item.appendChild(av);
|
|
|
|
const info = document.createElement('div');
|
|
info.className = 'profile-info';
|
|
const nameDiv = document.createElement('div');
|
|
nameDiv.className = 'name';
|
|
nameDiv.textContent = p.name + (p.has_pin ? ' 🔒' : '');
|
|
info.appendChild(nameDiv);
|
|
const roleTags = [];
|
|
if (p.is_admin) roleTags.push('Admin');
|
|
if (p.can_download === false) roleTags.push('No Downloads');
|
|
if (p.allowed_pages) roleTags.push(`${p.allowed_pages.length} pages`);
|
|
if (roleTags.length) {
|
|
const roleDiv = document.createElement('div');
|
|
roleDiv.className = 'role';
|
|
roleDiv.textContent = roleTags.join(' · ');
|
|
info.appendChild(roleDiv);
|
|
}
|
|
item.appendChild(info);
|
|
|
|
const actions = document.createElement('div');
|
|
actions.className = 'profile-manage-actions';
|
|
|
|
const editBtn = document.createElement('button');
|
|
editBtn.className = 'profile-edit-btn';
|
|
editBtn.dataset.id = p.id;
|
|
editBtn.dataset.name = p.name;
|
|
editBtn.dataset.color = p.avatar_color || '#6366f1';
|
|
editBtn.dataset.avatarUrl = p.avatar_url || '';
|
|
editBtn.dataset.homePage = p.home_page || '';
|
|
editBtn.dataset.allowedPages = p.allowed_pages ? JSON.stringify(p.allowed_pages) : '';
|
|
editBtn.dataset.canDownload = p.can_download !== false ? '1' : '0';
|
|
editBtn.dataset.isAdmin = p.is_admin ? '1' : '0';
|
|
editBtn.title = 'Edit profile';
|
|
editBtn.textContent = '✏️';
|
|
actions.appendChild(editBtn);
|
|
|
|
if (!p.is_admin) {
|
|
const delBtn = document.createElement('button');
|
|
delBtn.className = 'profile-delete-btn';
|
|
delBtn.dataset.id = p.id;
|
|
delBtn.title = 'Delete profile';
|
|
delBtn.textContent = '🗑️';
|
|
actions.appendChild(delBtn);
|
|
}
|
|
|
|
item.appendChild(actions);
|
|
list.appendChild(item);
|
|
});
|
|
|
|
// Bind edit buttons
|
|
list.querySelectorAll('.profile-edit-btn').forEach(btn => {
|
|
btn.onclick = () => {
|
|
showProfileEditForm(btn.dataset.id, btn.dataset.name, btn.dataset.color, btn.dataset.avatarUrl, {
|
|
home_page: btn.dataset.homePage || '',
|
|
allowed_pages: btn.dataset.allowedPages ? JSON.parse(btn.dataset.allowedPages) : null,
|
|
can_download: btn.dataset.canDownload !== '0',
|
|
is_admin: btn.dataset.isAdmin === '1'
|
|
});
|
|
};
|
|
});
|
|
|
|
// Bind delete buttons
|
|
list.querySelectorAll('.profile-delete-btn').forEach(btn => {
|
|
btn.onclick = async () => {
|
|
if (!await showConfirmDialog({ title: 'Delete Profile', message: 'Delete this profile and all its data?', confirmText: 'Delete', destructive: true })) return;
|
|
try {
|
|
const res = await fetch(`/api/profiles/${btn.dataset.id}`, { method: 'DELETE' });
|
|
const data = await res.json();
|
|
if (!data.success) {
|
|
alert(data.error || 'Failed to delete profile');
|
|
}
|
|
} catch (e) {
|
|
alert('Connection error');
|
|
}
|
|
loadProfileManageList();
|
|
};
|
|
});
|
|
|
|
checkAdminPinRequired();
|
|
}
|
|
|
|
function showProfileEditForm(profileId, currentName, currentColor, currentAvatarUrl, profileSettings = {}) {
|
|
const list = document.getElementById('profile-manage-list');
|
|
// Remove any existing edit form
|
|
const existing = document.getElementById('profile-edit-form');
|
|
if (existing) existing.remove();
|
|
|
|
const isAdmin = currentProfile && currentProfile.is_admin;
|
|
const isEditingAdmin = profileSettings.is_admin;
|
|
const editColors = ['#6366f1', '#ec4899', '#10b981', '#f59e0b', '#3b82f6', '#ef4444', '#8b5cf6', '#14b8a6'];
|
|
const pageSelectOptions = getProfilePageSelectOptions(profileSettings);
|
|
const pageAccessOptions = getProfilePageAccessOptions(profileSettings);
|
|
|
|
const form = document.createElement('div');
|
|
form.id = 'profile-edit-form';
|
|
form.className = 'profile-edit-form';
|
|
|
|
const nameInput = document.createElement('input');
|
|
nameInput.type = 'text';
|
|
nameInput.className = 'profile-input';
|
|
nameInput.value = currentName;
|
|
nameInput.maxLength = 20;
|
|
nameInput.placeholder = 'Profile name';
|
|
form.appendChild(nameInput);
|
|
|
|
const urlInput = document.createElement('input');
|
|
urlInput.type = 'url';
|
|
urlInput.className = 'profile-input';
|
|
urlInput.value = currentAvatarUrl || '';
|
|
urlInput.placeholder = 'Avatar image URL (optional)';
|
|
form.appendChild(urlInput);
|
|
|
|
const colorRow = document.createElement('div');
|
|
colorRow.className = 'profile-color-picker';
|
|
let editColor = currentColor;
|
|
editColors.forEach(c => {
|
|
const swatch = document.createElement('span');
|
|
swatch.className = 'profile-color-swatch' + (c === currentColor ? ' selected' : '');
|
|
swatch.style.background = c;
|
|
swatch.dataset.color = c;
|
|
swatch.onclick = () => {
|
|
colorRow.querySelectorAll('.profile-color-swatch').forEach(s => s.classList.remove('selected'));
|
|
swatch.classList.add('selected');
|
|
editColor = c;
|
|
};
|
|
colorRow.appendChild(swatch);
|
|
});
|
|
form.appendChild(colorRow);
|
|
|
|
// Home page selector — visible to everyone (self-edit or admin editing others)
|
|
const homeLabel = document.createElement('label');
|
|
homeLabel.className = 'profile-settings-label';
|
|
homeLabel.textContent = 'Home Page';
|
|
form.appendChild(homeLabel);
|
|
|
|
const homeSelect = document.createElement('select');
|
|
homeSelect.className = 'profile-input';
|
|
const defaultOpt = document.createElement('option');
|
|
defaultOpt.value = '';
|
|
defaultOpt.textContent = isEditingAdmin ? 'Default (Dashboard)' : 'Default (Discover)';
|
|
homeSelect.appendChild(defaultOpt);
|
|
const normalizedHome = profileSettings.home_page;
|
|
pageSelectOptions.forEach(({ value, label }) => {
|
|
const opt = document.createElement('option');
|
|
opt.value = value;
|
|
opt.textContent = label;
|
|
if (value === normalizedHome) opt.selected = true;
|
|
homeSelect.appendChild(opt);
|
|
});
|
|
form.appendChild(homeSelect);
|
|
|
|
// Admin-only settings: allowed pages & can_download
|
|
let pageCheckboxes = [];
|
|
let canDlCheckbox = null;
|
|
if (isAdmin && !isEditingAdmin) {
|
|
const apLabel = document.createElement('label');
|
|
apLabel.className = 'profile-settings-label';
|
|
apLabel.textContent = 'Page Access';
|
|
form.appendChild(apLabel);
|
|
|
|
const apContainer = document.createElement('div');
|
|
apContainer.className = 'profile-page-checkboxes';
|
|
pageAccessOptions.forEach(({ value, label, checked, disabled }) => {
|
|
const lbl = document.createElement('label');
|
|
const cb = document.createElement('input');
|
|
cb.type = 'checkbox';
|
|
cb.value = value;
|
|
cb.checked = checked;
|
|
cb.disabled = disabled;
|
|
lbl.appendChild(cb);
|
|
lbl.appendChild(document.createTextNode(' ' + label));
|
|
apContainer.appendChild(lbl);
|
|
pageCheckboxes.push(cb);
|
|
});
|
|
form.appendChild(apContainer);
|
|
|
|
const dlLabel = document.createElement('label');
|
|
dlLabel.className = 'profile-checkbox-label';
|
|
canDlCheckbox = document.createElement('input');
|
|
canDlCheckbox.type = 'checkbox';
|
|
canDlCheckbox.checked = profileSettings.can_download !== false;
|
|
dlLabel.appendChild(canDlCheckbox);
|
|
dlLabel.appendChild(document.createTextNode(' Can download music'));
|
|
form.appendChild(dlLabel);
|
|
}
|
|
|
|
const btnRow = document.createElement('div');
|
|
btnRow.className = 'profile-edit-buttons';
|
|
|
|
const saveBtn = document.createElement('button');
|
|
saveBtn.className = 'profile-create-btn';
|
|
saveBtn.textContent = 'Save';
|
|
saveBtn.onclick = async () => {
|
|
const newName = nameInput.value.trim();
|
|
if (!newName) { alert('Name cannot be empty'); return; }
|
|
const newAvatarUrl = urlInput.value.trim() || null;
|
|
const payload = { name: newName, avatar_color: editColor, avatar_url: newAvatarUrl };
|
|
|
|
// Home page
|
|
payload.home_page = homeSelect.value || null;
|
|
|
|
// Admin-only fields
|
|
if (isAdmin && !isEditingAdmin && pageCheckboxes.length) {
|
|
const editablePageCheckboxes = pageCheckboxes.filter(cb => !cb.disabled);
|
|
const allChecked = editablePageCheckboxes.every(cb => cb.checked);
|
|
payload.allowed_pages = allChecked ? null : editablePageCheckboxes.filter(cb => cb.checked).map(cb => cb.value);
|
|
payload.can_download = canDlCheckbox ? canDlCheckbox.checked : true;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`/api/profiles/${profileId}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
// Update sidebar indicator if editing current profile
|
|
if (currentProfile && currentProfile.id == profileId) {
|
|
currentProfile.name = newName;
|
|
currentProfile.avatar_color = editColor;
|
|
currentProfile.avatar_url = newAvatarUrl;
|
|
if (payload.home_page !== undefined) currentProfile.home_page = payload.home_page;
|
|
if (payload.allowed_pages !== undefined) currentProfile.allowed_pages = payload.allowed_pages;
|
|
if (payload.can_download !== undefined) currentProfile.can_download = payload.can_download;
|
|
updateProfileIndicator();
|
|
notifyProfileContextChanged();
|
|
}
|
|
loadProfileManageList();
|
|
} else {
|
|
alert(data.error || 'Failed to update profile');
|
|
}
|
|
} catch (e) {
|
|
alert('Connection error');
|
|
}
|
|
};
|
|
btnRow.appendChild(saveBtn);
|
|
|
|
const cancelBtn = document.createElement('button');
|
|
cancelBtn.className = 'profile-picker-cancel';
|
|
cancelBtn.textContent = 'Cancel';
|
|
cancelBtn.onclick = () => form.remove();
|
|
btnRow.appendChild(cancelBtn);
|
|
|
|
form.appendChild(btnRow);
|
|
list.appendChild(form);
|
|
nameInput.focus();
|
|
nameInput.select();
|
|
}
|
|
|
|
function showSelfEditForm() {
|
|
if (!currentProfile) return;
|
|
const overlay = document.getElementById('profile-picker-overlay');
|
|
const container = overlay.querySelector('.profile-picker-container');
|
|
|
|
// Hide the picker grid and show self-edit form
|
|
const grid = document.getElementById('profile-picker-grid');
|
|
const actions = document.getElementById('profile-picker-actions');
|
|
grid.style.display = 'none';
|
|
actions.style.display = 'none';
|
|
|
|
// Remove any existing self-edit form
|
|
const existing = document.getElementById('self-edit-form');
|
|
if (existing) existing.remove();
|
|
|
|
const pageLabels = {
|
|
dashboard: 'Dashboard', sync: 'Sync', search: 'Search', discover: 'Discover',
|
|
automations: 'Automations', library: 'Library', stats: 'Listening Stats',
|
|
'playlist-explorer': 'Playlist Explorer', import: 'Import', help: 'Help & Docs'
|
|
};
|
|
|
|
const form = document.createElement('div');
|
|
form.id = 'self-edit-form';
|
|
form.className = 'profile-edit-form';
|
|
form.style.marginTop = '16px';
|
|
|
|
const title = document.createElement('h3');
|
|
title.textContent = 'My Profile';
|
|
title.style.cssText = 'color: #fff; margin: 0 0 12px; font-size: 18px;';
|
|
form.appendChild(title);
|
|
|
|
// Name
|
|
const nameInput = document.createElement('input');
|
|
nameInput.type = 'text';
|
|
nameInput.className = 'profile-input';
|
|
nameInput.value = currentProfile.name;
|
|
nameInput.maxLength = 20;
|
|
nameInput.placeholder = 'Profile name';
|
|
form.appendChild(nameInput);
|
|
|
|
// Home page
|
|
const homeLabel = document.createElement('label');
|
|
homeLabel.className = 'profile-settings-label';
|
|
homeLabel.textContent = 'Home Page';
|
|
form.appendChild(homeLabel);
|
|
|
|
const homeSelect = document.createElement('select');
|
|
homeSelect.className = 'profile-input';
|
|
const defaultOpt = document.createElement('option');
|
|
defaultOpt.value = '';
|
|
defaultOpt.textContent = 'Default (Discover)';
|
|
homeSelect.appendChild(defaultOpt);
|
|
const normalizedHome = currentProfile.home_page;
|
|
getProfilePageSelectOptions({ home_page: normalizedHome }).forEach(({ value, label }) => {
|
|
const opt = document.createElement('option');
|
|
opt.value = value;
|
|
opt.textContent = label;
|
|
if (value === normalizedHome) opt.selected = true;
|
|
homeSelect.appendChild(opt);
|
|
});
|
|
form.appendChild(homeSelect);
|
|
|
|
// Buttons
|
|
const btnRow = document.createElement('div');
|
|
btnRow.className = 'profile-edit-buttons';
|
|
btnRow.style.marginTop = '12px';
|
|
|
|
const saveBtn = document.createElement('button');
|
|
saveBtn.className = 'profile-create-btn';
|
|
saveBtn.textContent = 'Save';
|
|
saveBtn.onclick = async () => {
|
|
const newName = nameInput.value.trim();
|
|
if (!newName) { alert('Name cannot be empty'); return; }
|
|
try {
|
|
const res = await fetch(`/api/profiles/${currentProfile.id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: newName, home_page: homeSelect.value || null })
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
currentProfile.name = newName;
|
|
currentProfile.home_page = homeSelect.value || null;
|
|
updateProfileIndicator();
|
|
closeSelfEdit();
|
|
hideProfilePicker();
|
|
} else {
|
|
alert(data.error || 'Failed to update');
|
|
}
|
|
} catch (e) {
|
|
alert('Connection error');
|
|
}
|
|
};
|
|
btnRow.appendChild(saveBtn);
|
|
|
|
const cancelBtn = document.createElement('button');
|
|
cancelBtn.className = 'profile-picker-cancel';
|
|
cancelBtn.textContent = 'Cancel';
|
|
cancelBtn.onclick = () => closeSelfEdit();
|
|
btnRow.appendChild(cancelBtn);
|
|
|
|
form.appendChild(btnRow);
|
|
container.appendChild(form);
|
|
|
|
function closeSelfEdit() {
|
|
form.remove();
|
|
grid.style.display = '';
|
|
actions.style.display = '';
|
|
}
|
|
}
|
|
|
|
async function checkAdminPinRequired() {
|
|
const res = await fetch('/api/profiles');
|
|
const data = await res.json();
|
|
const profiles = data.profiles || [];
|
|
const admin = profiles.find(p => p.is_admin);
|
|
const section = document.getElementById('admin-pin-section');
|
|
|
|
if (profiles.length > 1 && admin && !admin.has_pin && section) {
|
|
section.style.display = '';
|
|
} else if (section) {
|
|
section.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// Service worker registration. Runs as soon as the JS parses (doesn't
|
|
// need to wait for DOMContentLoaded). Cache-first image strategy +
|
|
// stale-while-revalidate static shell — see /sw.js for details. Skipped
|
|
// when the API isn't available (older browsers, file:// origin) or when
|
|
// the page is loaded from a non-secure origin (SW requires HTTPS or
|
|
// localhost).
|
|
if ('serviceWorker' in navigator) {
|
|
window.addEventListener('load', () => {
|
|
navigator.serviceWorker.register('/sw.js', { scope: '/' })
|
|
.catch((err) => console.warn('[SW] registration failed:', err));
|
|
});
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', async function () {
|
|
console.log('SoulSync WebUI initializing...');
|
|
|
|
// Check if first-run setup wizard should be shown
|
|
const params = new URLSearchParams(window.location.search);
|
|
const forceSetup = params.get('setup') === '1';
|
|
let showWizard = forceSetup;
|
|
|
|
if (!forceSetup) {
|
|
try {
|
|
const setupResp = await fetch('/api/setup/status');
|
|
const setupData = await setupResp.json();
|
|
if (!setupData.setup_complete) {
|
|
showWizard = true;
|
|
localStorage.removeItem('soulsync_setup_complete');
|
|
}
|
|
} catch (e) {
|
|
console.warn('Setup status check failed, continuing normal init:', e);
|
|
}
|
|
}
|
|
|
|
if (showWizard && typeof openSetupWizard === 'function') {
|
|
window._onSetupWizardComplete = function () {
|
|
_continueAppInit();
|
|
};
|
|
openSetupWizard();
|
|
return; // Defer init until wizard closes
|
|
}
|
|
|
|
_continueAppInit();
|
|
});
|
|
|
|
async function _continueAppInit() {
|
|
// Initialize profile management UI handlers
|
|
initProfileManagement();
|
|
|
|
// Check profiles first — may show picker instead of app
|
|
const profileReady = await initProfileSystem();
|
|
if (!profileReady) {
|
|
console.log('Waiting for profile selection...');
|
|
return; // App init deferred until profile is selected via picker
|
|
}
|
|
|
|
initApp();
|
|
}
|
|
|
|
function initApp() {
|
|
// Initialize components
|
|
initializeNavigation();
|
|
initializeMobileNavigation();
|
|
initializeMediaPlayer();
|
|
initExpandedPlayer();
|
|
initializeSyncPage();
|
|
initializeWatchlist();
|
|
if (typeof initializeSpotifyAuthCompletionListener === 'function') {
|
|
initializeSpotifyAuthCompletionListener();
|
|
}
|
|
|
|
|
|
// Initialize WebSocket connection (falls back to HTTP polling if unavailable)
|
|
initializeWebSocket();
|
|
|
|
// Start global service status polling for sidebar (works on all pages)
|
|
// Initial fetch for immediate data, then setInterval as fallback when WebSocket is disconnected
|
|
fetchAndUpdateServiceStatus();
|
|
setInterval(fetchAndUpdateServiceStatus, 5000); // Every 5 seconds (no-op when WebSocket active)
|
|
|
|
// Check for updates on load and every hour
|
|
checkForUpdates();
|
|
setInterval(checkForUpdates, 3600000);
|
|
|
|
// Refresh key data immediately when user returns to this tab
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (!document.hidden) {
|
|
fetchAndUpdateServiceStatus();
|
|
// Refresh dashboard-specific data if on dashboard
|
|
const dashboardPage = document.getElementById('dashboard-page');
|
|
if (dashboardPage && dashboardPage.classList.contains('active')) {
|
|
fetchAndUpdateSystemStats();
|
|
fetchAndUpdateActivityFeed();
|
|
}
|
|
}
|
|
});
|
|
|
|
// Start always-on download polling (batched, minimal overhead)
|
|
startGlobalDownloadPolling();
|
|
|
|
// Load initial data
|
|
loadInitialData();
|
|
|
|
// Handle window resize to re-check track title scrolling
|
|
window.addEventListener('resize', function () {
|
|
if (currentTrack) {
|
|
const trackTitleElement = document.getElementById('track-title');
|
|
const trackTitle = currentTrack.title || 'Unknown Track';
|
|
setTimeout(() => {
|
|
checkAndEnableScrolling(trackTitleElement, trackTitle);
|
|
}, 100); // Small delay to allow layout to settle
|
|
}
|
|
});
|
|
|
|
console.log('SoulSync WebUI initialized successfully!');
|
|
}
|
|
|
|
// ===============================
|
|
// NAVIGATION SYSTEM
|
|
// ===============================
|
|
|
|
function initializeNavigation() {
|
|
const navButtons = document.querySelectorAll('.nav-button');
|
|
|
|
navButtons.forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
const page = button.getAttribute('data-page');
|
|
navigateToPage(page);
|
|
});
|
|
});
|
|
|
|
}
|
|
|
|
const _DEEPLINK_VALID_PAGES = new Set([
|
|
'dashboard', 'sync', 'search', 'discover', 'automations',
|
|
'library', 'import', 'settings', 'help', 'issues', 'stats', 'watchlist',
|
|
'wishlist', 'active-downloads', 'artist-detail', 'playlist-explorer',
|
|
'hydrabase', 'tools'
|
|
]);
|
|
|
|
function _getPageFromPath() {
|
|
const router = getWebRouter();
|
|
const resolved = router?.resolvePageId?.(window.location.pathname);
|
|
if (resolved) return resolved;
|
|
|
|
const path = window.location.pathname.replace(/^\/+|\/+$/g, '');
|
|
if (!path) return 'dashboard';
|
|
const basePage = path.split('/')[0];
|
|
if (!_DEEPLINK_VALID_PAGES.has(basePage)) return 'dashboard';
|
|
// Context-dependent pages fall back to a sensible parent
|
|
if (basePage === 'artist-detail') return 'library';
|
|
if (basePage === 'playlist-explorer') return 'library';
|
|
return basePage;
|
|
}
|
|
|
|
// ===============================
|
|
// MOBILE NAVIGATION
|
|
// ===============================
|
|
|
|
function initializeMobileNavigation() {
|
|
const hamburgerBtn = document.getElementById('hamburger-btn');
|
|
const sidebar = document.querySelector('.sidebar');
|
|
const overlay = document.getElementById('mobile-overlay');
|
|
|
|
if (!hamburgerBtn || !sidebar || !overlay) return;
|
|
|
|
function openMobileNav() {
|
|
sidebar.classList.add('mobile-open');
|
|
hamburgerBtn.classList.add('active');
|
|
overlay.classList.add('active');
|
|
document.body.classList.add('mobile-nav-open');
|
|
}
|
|
|
|
function closeMobileNav() {
|
|
sidebar.classList.remove('mobile-open');
|
|
hamburgerBtn.classList.remove('active');
|
|
overlay.classList.remove('active');
|
|
document.body.classList.remove('mobile-nav-open');
|
|
}
|
|
|
|
hamburgerBtn.addEventListener('click', () => {
|
|
if (sidebar.classList.contains('mobile-open')) {
|
|
closeMobileNav();
|
|
} else {
|
|
openMobileNav();
|
|
}
|
|
});
|
|
|
|
overlay.addEventListener('click', closeMobileNav);
|
|
|
|
// Close sidebar on nav button click (mobile only)
|
|
document.querySelectorAll('.nav-button').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
if (window.innerWidth <= 768) {
|
|
closeMobileNav();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function initializeWatchlist() {
|
|
// Watchlist button navigates to watchlist page
|
|
const watchlistButton = document.getElementById('watchlist-button');
|
|
if (watchlistButton) {
|
|
watchlistButton.addEventListener('click', () => navigateToPage('watchlist'));
|
|
}
|
|
|
|
// Wishlist button: quick check for active download, otherwise navigate to page
|
|
const wishlistButton = document.getElementById('wishlist-button');
|
|
if (wishlistButton) {
|
|
wishlistButton.addEventListener('click', async () => {
|
|
// Fast path: check if we already know about an active wishlist process
|
|
const clientProcess = activeDownloadProcesses['wishlist'];
|
|
if (clientProcess && clientProcess.modalElement && document.body.contains(clientProcess.modalElement)) {
|
|
clientProcess.modalElement.style.display = 'flex';
|
|
WishlistModalState.setVisible();
|
|
return;
|
|
}
|
|
// Slow path: ask the server (with timeout to prevent button feeling dead)
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 2000);
|
|
const resp = await fetch('/api/active-processes', { signal: controller.signal });
|
|
clearTimeout(timeout);
|
|
if (resp.ok) {
|
|
const data = await resp.json();
|
|
const serverProcess = (data.active_processes || []).find(p => p.playlist_id === 'wishlist');
|
|
if (serverProcess) {
|
|
try {
|
|
WishlistModalState.clearUserClosed();
|
|
await rehydrateModal(serverProcess, true);
|
|
} catch (e) {
|
|
console.debug('Rehydration failed, navigating to page:', e);
|
|
navigateToPage('wishlist');
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Timeout or network error — just navigate
|
|
}
|
|
navigateToPage('wishlist');
|
|
});
|
|
}
|
|
|
|
// Update watchlist count initially
|
|
updateWatchlistButtonCount();
|
|
|
|
// Update count every 10 seconds
|
|
setInterval(updateWatchlistButtonCount, 10000);
|
|
|
|
console.log('Watchlist system initialized');
|
|
}
|
|
|
|
function navigateToPage(pageId, options = {}) {
|
|
navigationEpoch += 1;
|
|
|
|
if (!options.forceReload && pageId === currentPage) return;
|
|
|
|
// Permission guard — redirect to home page if not allowed
|
|
if (!isPageAllowed(pageId)) {
|
|
const home = getProfileHomePage();
|
|
if (home !== currentPage && isPageAllowed(home)) {
|
|
navigateToPage(home, options);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const router = getWebRouter();
|
|
if (router && !options.skipRouteChange) {
|
|
notifyPageWillChange(pageId);
|
|
const route = router.routeManifest?.find((entry) => entry.pageId === pageId);
|
|
if (route?.kind === 'react') {
|
|
showReactHost(pageId);
|
|
setActivePageChrome(pageId);
|
|
}
|
|
return router.navigateToPage(pageId, { replace: options.replace === true });
|
|
}
|
|
|
|
// Fallback path for initial bootstrap or environments without TanStack routing.
|
|
const route = router?.routeManifest?.find((entry) => entry.pageId === pageId);
|
|
notifyPageWillChange(pageId);
|
|
const legacyPageElement = document.getElementById(`${pageId}-page`);
|
|
if (route?.kind === 'react' || !legacyPageElement) {
|
|
showReactHost(pageId);
|
|
setActivePageChrome(pageId);
|
|
} else {
|
|
activatePage(pageId, { forceReload: options.forceReload === true });
|
|
}
|
|
|
|
if (!options.skipPushState) {
|
|
const urlPath = pageId === 'dashboard' ? '/' : '/' + pageId;
|
|
if (window.location.pathname !== urlPath) {
|
|
if (options.replace === true) {
|
|
history.replaceState({ page: pageId }, '', urlPath);
|
|
} else {
|
|
history.pushState({ page: pageId }, '', urlPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
async function loadPageData(pageId) {
|
|
try {
|
|
// Stop any active polling when navigating away
|
|
stopDbStatsPolling();
|
|
stopDbUpdatePolling();
|
|
stopWishlistCountPolling();
|
|
stopLogPolling();
|
|
// Stop watchlist/wishlist page timers when navigating away
|
|
if (watchlistCountdownInterval) { clearInterval(watchlistCountdownInterval); watchlistCountdownInterval = null; }
|
|
if (wishlistCountdownInterval) { clearInterval(wishlistCountdownInterval); wishlistCountdownInterval = null; }
|
|
if (typeof _stopNebulaLivePolling === 'function') _stopNebulaLivePolling();
|
|
if (pageId !== 'sync') {
|
|
cleanupBeatportContent();
|
|
}
|
|
switch (pageId) {
|
|
case 'dashboard':
|
|
await loadDashboardData();
|
|
loadDashboardSyncHistory();
|
|
break;
|
|
case 'sync':
|
|
initializeSyncPage();
|
|
await loadSyncData();
|
|
break;
|
|
case 'search':
|
|
initializeSearch();
|
|
initializeSearchModeToggle();
|
|
initializeFilters();
|
|
break;
|
|
case 'active-downloads':
|
|
loadActiveDownloadsPage();
|
|
break;
|
|
case 'library':
|
|
// Check if we should return to artist detail view instead of list
|
|
if (artistDetailPageState.currentArtistId && artistDetailPageState.currentArtistName) {
|
|
navigateToPage('artist-detail');
|
|
if (!artistDetailPageState.isInitialized) {
|
|
initializeArtistDetailPage();
|
|
loadArtistDetailData(artistDetailPageState.currentArtistId, artistDetailPageState.currentArtistName);
|
|
}
|
|
// Already initialized — DOM content persists, no reload needed
|
|
} else {
|
|
if (!libraryPageState.isInitialized) {
|
|
initializeLibraryPage();
|
|
}
|
|
// Already initialized — DOM content persists, no reload needed
|
|
}
|
|
break;
|
|
case 'artist-detail':
|
|
// Artist detail page is handled separately by navigateToArtistDetail()
|
|
break;
|
|
case 'discover':
|
|
if (!discoverPageInitialized) {
|
|
await loadDiscoverPage();
|
|
discoverPageInitialized = true;
|
|
}
|
|
// Already initialized — DOM content persists, no reload needed
|
|
break;
|
|
case 'playlist-explorer':
|
|
initExplorer();
|
|
break;
|
|
case 'settings':
|
|
initializeSettings();
|
|
switchSettingsTab('connections');
|
|
await loadSettingsData();
|
|
await loadQualityProfile();
|
|
loadApiKeys();
|
|
loadBlacklistCount();
|
|
break;
|
|
case 'stats':
|
|
initializeStatsPage();
|
|
break;
|
|
case 'import':
|
|
initializeImportPage();
|
|
break;
|
|
case 'hydrabase':
|
|
// Check connection status and pre-fill saved credentials
|
|
try {
|
|
const hsResp = await fetch('/api/hydrabase/status');
|
|
const hsData = await hsResp.json();
|
|
_hydrabaseConnected = hsData.connected;
|
|
document.getElementById('hydra-connection-status').textContent = hsData.connected ? 'Connected' : 'Disconnected';
|
|
document.getElementById('hydra-connection-status').style.color = hsData.connected ? 'rgb(var(--accent-light-rgb))' : '#888';
|
|
document.getElementById('hydra-connect-btn').textContent = hsData.connected ? 'Disconnect' : 'Connect';
|
|
// Pre-fill saved credentials
|
|
if (hsData.saved_url) {
|
|
document.getElementById('hydra-ws-url').value = hsData.saved_url;
|
|
}
|
|
if (hsData.saved_api_key) {
|
|
document.getElementById('hydra-api-key').value = hsData.saved_api_key;
|
|
}
|
|
// Update peer count
|
|
if (hsData.peer_count !== null && hsData.peer_count !== undefined) {
|
|
document.getElementById('hydra-peer-count').textContent = `Peers: ${hsData.peer_count}`;
|
|
}
|
|
} catch (e) { }
|
|
// Load comparisons
|
|
loadHydrabaseComparisons();
|
|
break;
|
|
case 'tools':
|
|
await initializeToolsPage();
|
|
break;
|
|
case 'watchlist':
|
|
await initializeWatchlistPage();
|
|
break;
|
|
case 'wishlist':
|
|
await initializeWishlistPage();
|
|
break;
|
|
case 'automations':
|
|
await loadAutomations();
|
|
break;
|
|
case 'help':
|
|
initializeDocsPage();
|
|
break;
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error loading ${pageId} data:`, error);
|
|
showToast(`Failed to load ${pageId} data`, 'error');
|
|
}
|
|
}
|