mirror of https://github.com/Nezreka/SoulSync.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
2632 lines
103 KiB
2632 lines
103 KiB
// 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 errorEl = document.getElementById('profile-pin-error');
|
|
const oldInput = document.getElementById('profile-pin-input');
|
|
const oldSubmit = document.getElementById('profile-pin-submit');
|
|
const oldCancel = document.getElementById('profile-pin-cancel');
|
|
|
|
// Replace controls on every open so stale listeners from a previous
|
|
// profile cannot submit the new PIN against the old profile id.
|
|
const input = oldInput.cloneNode(true);
|
|
const submit = oldSubmit.cloneNode(true);
|
|
const cancel = oldCancel.cloneNode(true);
|
|
oldInput.parentNode.replaceChild(input, oldInput);
|
|
oldSubmit.parentNode.replaceChild(submit, oldSubmit);
|
|
oldCancel.parentNode.replaceChild(cancel, oldCancel);
|
|
|
|
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 wasSwitching = !!currentProfile;
|
|
const handleSubmit = async () => {
|
|
const pin = input.value;
|
|
if (!pin) return;
|
|
submit.disabled = true;
|
|
submit.textContent = 'Verifying...';
|
|
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 = '';
|
|
}
|
|
submit.disabled = false;
|
|
submit.textContent = 'Submit';
|
|
};
|
|
|
|
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() {
|
|
// Sidebar navigation is now driven by native link navigation.
|
|
// Page activation and active-state styling are synchronized from the
|
|
// current URL by the shell bridge and route controllers.
|
|
}
|
|
|
|
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 segs = path.split('/');
|
|
const basePage = segs[0];
|
|
if (!_DEEPLINK_VALID_PAGES.has(basePage)) return 'dashboard';
|
|
// Context-dependent pages fall back to a sensible parent
|
|
if (basePage === 'playlist-explorer') return 'library';
|
|
return basePage;
|
|
}
|
|
|
|
function _normalizeArtistDetailSource(source) {
|
|
const value = (source || '').toString().trim().toLowerCase();
|
|
return value || 'library';
|
|
}
|
|
|
|
function buildArtistDetailPath(artistId, source = null) {
|
|
if (!artistId) {
|
|
throw new Error('artistId is required for artist-detail navigation');
|
|
}
|
|
const normalizedSource = _normalizeArtistDetailSource(source);
|
|
return '/artist-detail/' + encodeURIComponent(normalizedSource) + '/' + encodeURIComponent(String(artistId));
|
|
}
|
|
|
|
function parseArtistDetailPath(pathname = window.location.pathname) {
|
|
const segs = String(pathname || '').split('/').filter(Boolean);
|
|
if (segs[0] !== 'artist-detail' || segs.length < 3) return null;
|
|
|
|
const source = decodeURIComponent(segs[1] || '');
|
|
const artistId = decodeURIComponent(segs.slice(2).join('/'));
|
|
if (!source || !artistId) return null;
|
|
|
|
return {
|
|
artistId,
|
|
source: source.toLowerCase() === 'library' ? null : source,
|
|
};
|
|
}
|
|
|
|
// ===============================
|
|
// 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;
|
|
}
|
|
|
|
if (pageId === 'artist-detail' && !options.artistId) {
|
|
return false;
|
|
}
|
|
|
|
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,
|
|
artistId: options.artistId,
|
|
artistSource: options.artistSource,
|
|
});
|
|
}
|
|
|
|
// 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 === 'artist-detail' && options.artistId) ? buildArtistDetailPath(options.artistId, options.artistSource)
|
|
: '/' + 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', {
|
|
artistId: artistDetailPageState.currentArtistId,
|
|
artistSource: artistDetailPageState.currentArtistSource,
|
|
});
|
|
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 entered through the route handoff and legacy navigator.
|
|
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 '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');
|
|
}
|
|
}
|
|
|
|
// ---- Dashboard cursor-following accent blob (two-layer liquid) ----
|
|
// Both layers lerp toward a target point: the cursor when it's hovering
|
|
// any .dash-card, otherwise the grid center (idle resting position).
|
|
// Core layer (--blob-x/y) follows faster, halo (--blob-x-soft/y-soft)
|
|
// trails. Each card renders both layers and clips them to its own bounds
|
|
// via overflow:hidden, so the blob spans the bento while gaps stay dark.
|
|
// Disabled entirely when body.reduce-effects is set.
|
|
(function initDashboardCursorBlob() {
|
|
let grid = null;
|
|
let cards = [];
|
|
let cardRects = []; // cached rects, refreshed each frame
|
|
let targetX = 0, targetY = 0;
|
|
let coreX = 0, coreY = 0;
|
|
let softX = 0, softY = 0;
|
|
let rafId = 0;
|
|
let attached = false;
|
|
let centeredOnce = false;
|
|
|
|
const RECENTER_DELAY_MS = 1500;
|
|
let recenterTimer = 0;
|
|
|
|
const isReduced = () => document.body.classList.contains('reduce-effects');
|
|
|
|
const gridCenter = () => {
|
|
const r = grid.getBoundingClientRect();
|
|
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
|
};
|
|
|
|
// Two-pass per frame: read all rects first (one layout flush), then
|
|
// write all CSS vars (no further reads). Avoids per-card layout thrash.
|
|
const tick = () => {
|
|
if (isReduced()) { rafId = 0; return; }
|
|
|
|
coreX += (targetX - coreX) * 0.040;
|
|
coreY += (targetY - coreY) * 0.040;
|
|
softX += (targetX - softX) * 0.022;
|
|
softY += (targetY - softY) * 0.022;
|
|
|
|
const n = cards.length;
|
|
if (cardRects.length !== n) cardRects.length = n;
|
|
for (let i = 0; i < n; i++) cardRects[i] = cards[i].getBoundingClientRect();
|
|
for (let i = 0; i < n; i++) {
|
|
const r = cardRects[i];
|
|
const s = cards[i].style;
|
|
s.setProperty('--blob-x', (coreX - r.left) + 'px');
|
|
s.setProperty('--blob-y', (coreY - r.top) + 'px');
|
|
s.setProperty('--blob-x-soft', (softX - r.left) + 'px');
|
|
s.setProperty('--blob-y-soft', (softY - r.top) + 'px');
|
|
}
|
|
|
|
const dx = Math.abs(targetX - softX) + Math.abs(targetX - coreX);
|
|
const dy = Math.abs(targetY - softY) + Math.abs(targetY - coreY);
|
|
if (dx + dy > 0.4) rafId = requestAnimationFrame(tick);
|
|
else rafId = 0;
|
|
};
|
|
|
|
const ensureLoop = () => {
|
|
if (!rafId && !isReduced()) rafId = requestAnimationFrame(tick);
|
|
};
|
|
|
|
const cancelRecenter = () => {
|
|
if (recenterTimer) { clearTimeout(recenterTimer); recenterTimer = 0; }
|
|
};
|
|
const recenterNow = () => {
|
|
recenterTimer = 0;
|
|
if (!grid) return;
|
|
const c = gridCenter();
|
|
targetX = c.x; targetY = c.y;
|
|
ensureLoop();
|
|
};
|
|
const scheduleRecenter = () => {
|
|
if (recenterTimer) return;
|
|
recenterTimer = setTimeout(recenterNow, RECENTER_DELAY_MS);
|
|
};
|
|
|
|
// Snap the blob to grid center the first time the grid becomes
|
|
// measurable (page may not be visible at DOMContentLoaded).
|
|
const snapToCenterIfReady = () => {
|
|
if (!grid || centeredOnce) return;
|
|
const r = grid.getBoundingClientRect();
|
|
if (r.width === 0 || r.height === 0) return; // not visible yet
|
|
const c = { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
|
targetX = coreX = softX = c.x;
|
|
targetY = coreY = softY = c.y;
|
|
centeredOnce = true;
|
|
ensureLoop();
|
|
};
|
|
|
|
function attach() {
|
|
if (attached) return;
|
|
grid = document.querySelector('.dash-grid');
|
|
if (!grid) return;
|
|
attached = true;
|
|
cards = Array.from(grid.querySelectorAll('.dash-card'));
|
|
|
|
snapToCenterIfReady();
|
|
|
|
grid.addEventListener('pointermove', (e) => {
|
|
if (isReduced()) return;
|
|
const onCard = e.target && e.target.closest && e.target.closest('.dash-card');
|
|
if (onCard) {
|
|
cancelRecenter();
|
|
targetX = e.clientX;
|
|
targetY = e.clientY;
|
|
ensureLoop();
|
|
} else {
|
|
scheduleRecenter();
|
|
}
|
|
});
|
|
grid.addEventListener('pointerleave', () => {
|
|
if (!isReduced()) scheduleRecenter();
|
|
});
|
|
window.addEventListener('resize', () => {
|
|
if (isReduced()) return;
|
|
// Idle: snap immediately. Active: respect the existing delay.
|
|
if (!recenterTimer) recenterNow();
|
|
});
|
|
|
|
// Re-resolve cards when active-downloads card toggles visibility.
|
|
const cardObserver = new MutationObserver(() => {
|
|
cards = Array.from(grid.querySelectorAll('.dash-card'));
|
|
ensureLoop();
|
|
});
|
|
cardObserver.observe(grid, { childList: true, subtree: false, attributes: true, attributeFilter: ['style', 'class'] });
|
|
|
|
// If the grid was hidden at attach time, snap once it becomes
|
|
// measurable (page navigation, tab switch).
|
|
if (!centeredOnce && 'IntersectionObserver' in window) {
|
|
const visObserver = new IntersectionObserver((entries) => {
|
|
for (const ent of entries) {
|
|
if (ent.isIntersecting) { snapToCenterIfReady(); break; }
|
|
}
|
|
});
|
|
visObserver.observe(grid);
|
|
}
|
|
|
|
// React to reduce-effects toggle on body class.
|
|
const bodyObserver = new MutationObserver(() => {
|
|
if (isReduced()) {
|
|
cancelRecenter();
|
|
if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
|
|
} else {
|
|
centeredOnce = false;
|
|
snapToCenterIfReady();
|
|
}
|
|
});
|
|
bodyObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] });
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', attach);
|
|
} else {
|
|
attach();
|
|
}
|
|
// Also retry on full load — covers late-mounted markup.
|
|
window.addEventListener('load', () => {
|
|
attach();
|
|
snapToCenterIfReady();
|
|
});
|
|
})();
|