Redesign personal settings modal with tabs and library dropdowns

Non-admin: 3-tab layout (Music Services | Server | Scrobbling).
Admin: just ListenBrainz, no tabs (unchanged).

Server tab auto-detects active server (Plex/Jellyfin) and shows
library name dropdowns instead of raw ID inputs. Modal has max-height
with scroll, tab bar with accent underline indicator.
pull/253/head
Broque Thomas 2 months ago
parent 53477768cb
commit 272b1cd278

@ -1251,21 +1251,71 @@ async function openPersonalSettings() {
const lbData = await lbRes.json();
const spotifyData = await spotifyRes.json();
// Build sections
body.innerHTML = '';
// Spotify + Tidal + server library per-profile only shown for non-admin profiles
if (currentProfile && !currentProfile.is_admin) {
renderPersonalSettingsSpotify(body, spotifyData);
renderPersonalSettingsTidal(body);
try {
const libRes = await fetch('/api/profiles/me/server-library');
const libData = await libRes.json();
renderPersonalSettingsServerLibrary(body, libData);
} catch (e) {
console.debug('Failed to load server library settings:', e);
}
const isNonAdmin = currentProfile && !currentProfile.is_admin;
if (isNonAdmin) {
// Tabbed layout for non-admin with multiple sections
const tabs = [
{ id: 'music', label: 'Music Services' },
{ id: 'server', label: 'Server' },
{ id: 'scrobble', label: 'Scrobbling' },
];
const tabBar = document.createElement('div');
tabBar.className = 'ps-tabbar';
tabs.forEach((t, i) => {
const btn = document.createElement('button');
btn.className = 'ps-tab' + (i === 0 ? ' active' : '');
btn.textContent = t.label;
btn.onclick = () => {
tabBar.querySelectorAll('.ps-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
body.querySelectorAll('.ps-tab-content').forEach(c => c.classList.remove('active'));
const target = document.getElementById(`ps-tab-${t.id}`);
if (target) target.classList.add('active');
};
tabBar.appendChild(btn);
});
body.appendChild(tabBar);
// Music Services tab
const musicTab = document.createElement('div');
musicTab.id = 'ps-tab-music';
musicTab.className = 'ps-tab-content active';
renderPersonalSettingsSpotify(musicTab, spotifyData);
renderPersonalSettingsTidal(musicTab);
body.appendChild(musicTab);
// Server tab
const serverTab = document.createElement('div');
serverTab.id = 'ps-tab-server';
serverTab.className = 'ps-tab-content';
serverTab.innerHTML = '<div style="text-align:center;padding:20px;color:rgba(255,255,255,0.3);">Loading libraries...</div>';
body.appendChild(serverTab);
// Load server libraries async (don't block modal)
fetch('/api/profiles/me/server-library').then(r => r.json()).then(libData => {
serverTab.innerHTML = '';
renderPersonalSettingsServerLibrary(serverTab, libData);
}).catch(() => {
serverTab.innerHTML = '';
renderPersonalSettingsServerLibrary(serverTab, {});
});
// Scrobbling tab
const scrobbleTab = document.createElement('div');
scrobbleTab.id = 'ps-tab-scrobble';
scrobbleTab.className = 'ps-tab-content';
body.appendChild(scrobbleTab);
// Render LB into the scrobble tab
const origBody = body;
renderPersonalSettingsLB(lbData, scrobbleTab);
} else {
// Admin: just ListenBrainz, no tabs
const content = document.createElement('div');
content.style.padding = '18px 22px 22px';
body.appendChild(content);
renderPersonalSettingsLB(lbData, content);
}
renderPersonalSettingsLB(lbData);
} catch (e) {
body.innerHTML = '<div style="color:#ef4444;padding:16px;">Failed to load settings</div>';
}
@ -1402,98 +1452,153 @@ function authenticatePersonalTidal() {
window.open('/auth/tidal?profile_id=' + (currentProfile?.id || ''), '_blank');
}
function renderPersonalSettingsServerLibrary(body, data) {
const plexLib = data.plex_library_id || '';
const jellyfinUser = data.jellyfin_user_id || '';
const jellyfinLib = data.jellyfin_library_id || '';
const navidromeLib = data.navidrome_library_id || '';
const hasAny = plexLib || jellyfinUser || jellyfinLib || navidromeLib;
async function renderPersonalSettingsServerLibrary(container, profileData) {
const section = document.createElement('div');
section.id = 'ps-server-library-section';
section.innerHTML = `
<div class="ps-section">
<div class="ps-section-header">
<h4 class="ps-section-title">Media Server Library</h4>
<span class="ps-connection-badge ${hasAny ? 'connected' : 'disconnected'}">
<span class="ps-connection-dot"></span>
${hasAny ? 'Customized' : 'Using default'}
</span>
</div>
<div class="ps-help-text" style="margin-bottom:12px;">
Choose which library playlists sync to. Leave empty to use the admin's default.
</div>
<div class="ps-form-group">
<label>Plex Library ID</label>
<input type="text" id="ps-plex-library-id" value="${escapeHtml(plexLib)}" placeholder="Leave empty for default">
</div>
<div class="ps-form-group">
<label>Jellyfin User ID</label>
<input type="text" id="ps-jellyfin-user-id" value="${escapeHtml(jellyfinUser)}" placeholder="Leave empty for default">
</div>
<div class="ps-form-group">
<label>Jellyfin Library ID</label>
<input type="text" id="ps-jellyfin-library-id" value="${escapeHtml(jellyfinLib)}" placeholder="Leave empty for default">
// 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>
<div class="ps-form-group">
<label>Navidrome Library ID</label>
<input type="text" id="ps-navidrome-library-id" value="${escapeHtml(navidromeLib)}" placeholder="Leave empty for default">
`;
} 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>
<div id="ps-server-result"></div>
<div class="ps-actions">
<button class="ps-btn ps-btn-primary" onclick="savePersonalServerLibrary()">Save</button>
${hasAny ? '<button class="ps-btn ps-btn-danger" onclick="clearPersonalServerLibrary()">Reset to Default</button>' : ''}
`;
} 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>
</div>
`;
`;
}
const existing = document.getElementById('ps-server-library-section');
if (existing) existing.replaceWith(section);
else body.appendChild(section);
else container.appendChild(section);
}
async function savePersonalServerLibrary() {
const resultEl = document.getElementById('ps-server-result');
try {
// Save each server type that has a value
const plex = document.getElementById('ps-plex-library-id')?.value?.trim();
const jellyfinUser = document.getElementById('ps-jellyfin-user-id')?.value?.trim();
const jellyfinLib = document.getElementById('ps-jellyfin-library-id')?.value?.trim();
const navidrome = document.getElementById('ps-navidrome-library-id')?.value?.trim();
const saves = [];
if (plex !== undefined) saves.push(fetch('/api/profiles/me/server-library', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ server_type: 'plex', library_id: plex || null })
}));
if (jellyfinUser !== undefined || jellyfinLib !== undefined) saves.push(fetch('/api/profiles/me/server-library', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ server_type: 'jellyfin', user_id: jellyfinUser || null, library_id: jellyfinLib || null })
}));
if (navidrome !== undefined) saves.push(fetch('/api/profiles/me/server-library', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ server_type: 'navidrome', library_id: navidrome || null })
}));
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');
await Promise.all(saves);
showToast('Server library settings saved', 'success');
openPersonalSettings();
} catch (e) {
if (resultEl) resultEl.innerHTML = '<div style="color:#ef4444;font-size:12px;margin-top:8px;">Failed to save</div>';
}
}
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
})
});
}
async function clearPersonalServerLibrary() {
try {
await Promise.all([
fetch('/api/profiles/me/server-library', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ server_type: 'plex', library_id: null }) }),
fetch('/api/profiles/me/server-library', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ server_type: 'jellyfin', user_id: null, library_id: null }) }),
fetch('/api/profiles/me/server-library', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ server_type: 'navidrome', library_id: null }) }),
]);
showToast('Server library settings reset to default', 'info');
openPersonalSettings();
showToast('Server library settings saved', 'success');
} catch (e) {
showToast('Error resetting settings', 'error');
showToast('Error saving settings', 'error');
}
}
@ -1510,8 +1615,8 @@ async function disconnectPersonalSpotify() {
}
}
function renderPersonalSettingsLB(data) {
const body = document.getElementById('personal-settings-body');
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 || '';

@ -34001,10 +34001,13 @@ body.downloads-disabled [onclick*="DownloadMissing"]:not([onclick*="close"]) {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
width: 90%;
max-width: 460px;
max-width: 500px;
max-height: 85vh;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
animation: ps-slide-up 0.25s ease;
overflow: hidden;
display: flex;
flex-direction: column;
}
@keyframes ps-slide-up {
from { opacity: 0; transform: translateY(20px) scale(0.97); }
@ -34043,16 +34046,57 @@ body.downloads-disabled [onclick*="DownloadMissing"]:not([onclick*="close"]) {
color: rgba(255, 255, 255, 0.9);
}
.personal-settings-body {
padding: 20px 22px 24px;
padding: 0;
overflow-y: auto;
flex: 1;
}
/* LB Section within Personal Settings */
/* Tab bar within personal settings */
.ps-tabbar {
display: flex;
gap: 2px;
padding: 12px 22px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(0, 0, 0, 0.15);
}
.ps-tab {
padding: 8px 16px 10px;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.4);
font-size: 0.82em;
font-weight: 500;
font-family: inherit;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s;
white-space: nowrap;
}
.ps-tab:hover {
color: rgba(255, 255, 255, 0.7);
}
.ps-tab.active {
color: #fff;
border-bottom-color: var(--accent-color, #1db954);
font-weight: 600;
}
.ps-tab-content {
display: none;
padding: 18px 22px 22px;
}
.ps-tab-content.active {
display: block;
}
/* Section cards within Personal Settings */
.ps-section {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 16px 18px;
margin-bottom: 12px;
}
.ps-section:last-child { margin-bottom: 0; }
.ps-section-header {
display: flex;
align-items: center;

Loading…
Cancel
Save