185 lines
8 KiB
JavaScript
185 lines
8 KiB
JavaScript
/*
|
|
* My Accounts — per-profile self-auth for playlist services.
|
|
*
|
|
* Each profile connects its OWN streaming accounts (their token, the app's
|
|
* shared client). Used for that profile's playlist operations; the global/admin
|
|
* auth keeps running the background app. Spotify is the first service; the others
|
|
* follow the same pattern.
|
|
*
|
|
* Backend: GET /api/profiles/me/connections, the per-service OAuth popups, and
|
|
* POST /api/profiles/me/connections/<service>/disconnect.
|
|
*/
|
|
|
|
// Playlist services shown in My Accounts. `connect` returns the OAuth URL for a
|
|
// given profile id (popup); services are wired in over time.
|
|
const _MA_SERVICES = [
|
|
{
|
|
id: 'spotify', name: 'Spotify', brand: '#1db954',
|
|
logo: 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png',
|
|
connect: (pid) => `/auth/spotify?profile_id=${pid}`,
|
|
},
|
|
{
|
|
id: 'tidal', name: 'Tidal', brand: '#00cfe8',
|
|
logo: 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/tidal.png',
|
|
connect: (pid) => `/auth/tidal?profile_id=${pid}`,
|
|
},
|
|
{
|
|
id: 'listenbrainz', name: 'ListenBrainz', brand: '#eb743b', dark: true,
|
|
logo: 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/listenbrainz.png',
|
|
type: 'token',
|
|
saveUrl: '/api/profiles/me/listenbrainz',
|
|
hint: 'Paste your token from listenbrainz.org/profile',
|
|
},
|
|
];
|
|
|
|
function _maEsc(s) {
|
|
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
|
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
}
|
|
|
|
function _maProfileId() {
|
|
try {
|
|
const ctx = (typeof getCurrentProfileContext === 'function') ? getCurrentProfileContext() : null;
|
|
return ctx ? ctx.profileId : 1;
|
|
} catch (_e) { return 1; }
|
|
}
|
|
|
|
function openMyAccountsModal() {
|
|
let overlay = document.getElementById('my-accounts-overlay');
|
|
if (!overlay) {
|
|
overlay = document.createElement('div');
|
|
overlay.id = 'my-accounts-overlay';
|
|
overlay.className = 'modal-overlay ma-overlay hidden';
|
|
overlay.onclick = (e) => { if (e.target === overlay) closeMyAccountsModal(); };
|
|
overlay.innerHTML = `
|
|
<div class="ma-modal" role="dialog" aria-modal="true" aria-label="My Accounts" tabindex="-1">
|
|
<div class="ma-topbar">
|
|
<div class="ma-topbar-icon"><img src="/static/trans2.png" alt="SoulSync" class="ma-topbar-logo"></div>
|
|
<div class="ma-topbar-titles">
|
|
<h3 class="ma-topbar-title">My Accounts</h3>
|
|
<div class="ma-topbar-sub">Connect your own streaming accounts — used for your playlists, just for you.</div>
|
|
</div>
|
|
<button class="ma-icon-btn" title="Close" onclick="closeMyAccountsModal()">×</button>
|
|
</div>
|
|
<div class="ma-body" id="ma-body"></div>
|
|
</div>`;
|
|
document.body.appendChild(overlay);
|
|
}
|
|
overlay.classList.remove('hidden');
|
|
const modal = overlay.querySelector('.ma-modal');
|
|
if (modal) { modal.classList.remove('ma-in'); void modal.offsetWidth; modal.classList.add('ma-in'); }
|
|
document.addEventListener('keydown', _maOnKeydown);
|
|
_maLoad();
|
|
}
|
|
|
|
function closeMyAccountsModal() {
|
|
const o = document.getElementById('my-accounts-overlay');
|
|
if (o) o.classList.add('hidden');
|
|
document.removeEventListener('keydown', _maOnKeydown);
|
|
}
|
|
|
|
function _maOnKeydown(e) { if (e.key === 'Escape') closeMyAccountsModal(); }
|
|
|
|
async function _maLoad() {
|
|
const body = document.getElementById('ma-body');
|
|
if (body) body.innerHTML = '<div class="ma-empty">Loading…</div>';
|
|
let data = null;
|
|
try {
|
|
data = await (await fetch('/api/profiles/me/connections')).json();
|
|
} catch (e) { /* render disconnected */ }
|
|
_maRender(body, data || { connections: {}, is_admin: false });
|
|
}
|
|
|
|
function _maRender(body, data) {
|
|
const conns = data.connections || {};
|
|
const isAdmin = !!data.is_admin;
|
|
const rows = _MA_SERVICES.map(svc => {
|
|
const c = conns[svc.id] || {};
|
|
const connected = !!c.connected;
|
|
// Admin uses the global app account (set up in Settings) for every
|
|
// service — not a personal connection here.
|
|
const adminNote = isAdmin;
|
|
let action;
|
|
if (adminNote) {
|
|
action = `<span class="ma-note">Managed in Settings (app account)</span>`;
|
|
} else if (connected) {
|
|
action = `
|
|
<span class="ma-account">${_maEsc(c.account || 'Connected')}</span>
|
|
<button class="ma-btn ma-btn--ghost" onclick="disconnectMyAccount('${svc.id}')">Disconnect</button>`;
|
|
} else if (svc.type === 'token') {
|
|
action = `
|
|
<input type="password" class="ma-token-input" id="ma-token-${svc.id}" placeholder="Paste token"
|
|
title="${_maEsc(svc.hint || '')}">
|
|
<button class="ma-btn ma-btn--connect" onclick="saveMyAccountToken('${svc.id}')">Save</button>`;
|
|
} else {
|
|
action = `<button class="ma-btn ma-btn--connect" onclick="connectMyAccount('${svc.id}')">Connect</button>`;
|
|
}
|
|
return `
|
|
<div class="ma-row" style="--ma-brand:${svc.brand}">
|
|
<span class="ma-disc${svc.dark ? ' ma-disc--dark' : ''}"><img class="ma-logo" src="${svc.logo}" alt=""
|
|
onerror="this.style.display='none'"></span>
|
|
<div class="ma-row-info">
|
|
<div class="ma-row-name">${_maEsc(svc.name)}</div>
|
|
<div class="ma-row-status ${connected ? 'is-on' : ''}">${connected ? 'Connected' : (adminNote ? '' : 'Not connected')}</div>
|
|
</div>
|
|
<div class="ma-row-action">${action}</div>
|
|
</div>`;
|
|
}).join('');
|
|
body.innerHTML = rows || '<div class="ma-empty">No services available.</div>';
|
|
}
|
|
|
|
let _maPollTimer = null;
|
|
|
|
function connectMyAccount(serviceId) {
|
|
const svc = _MA_SERVICES.find(s => s.id === serviceId);
|
|
if (!svc) return;
|
|
const pid = _maProfileId();
|
|
const popup = window.open(svc.connect(pid), 'soulsync-connect-' + serviceId,
|
|
'width=560,height=720,menubar=no,toolbar=no');
|
|
// Poll for the popup closing, then refresh status.
|
|
if (_maPollTimer) clearInterval(_maPollTimer);
|
|
_maPollTimer = setInterval(() => {
|
|
if (!popup || popup.closed) {
|
|
clearInterval(_maPollTimer);
|
|
_maPollTimer = null;
|
|
setTimeout(_maLoad, 600); // give the callback a moment to persist
|
|
}
|
|
}, 800);
|
|
}
|
|
|
|
async function saveMyAccountToken(serviceId) {
|
|
const svc = _MA_SERVICES.find(s => s.id === serviceId);
|
|
if (!svc || !svc.saveUrl) return;
|
|
const input = document.getElementById(`ma-token-${serviceId}`);
|
|
const token = (input && input.value || '').trim();
|
|
if (!token) { if (typeof showToast === 'function') showToast('Paste a token first', 'info'); return; }
|
|
try {
|
|
const res = await fetch(svc.saveUrl, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ token }),
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
if (typeof showToast === 'function') showToast(`${svc.name} connected`, 'success');
|
|
_maLoad();
|
|
} else if (typeof showToast === 'function') {
|
|
showToast(data.error || 'Could not connect', 'error');
|
|
}
|
|
} catch (e) {
|
|
if (typeof showToast === 'function') showToast('Could not connect', 'error');
|
|
}
|
|
}
|
|
|
|
async function disconnectMyAccount(serviceId) {
|
|
if (!confirm(`Disconnect your ${serviceId} account from this profile?`)) return;
|
|
try {
|
|
const res = await fetch(`/api/profiles/me/connections/${serviceId}/disconnect`, { method: 'POST' });
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
if (typeof showToast === 'function') showToast('Disconnected', 'success');
|
|
_maLoad();
|
|
} else if (typeof showToast === 'function') {
|
|
showToast(data.error || 'Disconnect failed', 'error');
|
|
}
|
|
} catch (e) { /* no-op */ }
|
|
}
|