The UI that makes opt-in login usable. Off by default → your LAN setup is unchanged (none of this appears unless security.require_login is on). - Login screen overlay (reuses the launch-PIN styling): username + password → /api/auth/login → reload into the app. Shown when /api/profiles/current reports login_required (checked before profile selection). - POST /api/profiles/<id>/set-password (admin, or self) to set/clear a login password, distinct from the PIN. - Settings → Security: "Login password (admin account)" field + a "Require login" toggle (with the anti-lockout note). Wired into the existing settings load/save. - Sign-out button in the profile bar, revealed only in login mode (login_mode flag on /api/profiles/current); soulsyncLogout() → /api/auth/logout → reload. Tests: set-password sets/clears + verifies; /api/profiles/current signals login_required. 20 login/password tests pass; 64 script-split integrity pass. Remaining (small follow-up): a password field in the Manage Profiles edit form so admins can set OTHER profiles' passwords from the UI (the endpoint already exists).
2727 lines
108 KiB
JavaScript
2727 lines
108 KiB
JavaScript
// INITIALIZATION
|
|
// ===============================
|
|
let navigationEpoch = 0;
|
|
let _optimisticNavPageId = null;
|
|
|
|
// Schedule heavy per-page init during browser idle time so navigation paints and
|
|
// the page becomes scrollable first. timeout caps the delay so content still loads
|
|
// promptly. Falls back to a macrotask in browsers without requestIdleCallback.
|
|
function _scheduleHeavyInit(fn) {
|
|
if (typeof requestIdleCallback === 'function') {
|
|
requestIdleCallback(fn, { timeout: 200 });
|
|
} else {
|
|
setTimeout(fn, 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');
|
|
}
|
|
window._reduceEffectsActive = enabled;
|
|
localStorage.setItem('soulsync-reduce-effects', enabled ? '1' : '0');
|
|
|
|
// Reduce Visual Effects is a full performance switch: also halt the canvas
|
|
// animation loops (particles + worker orbs), not just CSS effects.
|
|
const pcanvas = document.getElementById('page-particles-canvas');
|
|
if (enabled) {
|
|
if (window.pageParticles) window.pageParticles.stop();
|
|
if (pcanvas) pcanvas.style.display = 'none';
|
|
if (window.workerOrbs) window.workerOrbs.setPage('_disabled');
|
|
} else {
|
|
// Restore only what the user's own toggles still allow.
|
|
const activePage = document.querySelector('.page.active');
|
|
const activeId = activePage ? activePage.id.replace('-page', '') : null;
|
|
if (window._particlesEnabled !== false) {
|
|
if (pcanvas) pcanvas.style.display = '';
|
|
if (window.pageParticles && activeId) window.pageParticles.setPage(activeId);
|
|
}
|
|
if (window._workerOrbsEnabled !== false && window.workerOrbs && activeId) {
|
|
window.workerOrbs.setPage(activeId);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Bootstrap accent and reduce-effects from localStorage instantly (prevents flash)
|
|
(function () {
|
|
if (localStorage.getItem('soulsync-reduce-effects') === '1') {
|
|
document.body.classList.add('reduce-effects');
|
|
window._reduceEffectsActive = true;
|
|
}
|
|
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();
|
|
// Login mode: show the sign-in screen and defer everything else until
|
|
// the user authenticates.
|
|
if (currentData.login_required) {
|
|
showLoginScreen();
|
|
return false;
|
|
}
|
|
if (currentData.success && currentData.profile) {
|
|
setCurrentProfile(currentData.profile);
|
|
|
|
// Login mode → reveal the Sign out button in the profile bar.
|
|
if (currentData.login_mode) {
|
|
const lb = document.getElementById('logout-btn');
|
|
if (lb) lb.style.display = '';
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// ── Login Screen (username/password mode) ──────────────────────────────
|
|
|
|
function showLoginScreen() {
|
|
const overlay = document.getElementById('login-overlay');
|
|
if (!overlay) return;
|
|
overlay.style.display = 'flex';
|
|
const u = document.getElementById('login-username');
|
|
if (u) setTimeout(() => u.focus(), 50);
|
|
}
|
|
|
|
async function submitLogin() {
|
|
const username = (document.getElementById('login-username')?.value || '').trim();
|
|
const password = document.getElementById('login-password')?.value || '';
|
|
const errEl = document.getElementById('login-error');
|
|
const btn = document.getElementById('login-submit');
|
|
const showErr = (msg) => { if (errEl) { errEl.textContent = msg; errEl.style.display = 'block'; } };
|
|
if (errEl) errEl.style.display = 'none';
|
|
if (!username || !password) { showErr('Enter your username and password'); return; }
|
|
if (btn) { btn.disabled = true; btn.textContent = 'Signing in...'; }
|
|
try {
|
|
const res = await fetch('/api/auth/login', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username, password }),
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
window.location.reload(); // authenticated → reload into the app
|
|
} else {
|
|
showErr(res.status === 429 ? 'Too many attempts — wait a moment.' : (data.error || 'Sign in failed'));
|
|
if (btn) { btn.disabled = false; btn.textContent = 'Sign in'; }
|
|
}
|
|
} catch (e) {
|
|
showErr('Connection error');
|
|
if (btn) { btn.disabled = false; btn.textContent = 'Sign in'; }
|
|
}
|
|
}
|
|
|
|
async function soulsyncLogout() {
|
|
try { await fetch('/api/auth/logout', { method: 'POST' }); } catch (e) { /* reload anyway */ }
|
|
window.location.reload();
|
|
}
|
|
|
|
// ── 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 saveLoginPassword() {
|
|
const input = document.getElementById('security-login-password');
|
|
const msg = document.getElementById('security-login-password-msg');
|
|
const password = input?.value || '';
|
|
const show = (text, ok) => {
|
|
if (!msg) return;
|
|
msg.textContent = text;
|
|
msg.style.color = ok ? '#4caf50' : '#ff5252';
|
|
msg.style.display = 'block';
|
|
};
|
|
if (!password || password.length < 6) { show('Password must be at least 6 characters', false); return; }
|
|
try {
|
|
const res = await fetch('/api/profiles/1/set-password', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ password }),
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) { show('Admin login password saved', true); if (input) input.value = ''; }
|
|
else show(data.error || 'Failed to save password', false);
|
|
} catch (e) { show('Connection error', false); }
|
|
}
|
|
|
|
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';
|
|
|
|
// Service Status quick-switch is admin-only — drop the clickable affordance
|
|
// for non-admins so it doesn't look interactive.
|
|
const statusSection = document.querySelector('.status-section--clickable');
|
|
if (statusSection) statusSection.classList.toggle('status-section--locked', !currentProfile.is_admin);
|
|
|
|
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 {
|
|
body.innerHTML = '';
|
|
const isNonAdmin = currentProfile && !currentProfile.is_admin;
|
|
|
|
// Streaming-account connections now live in the My Accounts modal (the ♫
|
|
// button). Personal Settings keeps only the per-profile server library.
|
|
if (isNonAdmin) {
|
|
const serverTab = document.createElement('div');
|
|
serverTab.style.padding = '18px 22px 22px';
|
|
serverTab.innerHTML = '<div style="text-align:center;padding:20px;color:rgba(255,255,255,0.3);">Loading libraries...</div>';
|
|
body.appendChild(serverTab);
|
|
fetch('/api/profiles/me/server-library').then(r => r.json()).then(libData => {
|
|
serverTab.innerHTML = '';
|
|
renderPersonalSettingsServerLibrary(serverTab, libData);
|
|
}).catch(() => {
|
|
serverTab.innerHTML = '';
|
|
renderPersonalSettingsServerLibrary(serverTab, {});
|
|
});
|
|
} else {
|
|
const content = document.createElement('div');
|
|
content.style.padding = '24px';
|
|
content.innerHTML = '<div style="color:rgba(255,255,255,0.55);font-size:0.9rem;line-height:1.7;">'
|
|
+ 'Your streaming accounts are in <b>My Accounts</b> (the ♫ button next to your profile).<br>'
|
|
+ 'Global service setup lives in <b>Settings</b>.</div>';
|
|
body.appendChild(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 isCurrent = currentProfile && currentProfile.id === p.id;
|
|
if (isCurrent) item.classList.add('is-current');
|
|
|
|
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);
|
|
// Role/status as pills
|
|
const pills = [];
|
|
if (isCurrent) pills.push({ text: 'You', cls: 'profile-role-pill--current' });
|
|
if (p.is_admin) pills.push({ text: 'Admin', cls: 'profile-role-pill--admin' });
|
|
if (p.can_download === false) pills.push({ text: 'No Downloads', cls: '' });
|
|
if (p.allowed_pages) pills.push({ text: `${p.allowed_pages.length} pages`, cls: '' });
|
|
if (pills.length) {
|
|
const roleDiv = document.createElement('div');
|
|
roleDiv.className = 'role';
|
|
pills.forEach(pill => {
|
|
const span = document.createElement('span');
|
|
span.className = ('profile-role-pill ' + pill.cls).trim();
|
|
span.textContent = pill.text;
|
|
roleDiv.appendChild(span);
|
|
});
|
|
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.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>';
|
|
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.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
|
|
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 = 'btn btn--block btn--primary 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 = 'btn btn--block btn--primary 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');
|
|
// Fail-safe (#842): only launch the wizard when the server DEFINITIVELY
|
|
// says setup isn't done. A non-OK response (e.g. 401 while the launch
|
|
// PIN is locked) must NOT trigger the wizard — otherwise a PIN-gated
|
|
// returning user gets the full setup flow every visit.
|
|
if (setupResp.ok) {
|
|
const setupData = await setupResp.json();
|
|
if (setupData.setup_complete === false) {
|
|
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);
|
|
} else if (route?.kind === 'legacy' && pageId !== 'artist-detail') {
|
|
// Show legacy page immediately — don't wait for TanStack Router's async cycle
|
|
showLegacyPage(pageId);
|
|
setActivePageChrome(pageId);
|
|
_optimisticNavPageId = pageId;
|
|
// Defer data loading until the browser is idle, so the page paints AND
|
|
// becomes scrollable before heavy sync init (settings form wiring, etc.)
|
|
// runs. Falls back to a macrotask where requestIdleCallback is missing.
|
|
_scheduleHeavyInit(() => loadPageData(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':
|
|
// Suppress auto-save while the form is being populated, so opening
|
|
// Settings no longer fires a spurious full save (4 POSTs + backend
|
|
// service re-init) on every visit.
|
|
window._suppressSettingsAutoSave = true;
|
|
try {
|
|
initializeSettings();
|
|
switchSettingsTab('connections');
|
|
await loadSettingsData();
|
|
await loadQualityProfile();
|
|
loadApiKeys();
|
|
loadBlacklistCount();
|
|
} finally {
|
|
window._suppressSettingsAutoSave = false;
|
|
}
|
|
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();
|
|
});
|
|
})();
|