Add Memby account management
This commit is contained in:
@@ -138,6 +138,34 @@
|
||||
.component.negative { color:#ffaaa3; background:#3a1d1c; }
|
||||
.recommendation-meta { color:var(--muted); font-size:12px; margin-top:4px; }
|
||||
.reason-text { margin-top:9px; color:#dce3e8; }
|
||||
.account-summary {
|
||||
display:grid; grid-template-columns:repeat(3,minmax(150px,1fr)); gap:12px; margin-bottom:16px;
|
||||
}
|
||||
.account-stat { padding:15px; border:1px solid var(--line); border-radius:10px; background:#10151a; }
|
||||
.account-stat b { display:block; font-size:24px; }
|
||||
.account-stat span { color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.06em; }
|
||||
.account-list { display:grid; gap:12px; }
|
||||
.account-card { border:1px solid var(--line); border-radius:11px; background:#10151a; overflow:hidden; }
|
||||
.account-head { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; padding:17px; }
|
||||
.account-name { display:flex; align-items:center; gap:12px; }
|
||||
.account-avatar { display:grid; place-items:center; width:42px; height:42px; border-radius:50%; background:var(--accent-soft); color:#91df94; font-size:17px; font-weight:800; }
|
||||
.account-name h3 { margin:0; font-size:17px; }
|
||||
.account-name p { margin:2px 0 0; color:var(--muted); font-size:12px; }
|
||||
.account-actions { display:flex; gap:8px; flex-wrap:wrap; justify-content:flex-end; }
|
||||
.account-actions button,.device-actions button { padding:7px 10px; font-size:12px; }
|
||||
.account-body { display:grid; grid-template-columns:minmax(0,1.2fr) minmax(280px,.8fr); border-top:1px solid var(--line); }
|
||||
.account-pane { padding:16px 17px; min-width:0; }
|
||||
.account-pane + .account-pane { border-left:1px solid var(--line); }
|
||||
.account-pane h4 { margin:0 0 10px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.07em; }
|
||||
.device-row { display:grid; grid-template-columns:minmax(150px,1fr) auto; gap:12px; align-items:center; padding:10px 0; border-top:1px solid #202830; }
|
||||
.device-row:first-of-type { border-top:0; padding-top:0; }
|
||||
.device-title { font-weight:650; }
|
||||
.device-meta { color:var(--muted); font-size:12px; margin-top:2px; }
|
||||
.device-actions { display:flex; gap:6px; }
|
||||
.choice-list { display:flex; gap:6px; flex-wrap:wrap; }
|
||||
.choice { padding:4px 8px; border-radius:6px; background:#202830; color:#dce3e8; font-size:12px; }
|
||||
.choice.rating { background:#302b16; color:#f2d575; }
|
||||
.account-note { padding:12px 14px; border:1px solid #2b4830; border-radius:8px; background:rgba(82,181,75,.07); color:#b9c6bc; font-size:13px; margin-bottom:16px; }
|
||||
.feature-hero {
|
||||
display:grid; grid-template-columns:minmax(0,1fr) auto; gap:22px; align-items:center;
|
||||
padding:20px; margin-bottom:16px; border:1px solid #2b4830; border-radius:11px;
|
||||
@@ -176,6 +204,8 @@
|
||||
.event-attrs { grid-column:1 / -1; }
|
||||
.recommendation-controls { grid-template-columns:repeat(2,minmax(150px,1fr)); }
|
||||
.feature-grid { grid-template-columns:1fr; }
|
||||
.account-body { grid-template-columns:1fr; }
|
||||
.account-pane + .account-pane { border-left:0; border-top:1px solid var(--line); }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
input[type=text], input[type=password], input[type=number], input[type=datetime-local],
|
||||
@@ -184,6 +214,9 @@
|
||||
.stats { gap: 18px; }
|
||||
.recommendation-controls { grid-template-columns:1fr; }
|
||||
.feature-hero { grid-template-columns:1fr; }
|
||||
.account-summary { grid-template-columns:1fr; }
|
||||
.account-head,.device-row { grid-template-columns:1fr; display:grid; }
|
||||
.account-actions { justify-content:flex-start; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
@@ -199,6 +232,9 @@
|
||||
</a>
|
||||
<div class="rail-label">Manage</div>
|
||||
<nav class="rail-nav">
|
||||
<a class="rail-link" href="/admin/accounts" data-section="accounts" title="Memby accounts">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M16 19v-1.5A3.5 3.5 0 0 0 12.5 14h-5A3.5 3.5 0 0 0 4 17.5V19M10 10a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm7-2h3m-1.5-1.5v3"/></svg><span>Memby accounts</span>
|
||||
</a>
|
||||
<a class="rail-link" href="/admin/features" data-section="features" title="Features">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h2m4 0h10M14 4v6M6 14v6"/></svg><span>Features</span>
|
||||
</a>
|
||||
@@ -253,6 +289,17 @@
|
||||
|
||||
<div id="error" class="banner"></div>
|
||||
|
||||
<section id="accounts" data-admin-page="accounts">
|
||||
<h2>Memby accounts</h2>
|
||||
<div class="account-note">
|
||||
This is the Memby account list, not the Emby user directory. A person appears here only after
|
||||
signing in to the Memby app. Removing access signs their Memby devices out and does not delete
|
||||
or change their Emby account.
|
||||
</div>
|
||||
<div id="account-summary" class="account-summary"><span class="muted">Loading accounts…</span></div>
|
||||
<div id="account-list" class="account-list"><span class="muted">Loading accounts…</span></div>
|
||||
</section>
|
||||
|
||||
<section id="library" data-admin-page="library">
|
||||
<h2>Library</h2>
|
||||
<div class="stats" id="library-stats"><span class="muted">Loading…</span></div>
|
||||
@@ -487,6 +534,7 @@
|
||||
const railLinks = [...document.querySelectorAll('.rail-link[data-section]')];
|
||||
const currentAdminPage = location.pathname.split('/').filter(Boolean).pop() || 'library';
|
||||
const pageCopy = {
|
||||
accounts: ['Memby accounts', 'See who uses Memby, where they are signed in and how they personalised recommendations.'],
|
||||
library: ['Library', 'Import and inspect the catalogue Memby ranks.'],
|
||||
recommendations: ['Recommendations', 'Pressure-test personalised rows and title scores per user.'],
|
||||
requests: ['Media requests', 'Control who can request missing movies and shows.'],
|
||||
@@ -550,6 +598,72 @@ const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (char) =>
|
||||
'&':'&', '<':'<', '>':'>', '"':'"', "'":''',
|
||||
}[char]));
|
||||
|
||||
function initials(name) {
|
||||
return String(name || '?').trim().split(/\s+/).slice(0, 2).map((part) => part[0] || '').join('').toUpperCase();
|
||||
}
|
||||
|
||||
function recent(value) {
|
||||
if (!value) return false;
|
||||
return Date.now() - new Date(value).getTime() < 15 * 60 * 1000;
|
||||
}
|
||||
|
||||
function renderAccounts(payload) {
|
||||
const accounts = payload.accounts || [];
|
||||
const devices = accounts.flatMap((account) => account.devices || []);
|
||||
const online = devices.filter((device) => recent(device.lastSeen)).length;
|
||||
const personalised = accounts.filter((account) => account.recommendations?.completed).length;
|
||||
document.getElementById('account-summary').innerHTML =
|
||||
'<div class="account-stat"><b>' + number(accounts.length) + '</b><span>Memby accounts</span></div>' +
|
||||
'<div class="account-stat"><b>' + number(devices.length) + '</b><span>signed-in devices · ' + online + ' active now</span></div>' +
|
||||
'<div class="account-stat"><b>' + number(personalised) + '</b><span>recommendation prompts completed</span></div>';
|
||||
|
||||
document.getElementById('account-list').innerHTML = accounts.length ? accounts.map((account) => {
|
||||
const prefs = account.recommendations || {};
|
||||
const ratings = prefs.ratings || [];
|
||||
const dimensions = [
|
||||
['Genres', prefs.genres], ['Studios', prefs.studios], ['Actors', prefs.actors],
|
||||
['Directors', prefs.directors], ['Types', prefs.contentTypes],
|
||||
].filter((entry) => (entry[1] || []).length);
|
||||
const choices = ratings.map((rating) =>
|
||||
'<span class="choice rating" title="' + escapeHtml(rating.itemId) + '">' +
|
||||
escapeHtml(rating.title) + ' · ' + number(rating.rating) + ' ★</span>').join('') +
|
||||
dimensions.flatMap(([label, values]) => (values || []).map((value) =>
|
||||
'<span class="choice">' + escapeHtml(label) + ': ' + escapeHtml(value) + '</span>')).join('');
|
||||
const deviceRows = (account.devices || []).map((device) => {
|
||||
const state = recent(device.lastSeen);
|
||||
return '<div class="device-row"><div><div class="device-title">' +
|
||||
'<span class="pill ' + (state ? 'ok' : 'muted') + '">' + (state ? 'active' : 'idle') + '</span> ' +
|
||||
escapeHtml(device.name || 'Memby TV') + '</div><div class="device-meta">' +
|
||||
escapeHtml(device.version ? 'Memby ' + device.version : 'Legacy Memby client') +
|
||||
(device.protocol ? ' · protocol ' + escapeHtml(device.protocol) : '') +
|
||||
' · last seen ' + escapeHtml(when(device.lastSeen)) +
|
||||
' · signed in ' + escapeHtml(when(device.signedInAt)) + '</div></div>' +
|
||||
'<div class="device-actions"><button class="secondary" data-account-action="rename-device" data-user-id="' +
|
||||
escapeHtml(account.id) + '" data-device-id="' + escapeHtml(device.id) + '" data-device-name="' +
|
||||
escapeHtml(device.name) + '"' + (!device.id ? ' disabled' : '') + '>Rename</button>' +
|
||||
'<button class="secondary" data-account-action="remove-device" data-user-id="' + escapeHtml(account.id) +
|
||||
'" data-device-id="' + escapeHtml(device.id) + '"' + (!device.id ? ' disabled' : '') + '>Sign out</button></div></div>';
|
||||
}).join('');
|
||||
const promptState = prefs.completed
|
||||
? '<span class="pill ok">completed</span>'
|
||||
: '<span class="pill warn">not completed</span>';
|
||||
return '<article class="account-card"><div class="account-head"><div class="account-name">' +
|
||||
'<div class="account-avatar">' + escapeHtml(initials(account.username)) + '</div><div><h3>' +
|
||||
escapeHtml(account.username || 'Unnamed user') + '</h3><p>Memby account · last seen ' +
|
||||
escapeHtml(when(account.lastSeen)) + ' · ' + number((account.devices || []).length) + ' device' +
|
||||
((account.devices || []).length === 1 ? '' : 's') + '</p></div></div>' +
|
||||
'<div class="account-actions">' + (prefs.updated
|
||||
? '<button class="secondary" data-account-action="reset-recommendations" data-user-id="' +
|
||||
escapeHtml(account.id) + '">Clear stored choices</button>' : '') +
|
||||
'<button class="danger" data-account-action="remove-account" data-user-id="' + escapeHtml(account.id) +
|
||||
'" data-username="' + escapeHtml(account.username) + '">Remove Memby access</button></div></div>' +
|
||||
'<div class="account-body"><div class="account-pane"><h4>Signed-in devices</h4>' + deviceRows + '</div>' +
|
||||
'<div class="account-pane"><h4>Recommendation prompt · ' + promptState + '</h4>' +
|
||||
(choices || '<span class="muted">No recommendation selections have been saved.</span>') +
|
||||
'</div></div></article>';
|
||||
}).join('') : '<div class="account-note">No one has signed in to Memby yet. Emby-only users are intentionally not listed here.</div>';
|
||||
}
|
||||
|
||||
let featureRevision = 0;
|
||||
|
||||
function renderFeatures(payload, clients) {
|
||||
@@ -896,14 +1010,18 @@ async function refresh() {
|
||||
const live = document.getElementById('live');
|
||||
const railLive = document.getElementById('rail-live');
|
||||
try {
|
||||
const [status, analytics] = await Promise.all([
|
||||
const [status, analytics, accounts] = await Promise.all([
|
||||
api('/admin/api/status'),
|
||||
currentAdminPage === 'engagement'
|
||||
? api('/admin/api/analytics?days=' + document.getElementById('days').value)
|
||||
: Promise.resolve(null),
|
||||
currentAdminPage === 'accounts'
|
||||
? api('/admin/api/accounts')
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
renderStatus(status);
|
||||
if (analytics) renderAnalytics(analytics);
|
||||
if (accounts) renderAccounts(accounts);
|
||||
const updatedAt = new Date().toLocaleTimeString();
|
||||
live.textContent = 'updated ' + updatedAt;
|
||||
live.className = 'pill ok';
|
||||
@@ -928,6 +1046,34 @@ async function act(fn) {
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('account-list').addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-account-action]');
|
||||
if (!button) return;
|
||||
const action = button.dataset.accountAction;
|
||||
const userId = button.dataset.userId;
|
||||
const base = '/admin/api/accounts/' + encodeURIComponent(userId);
|
||||
if (action === 'rename-device') {
|
||||
const deviceName = prompt('Name this Memby device', button.dataset.deviceName || 'Memby TV');
|
||||
if (deviceName === null || !deviceName.trim()) return;
|
||||
act(() => api(base + '/devices/' + encodeURIComponent(button.dataset.deviceId), {
|
||||
method: 'PUT', body: JSON.stringify({deviceName: deviceName.trim()}),
|
||||
}));
|
||||
}
|
||||
if (action === 'remove-device') {
|
||||
if (!confirm('Sign this device out of Memby? Its Emby account will not be changed.')) return;
|
||||
act(() => api(base + '/devices/' + encodeURIComponent(button.dataset.deviceId), {method: 'DELETE'}));
|
||||
}
|
||||
if (action === 'remove-account') {
|
||||
if (!confirm('Remove Memby access for ' + (button.dataset.username || 'this user') +
|
||||
'? Every Memby device will be signed out. Their Emby account will not be changed.')) return;
|
||||
act(() => api(base + '/sessions', {method: 'DELETE'}));
|
||||
}
|
||||
if (action === 'reset-recommendations') {
|
||||
if (!confirm('Clear this user’s stored recommendation choices? Viewing history remains intact.')) return;
|
||||
act(() => api(base + '/recommendations', {method: 'DELETE'}));
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('sync-incremental').addEventListener('click', () =>
|
||||
act(() => api('/admin/api/sync', { method: 'POST', body: JSON.stringify({ kind: 'incremental' }) })));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user