import { LoginDialog } from './components/login_dialog.js';
import { firebaseAuth, signOut, onAuthStateChanged } from './firebase.js';
import { initTheme, getTheme, setTheme } from './utilities/theme.js';
initTheme();
(function initPageThemeToggle() {
const btn = document.getElementById('pageThemeBtn');
const popup = document.getElementById('pageThemePopup');
if (!btn || !popup) return;
/** Syncs active state of theme option buttons with the current stored theme. */
function updateBtns() {
const cur = getTheme();
popup.querySelectorAll('[data-theme-value]').forEach(o => o.classList.toggle('active', o.dataset.themeValue === cur));
}
btn.addEventListener('click', e => { e.stopPropagation(); popup.classList.toggle('open'); });
document.addEventListener('click', () => popup.classList.remove('open'));
popup.addEventListener('click', e => e.stopPropagation());
popup.querySelectorAll('[data-theme-value]').forEach(o => o.addEventListener('click', () => {
setTheme(o.dataset.themeValue);
updateBtns();
popup.classList.remove('open');
}));
updateBtns();
})();
const LOCAL_MODE = document.querySelector('meta[name="local-mode"]').content === 'true';
let authToken = null;
let loginDialog = null;
let _currentAdminId = null; // ID of the signed-in admin, to prevent self-deactivation
// ── Tab switching ─────────────────────────────────────────────────────────────
const tabs = document.querySelectorAll('.account-tab[data-tab]');
const panels = document.querySelectorAll('.account-panel');
const _panelInited = new Set();
tabs.forEach(tab => {
tab.addEventListener('click', () => {
const target = tab.dataset.tab;
tabs.forEach(t => t.classList.toggle('active', t.dataset.tab === target));
panels.forEach(p => p.classList.toggle('active', p.id === 'tab-' + target));
localStorage.setItem('adminTab', target);
closeDrawer();
closeProjectDrawer();
closeFolderDrawer();
closeEmbedDrawer();
closeSubDrawer();
if (target === 'users' && !_panelInited.has('users')) {
_panelInited.add('users');
initUsersPanel();
}
if (target === 'projects' && !_panelInited.has('projects')) {
_panelInited.add('projects');
initProjectsPanel();
}
if (target === 'folders' && !_panelInited.has('folders')) {
_panelInited.add('folders');
initFoldersPanel();
}
if (target === 'embeds' && !_panelInited.has('embeds')) {
_panelInited.add('embeds');
initEmbedsPanel();
}
if (target === 'subscriptions' && !_panelInited.has('subscriptions')) {
_panelInited.add('subscriptions');
initSubsPanel();
}
if (target === 'analytics' && !_panelInited.has('analytics')) {
_panelInited.add('analytics');
initAnalyticsPanel();
}
if (target === 'dispatch' && !_panelInited.has('dispatch')) {
_panelInited.add('dispatch');
initDispatchPanel();
}
if (target === 'schema' && !_panelInited.has('schema')) {
_panelInited.add('schema');
initSchemaPanel();
}
});
});
document.getElementById('adminBackBtn').addEventListener('click', () => {
window.location.href = '/app';
});
// ── UI state helpers ──────────────────────────────────────────────────────────
/**
* Reveals the admin page UI and populates the header with the current admin user's info.
* @param {object} user - The admin user object from the API.
*/
function showAdminPage(user) {
_currentAdminId = user.id;
document.getElementById('adminLoading').style.display = 'none';
document.getElementById('adminPage').style.display = '';
const initials = (user.display_name || user.email || '?')
.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
document.getElementById('adminHeaderAvatar').textContent = initials;
document.getElementById('adminHeaderName').textContent = user.display_name || user.email;
_panelInited.add('analytics');
initAnalyticsPanel();
const savedTab = localStorage.getItem('adminTab');
if (savedTab && savedTab !== 'analytics') {
const savedTabEl = document.querySelector(`.account-tab[data-tab="${savedTab}"]`);
if (savedTabEl) savedTabEl.click();
}
}
/** Hides the loading spinner overlay. */
function hideLoading() {
document.getElementById('adminLoading').style.display = 'none';
}
// ── Logout ────────────────────────────────────────────────────────────────────
document.getElementById('adminLogoutBtn').addEventListener('click', async () => {
document.getElementById('adminPage').style.display = 'none';
document.getElementById('adminLoading').style.display = '';
authToken = null;
if (firebaseAuth) await signOut(firebaseAuth);
});
// ── Shared helpers ────────────────────────────────────────────────────────────
/**
* Escapes HTML special characters in a string.
* @param {*} s - Value to escape (coerced to string).
* @returns {string} HTML-safe string.
*/
function esc(s) {
return String(s ?? '')
.replace(/&/g, '&').replace(/</g, '<')
.replace(/>/g, '>').replace(/"/g, '"');
}
/**
* Formats an ISO timestamp as a short local date string.
* @param {string|null} iso - ISO 8601 date string, or null/empty.
* @returns {string} Formatted date, or '—' if not provided.
*/
function fmtDate(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleDateString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
});
}
/**
* Formats an ISO timestamp as a full local date and time string.
* @param {string|null} iso - ISO 8601 date string, or null/empty.
* @returns {string} Formatted date-time, or '—' if not provided.
*/
function fmtDatetime(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
let _users = [];
let _drawerUser = null; // Full user object (includes preferences after fetch)
// ── Status toggle (shared between table and drawer) ───────────────────────────
/**
* PATCH is_active for a user, update _users, and refresh the table.
* @param {string} userId - ID of the target user.
* @param {boolean} activate - Desired active state.
* @param {HTMLElement} btn - The button that triggered the action (disabled while in-flight).
*/
async function doSetUserActive(userId, activate, btn) {
btn.disabled = true;
btn.textContent = '…';
try {
const headers = { 'Content-Type': 'application/json' };
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch(`/api/admin/users/${encodeURIComponent(userId)}/active`, {
method: 'PATCH', headers,
body: JSON.stringify({ is_active: activate }),
});
if (!resp.ok) throw new Error(resp.status);
// Patch local list data and re-render the table
const listUser = _users.find(u => u.id === userId);
if (listUser) listUser.is_active = activate;
renderTable();
// If this user's drawer is open, patch the cached full user and re-render it
if (_drawerUser?.id === userId) {
_drawerUser.is_active = activate;
renderDrawerContent();
}
} catch {
btn.disabled = false;
btn.textContent = activate ? 'Activate' : 'Deactivate';
}
}
// ── Users panel ───────────────────────────────────────────────────────────────
let _sortCol = 'created_at';
let _sortDir = 'desc';
let _groupBy = '';
const COLS = [
{ key: 'email', label: 'Email', minWidth: '180px' },
{ key: 'display_name', label: 'Name', minWidth: '130px' },
{ key: 'is_active', label: 'Status', minWidth: '80px' },
{ key: 'auth_provider', label: 'Provider', minWidth: '85px' },
{ key: 'subscription_name', label: 'Subscription', minWidth: '110px' },
{ key: 'project_count', label: 'Projects', minWidth: '75px' },
{ key: 'folder_count', label: 'Folders', minWidth: '70px' },
{ key: 'embed_count', label: 'Embeds', minWidth: '70px' },
{ key: 'last_login_at', label: 'Last Login', minWidth: '110px' },
{ key: 'created_at', label: 'Created', minWidth: '110px' },
{ key: 'modified_at', label: 'Modified', minWidth: '110px' },
{ key: 'id', label: 'User ID', minWidth: '100px' },
{ key: 'external_id', label: 'Firebase UID', minWidth: '110px' },
{ key: 'stripe_customer_id', label: 'Stripe Customer', minWidth: '120px' },
{ key: '_actions', label: '', minWidth: '170px', noSort: true },
];
const GROUP_OPTIONS = [
{ value: '', label: 'None' },
{ value: 'is_active', label: 'Status' },
{ value: 'auth_provider', label: 'Provider' },
{ value: 'subscription_name',label: 'Subscription' },
];
/**
* Renders a user table cell as an HTML string for the given column.
* @param {object} col - Column descriptor from COLS.
* @param {object} user - User data object.
* @returns {string} Inner HTML for the cell.
*/
function fmtCell(col, user) {
const v = user[col.key];
if (col.key === 'is_active') {
const statusBadge = v
? '<span class="admin-badge admin-badge--active">Active</span>'
: '<span class="admin-badge admin-badge--inactive">Inactive</span>';
const adminBadge = user.is_admin
? '<span class="admin-badge admin-badge--admin">Admin</span>'
: '';
return `<span class="admin-badge-group">${statusBadge}${adminBadge}</span>`;
}
if (col.key === 'last_login_at' || col.key === 'created_at' || col.key === 'modified_at') {
return `<span title="${esc(v ?? '')}">${fmtDate(v)}</span>`;
}
if (col.key === 'subscription_name') {
return v
? `<span>${esc(v)}</span>`
: '<span class="admin-cell-muted">—</span>';
}
if (col.key === 'project_count' || col.key === 'folder_count' || col.key === 'embed_count') {
return `<span class="admin-cell-mono">${v ?? 0}</span>`;
}
if (col.key === 'id' || col.key === 'external_id' || col.key === 'stripe_customer_id') {
if (!v) return '<span class="admin-cell-muted">—</span>';
const trunc = v.length > 14 ? v.slice(0, 14) + '…' : v;
return `<span class="admin-cell-mono admin-cell-trunc" title="${esc(v)}">${esc(trunc)}</span>`;
}
if (col.key === '_actions') {
const action = user.is_active ? 'deactivate' : 'activate';
const label = user.is_active ? 'Deactivate' : 'Activate';
const cls = user.is_active ? 'btn btn-danger admin-btn-sm' : 'btn btn-primary admin-btn-sm';
const toggleBtn = `<button class="${cls}" data-action="${action}" data-user-id="${esc(user.id)}">${label}</button>`;
const deleteBtn = `<button class="btn btn-danger admin-btn-sm" data-action="delete" data-user-id="${esc(user.id)}" data-user-label="${esc(user.display_name || user.email)}">Delete</button>`;
return `<span style="display:inline-flex;gap:0.3rem">${toggleBtn}${deleteBtn}</span>`;
}
if (!v && v !== false) return '<span class="admin-cell-muted">—</span>';
return `<span>${esc(v)}</span>`;
}
/**
* Returns a human-readable label for the current group-by field value.
* @param {*} value - The raw group field value.
* @returns {string} Display label for the group.
*/
function getGroupLabel(value) {
if (_groupBy === 'is_active') return value ? 'Active' : 'Inactive';
if (_groupBy === 'subscription_name') return value ? String(value) : 'No subscription';
return value ? String(value) : '—';
}
/**
* Null-safe comparator for table sorting, handles booleans and strings.
* @param {*} a - First value.
* @param {*} b - Second value.
* @returns {number} Negative, zero, or positive sort order.
*/
function cmpValues(a, b) {
if (a == null && b == null) return 0;
if (a == null) return 1;
if (b == null) return -1;
if (typeof a === 'boolean') return a === b ? 0 : (a ? -1 : 1);
return String(a).localeCompare(String(b), undefined, { sensitivity: 'base', numeric: true });
}
/**
* Returns a sorted copy of the _users array based on the current sort column and direction.
* @returns {object[]} Sorted array of user objects.
*/
function sortedUsers() {
return [..._users].sort((a, b) => {
const cmp = cmpValues(a[_sortCol], b[_sortCol]);
return _sortDir === 'asc' ? cmp : -cmp;
});
}
/** Rebuild and inject the table HTML, then wire up interaction handlers. */
function renderTable() {
const wrap = document.getElementById('adminUsersTableWrap');
const count = document.getElementById('adminUsersCount');
count.textContent = `${_users.length} user${_users.length !== 1 ? 's' : ''}`;
const sorted = sortedUsers();
const thead = COLS.map(c => {
if (c.noSort) return `<th style="min-width:${c.minWidth}"></th>`;
const active = c.key === _sortCol;
const arrow = active ? (_sortDir === 'asc' ? ' ↑' : ' ↓') : '';
return `<th class="${active ? 'sorted' : ''}" data-col="${c.key}" style="min-width:${c.minWidth}">${c.label}${arrow}</th>`;
}).join('');
let tbody = '';
if (_groupBy) {
const groups = new Map();
for (const user of sorted) {
const label = getGroupLabel(user[_groupBy]);
if (!groups.has(label)) groups.set(label, []);
groups.get(label).push(user);
}
for (const [label, rows] of groups) {
tbody += `<tr class="admin-group-row"><td colspan="${COLS.length}">${esc(label)} · ${rows.length}</td></tr>`;
for (const user of rows) {
tbody += `<tr class="admin-table-row" data-user-id="${esc(user.id)}">${COLS.map(c => `<td>${fmtCell(c, user)}</td>`).join('')}</tr>`;
}
}
} else {
for (const user of sorted) {
tbody += `<tr class="admin-table-row" data-user-id="${esc(user.id)}">${COLS.map(c => `<td>${fmtCell(c, user)}</td>`).join('')}</tr>`;
}
}
if (!tbody) {
tbody = `<tr><td colspan="${COLS.length}" class="admin-table-empty">No users found.</td></tr>`;
}
const table = document.createElement('table');
table.className = 'admin-table';
const thead_el = document.createElement('thead');
const thead_tr = document.createElement('tr');
thead_tr.innerHTML = thead;
thead_el.appendChild(thead_tr);
const tbody_el = document.createElement('tbody');
tbody_el.innerHTML = tbody;
table.appendChild(thead_el);
table.appendChild(tbody_el);
wrap.textContent = '';
wrap.appendChild(table);
// Sort header clicks
wrap.querySelectorAll('thead th[data-col]').forEach(th => {
th.addEventListener('click', () => {
const col = th.dataset.col;
_sortDir = (_sortCol === col && _sortDir === 'asc') ? 'desc' : 'asc';
_sortCol = col;
renderTable();
});
});
// Restore selected highlight if a drawer is open
if (_drawerUser) _setUserRowSelected(_drawerUser.id);
}
/** Fetches the user list from the API and re-renders the table. */
async function fetchUsers() {
const btn = document.getElementById('adminUsersRefreshBtn');
btn.disabled = true;
btn.textContent = 'Refreshing…';
try {
const headers = {};
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch('/api/admin/users', { headers });
if (!resp.ok) throw new Error(resp.status);
_users = await resp.json();
renderTable();
} catch {
document.getElementById('adminUsersCount').textContent = 'Failed to load users.';
} finally {
btn.disabled = false;
btn.textContent = 'Refresh';
}
}
/** Builds and injects the users panel toolbar and table into the DOM, then fetches data. */
function initUsersPanel() {
const section = document.getElementById('tab-users');
// Toolbar
const toolbar = document.createElement('div');
toolbar.className = 'admin-toolbar';
const countEl = document.createElement('span');
countEl.id = 'adminUsersCount';
countEl.className = 'admin-row-count';
countEl.textContent = 'Loading…';
const groupLabelEl = document.createElement('label');
groupLabelEl.className = 'admin-toolbar-label';
groupLabelEl.textContent = 'Group by';
const groupSelect = document.createElement('select');
groupSelect.className = 'admin-group-select';
GROUP_OPTIONS.forEach(opt => {
const o = document.createElement('option');
o.value = opt.value;
o.textContent = opt.label;
groupSelect.appendChild(o);
});
groupSelect.addEventListener('change', () => {
_groupBy = groupSelect.value;
renderTable();
});
const refreshBtn = document.createElement('button');
refreshBtn.id = 'adminUsersRefreshBtn';
refreshBtn.className = 'btn btn-ghost';
refreshBtn.textContent = 'Refresh';
refreshBtn.addEventListener('click', fetchUsers);
toolbar.appendChild(countEl);
toolbar.appendChild(groupLabelEl);
toolbar.appendChild(groupSelect);
toolbar.appendChild(refreshBtn);
const tableWrap = document.createElement('div');
tableWrap.id = 'adminUsersTableWrap';
tableWrap.className = 'admin-table-wrap';
// Delegated click handler for both row-open and action buttons
tableWrap.addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-action]');
if (btn && !btn.disabled) {
if (btn.dataset.action === 'delete') {
openDeleteConfirm(btn.dataset.userId, btn.dataset.userLabel);
} else {
await doSetUserActive(btn.dataset.userId, btn.dataset.action === 'activate', btn);
}
return;
}
// Row click → open detail drawer
const row = e.target.closest('tr.admin-table-row[data-user-id]');
if (row) openDrawer(row.dataset.userId);
});
section.appendChild(toolbar);
section.appendChild(tableWrap);
fetchUsers();
}
// ── Projects panel ────────────────────────────────────────────────────────────
let _projects = [];
let _projSortCol = 'modified_at';
let _projSortDir = 'desc';
let _drawerProject = null;
let _projGroupBy = '';
const PROJ_COLS = [
{ key: 'name', label: 'Name', minWidth: '180px' },
{ key: 'owner', label: 'Owner', minWidth: '130px' },
{ key: 'folder', label: 'Folder', minWidth: '110px' },
{ key: 'has_transcript', label: 'Transcript', minWidth: '90px' },
{ key: 'audio_format', label: 'Format', minWidth: '70px' },
{ key: 'duration', label: 'Duration', minWidth: '80px' },
{ key: 'embed_count', label: 'Embeds', minWidth: '70px' },
{ key: 'any_with_link', label: 'Public', minWidth: '70px' },
{ key: 'modified_at', label: 'Modified', minWidth: '110px' },
{ key: 'created_at', label: 'Created', minWidth: '110px' },
{ key: 'id', label: 'Project ID', minWidth: '100px' },
{ key: '_actions', label: '', minWidth: '175px', noSort: true },
];
const PROJ_GROUP_OPTIONS = [
{ value: '', label: 'None' },
{ value: 'owner', label: 'Owner' },
{ value: 'folder', label: 'Folder' },
{ value: 'has_transcript',label: 'Transcript' },
{ value: 'audio_format', label: 'Format' },
{ value: 'any_with_link', label: 'Public' },
];
/**
* Formats a duration in seconds as m:ss or h:mm:ss.
* @param {number|null} secs - Duration in seconds.
* @returns {string} Formatted duration string, or '—' if not provided.
*/
function fmtDuration(secs) {
if (secs == null || secs < 0) return '—';
const s = Math.round(secs);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return `${h}:${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`;
return `${m}:${String(sec).padStart(2,'0')}`;
}
/**
* Renders a projects table cell as an HTML string for the given column.
* @param {object} col - Column descriptor from PROJ_COLS.
* @param {object} proj - Project data object.
* @returns {string} Inner HTML for the cell.
*/
function fmtProjCell(col, proj) {
const v = proj[col.key];
if (col.key === 'has_transcript') {
return v
? '<span class="admin-badge admin-badge--active">Yes</span>'
: '<span class="admin-cell-muted">—</span>';
}
if (col.key === 'any_with_link') {
const projBadge = v ? '<span class="admin-badge admin-badge--admin">Public</span>' : '';
const folderBadge = proj.folder_is_public
? '<span class="admin-badge admin-badge--active">Public Folder</span>' : '';
const badges = projBadge || folderBadge
? `<span class="admin-badge-group">${projBadge}${folderBadge}</span>`
: '<span class="admin-cell-muted">—</span>';
return badges;
}
if (col.key === 'duration') {
return `<span class="admin-cell-mono">${fmtDuration(v)}</span>`;
}
if (col.key === 'embed_count') {
return `<span class="admin-cell-mono">${v ?? 0}</span>`;
}
if (col.key === 'audio_format') {
return v ? `<span class="admin-cell-mono">${esc(v.toUpperCase())}</span>`
: '<span class="admin-cell-muted">—</span>';
}
if (col.key === 'modified_at' || col.key === 'created_at') {
return `<span title="${esc(v ?? '')}">${fmtDate(v)}</span>`;
}
if (col.key === 'folder') {
return v ? `<span class="admin-cell-muted">${esc(v)}</span>`
: '<span class="admin-cell-muted">root</span>';
}
if (col.key === 'id') {
if (!v) return '<span class="admin-cell-muted">—</span>';
const trunc = v.length > 14 ? v.slice(0, 14) + '…' : v;
return `<span class="admin-cell-mono admin-cell-trunc" title="${esc(v)}">${esc(trunc)}</span>`;
}
if (col.key === '_actions') {
const canEdit = proj.owner_id === null || proj.owner_id === _currentAdminId;
const disabled = canEdit ? '' : ' disabled title="You do not own this project"';
const pubBtn = proj.any_with_link
? `<button class="btn btn-ghost admin-btn-sm" data-proj-action="make-private" data-proj-id="${esc(proj.id)}"${disabled}>Make Private</button>`
: `<button class="btn btn-ghost admin-btn-sm" data-proj-action="make-public" data-proj-id="${esc(proj.id)}"${disabled}>Make Public</button>`;
const delBtn = `<button class="btn btn-danger admin-btn-sm" data-proj-action="delete"
data-proj-id="${esc(proj.id)}"
data-proj-label="${esc(proj.name)}"${disabled}>Delete</button>`;
return `<span style="display:inline-flex;gap:0.3rem">${pubBtn}${delBtn}</span>`;
}
if (!v && v !== false) return '<span class="admin-cell-muted">—</span>';
return `<span>${esc(v)}</span>`;
}
/**
* Returns a sorted copy of the _projects array based on the current sort column and direction.
* @returns {object[]} Sorted array of project objects.
*/
function sortedProjects() {
return [..._projects].sort((a, b) => {
const cmp = cmpValues(a[_projSortCol], b[_projSortCol]);
return _projSortDir === 'asc' ? cmp : -cmp;
});
}
/** Rebuilds and injects the projects table HTML, then wires up interaction handlers. */
function renderProjectsTable() {
const wrap = document.getElementById('adminProjectsTableWrap');
const count = document.getElementById('adminProjectsCount');
count.textContent = `${_projects.length} project${_projects.length !== 1 ? 's' : ''}`;
const sorted = sortedProjects();
const thead = PROJ_COLS.map(c => {
if (c.noSort) return `<th style="min-width:${c.minWidth}"></th>`;
const active = c.key === _projSortCol;
const arrow = active ? (_projSortDir === 'asc' ? ' ↑' : ' ↓') : '';
return `<th class="${active ? 'sorted' : ''}" data-col="${c.key}" style="min-width:${c.minWidth}">${c.label}${arrow}</th>`;
}).join('');
let tbody = '';
if (_projGroupBy) {
const groups = new Map();
for (const proj of sorted) {
const gv = proj[_projGroupBy];
const label = _projGroupBy === 'has_transcript' ? (gv ? 'Has transcript' : 'No transcript')
: _projGroupBy === 'any_with_link' ? (gv ? 'Public' : 'Private')
: (gv ? String(gv) : '—');
if (!groups.has(label)) groups.set(label, []);
groups.get(label).push(proj);
}
for (const [label, rows] of groups) {
tbody += `<tr class="admin-group-row"><td colspan="${PROJ_COLS.length}">${esc(label)} · ${rows.length}</td></tr>`;
for (const proj of rows) {
tbody += `<tr class="admin-table-row" data-proj-id="${esc(proj.id)}">${PROJ_COLS.map(c => `<td>${fmtProjCell(c, proj)}</td>`).join('')}</tr>`;
}
}
} else {
for (const proj of sorted) {
tbody += `<tr class="admin-table-row" data-proj-id="${esc(proj.id)}">${PROJ_COLS.map(c => `<td>${fmtProjCell(c, proj)}</td>`).join('')}</tr>`;
}
}
if (!tbody) {
tbody = `<tr><td colspan="${PROJ_COLS.length}" class="admin-table-empty">No projects found.</td></tr>`;
}
const table = document.createElement('table');
table.className = 'admin-table';
const thead_el = document.createElement('thead');
const thead_tr = document.createElement('tr');
thead_tr.innerHTML = thead;
thead_el.appendChild(thead_tr);
const tbody_el = document.createElement('tbody');
tbody_el.innerHTML = tbody;
table.appendChild(thead_el);
table.appendChild(tbody_el);
wrap.textContent = '';
wrap.appendChild(table);
wrap.querySelectorAll('thead th[data-col]').forEach(th => {
th.addEventListener('click', () => {
const col = th.dataset.col;
_projSortDir = (_projSortCol === col && _projSortDir === 'asc') ? 'desc' : 'asc';
_projSortCol = col;
renderProjectsTable();
});
});
// Restore selected highlight if a drawer is open
if (_drawerProject) _setProjRowSelected(_drawerProject.id);
}
/** Fetches the project list from the API and re-renders the table. */
async function fetchProjects() {
const btn = document.getElementById('adminProjectsRefreshBtn');
btn.disabled = true;
btn.textContent = 'Refreshing…';
try {
const headers = {};
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch('/api/admin/projects', { headers });
if (!resp.ok) throw new Error(resp.status);
_projects = await resp.json();
renderProjectsTable();
} catch {
document.getElementById('adminProjectsCount').textContent = 'Failed to load projects.';
} finally {
btn.disabled = false;
btn.textContent = 'Refresh';
}
}
/** Builds and injects the projects panel toolbar and table into the DOM, then fetches data. */
function initProjectsPanel() {
const section = document.getElementById('tab-projects');
section.innerHTML = '';
const toolbar = document.createElement('div');
toolbar.className = 'admin-toolbar';
const countEl = document.createElement('span');
countEl.id = 'adminProjectsCount';
countEl.className = 'admin-row-count';
countEl.textContent = 'Loading…';
const groupLabelEl = document.createElement('label');
groupLabelEl.className = 'admin-toolbar-label';
groupLabelEl.textContent = 'Group by';
const groupSelect = document.createElement('select');
groupSelect.className = 'admin-group-select';
PROJ_GROUP_OPTIONS.forEach(opt => {
const o = document.createElement('option');
o.value = opt.value;
o.textContent = opt.label;
groupSelect.appendChild(o);
});
groupSelect.addEventListener('change', () => {
_projGroupBy = groupSelect.value;
renderProjectsTable();
});
const refreshBtn = document.createElement('button');
refreshBtn.id = 'adminProjectsRefreshBtn';
refreshBtn.className = 'btn btn-ghost';
refreshBtn.textContent = 'Refresh';
refreshBtn.addEventListener('click', fetchProjects);
toolbar.appendChild(countEl);
toolbar.appendChild(groupLabelEl);
toolbar.appendChild(groupSelect);
toolbar.appendChild(refreshBtn);
const tableWrap = document.createElement('div');
tableWrap.id = 'adminProjectsTableWrap';
tableWrap.className = 'admin-table-wrap';
tableWrap.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-proj-action]');
if (btn) {
if (btn.disabled) return;
const { projAction, projId, projLabel } = btn.dataset;
if (projAction === 'delete') {
openDeleteProjectConfirm(projId, projLabel);
} else if (projAction === 'make-public' || projAction === 'make-private') {
doSetProjectPublic(projId, projAction === 'make-public', btn);
}
return;
}
const row = e.target.closest('tr.admin-table-row[data-proj-id]');
if (row) openProjectDrawer(row.dataset.projId);
});
section.appendChild(toolbar);
section.appendChild(tableWrap);
fetchProjects();
}
// ── Delete confirm dialog ─────────────────────────────────────────────────────
const _confirmOverlay = document.getElementById('adminConfirmOverlay');
const _confirmMsg = document.getElementById('adminConfirmMsg');
const _confirmOkBtn = document.getElementById('adminConfirmOk');
const _confirmCancelBtn = document.getElementById('adminConfirmCancel');
let _confirmCallback = null;
/**
* Opens the delete confirmation dialog for a project.
* @param {string} projectId - ID of the project to delete.
* @param {string} label - Display name of the project shown in the confirmation message.
*/
function openDeleteProjectConfirm(projectId, label) {
_confirmMsg.textContent = `Permanently delete project "${label}"? This will delete all associated files and cannot be undone.`;
_confirmOverlay.classList.add('open');
_confirmOkBtn.onclick = () => {
closeConfirm();
doDeleteProject(projectId);
};
}
/**
* Sends a DELETE request for the given project and removes it from the local list.
* @param {string} projectId - ID of the project to delete.
*/
async function doDeleteProject(projectId) {
try {
const headers = { 'Content-Type': 'application/json' };
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch(`/api/admin/projects/${encodeURIComponent(projectId)}`, {
method: 'DELETE', headers,
});
if (!resp.ok) throw new Error(resp.status);
_projects = _projects.filter(p => p.id !== projectId);
if (_drawerProject?.id === projectId) closeProjectDrawer();
renderProjectsTable();
} catch {
// Silently ignore — user can retry
}
}
/**
* Toggles the public/private state of a project via PATCH and updates the local list.
* @param {string} projectId - ID of the project to update.
* @param {boolean} makePublic - True to make the project public, false to make it private.
* @param {HTMLElement} btn - The button that triggered the action (disabled while in-flight).
*/
async function doSetProjectPublic(projectId, makePublic, btn) {
btn.disabled = true;
btn.textContent = '…';
try {
const headers = { 'Content-Type': 'application/json' };
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch(`/api/admin/projects/${encodeURIComponent(projectId)}/public`, {
method: 'PATCH', headers,
body: JSON.stringify({ public: makePublic }),
});
if (!resp.ok) throw new Error(resp.status);
const proj = _projects.find(p => p.id === projectId);
if (proj) {
proj.any_with_link = makePublic;
if (makePublic) { proj.owner = '—'; proj.owner_id = null; }
}
renderProjectsTable();
} catch {
btn.disabled = false;
btn.textContent = makePublic ? 'Make Public' : 'Make Private';
}
}
// ── Project detail drawer ─────────────────────────────────────────────────────
const _projDrawerOverlay = document.getElementById('projectDrawerOverlay');
const _projDrawerPanel = document.getElementById('projectDrawerPanel');
/**
* Applies or clears the selected-row highlight in the projects table.
* @param {string|null} projectId - Project ID to highlight, or null to clear.
*/
function _setProjRowSelected(projectId) {
document.querySelectorAll('#adminProjectsTableWrap tr.admin-table-row').forEach(r => {
r.classList.toggle('admin-table-row--selected', !!projectId && r.dataset.projId === projectId);
});
}
/**
* Opens the project detail drawer for the given project.
* @param {string} projectId - ID of the project to display.
*/
function openProjectDrawer(projectId) {
const proj = _projects.find(p => p.id === projectId);
if (!proj) return;
if (_drawerUser) closeDrawer();
_drawerProject = proj;
renderProjectDrawerContent();
_projDrawerOverlay.classList.add('open');
_setProjRowSelected(projectId);
}
/** Closes the project detail drawer and clears the selection highlight. */
function closeProjectDrawer() {
_projDrawerOverlay.classList.remove('open');
_drawerProject = null;
_setProjRowSelected(null);
}
/** Re-renders the project drawer panel from _drawerProject. */
function renderProjectDrawerContent() {
const proj = _drawerProject;
if (!proj) return;
const canEdit = proj.owner_id === null || proj.owner_id === _currentAdminId;
// Header badges
const transcriptBadge = proj.has_transcript
? '<span class="admin-badge admin-badge--active">Transcript</span>' : '';
const publicBadge = proj.any_with_link
? '<span class="admin-badge admin-badge--admin">Public</span>' : '';
const folderBadge = proj.folder_is_public
? '<span class="admin-badge admin-badge--active">Public Folder</span>' : '';
const badgeGroup = (transcriptBadge || publicBadge || folderBadge)
? `<div class="admin-badge-group" style="margin-top:0.4rem">${transcriptBadge}${publicBadge}${folderBadge}</div>`
: '';
_projDrawerPanel.textContent = '';
// Header
const projHeader = document.createElement('div');
projHeader.className = 'user-drawer-header';
const projIdentity = document.createElement('div');
projIdentity.className = 'user-drawer-identity';
projIdentity.style.paddingTop = '0.1rem';
const projName = document.createElement('div');
projName.className = 'user-drawer-name';
projName.textContent = proj.name;
const projEmail = document.createElement('div');
projEmail.className = 'user-drawer-email';
projEmail.textContent = proj.owner || '—';
projIdentity.appendChild(projName);
projIdentity.appendChild(projEmail);
if (badgeGroup) projIdentity.insertAdjacentHTML('beforeend', badgeGroup);
const projCloseBtn = document.createElement('button');
projCloseBtn.className = 'user-drawer-close';
projCloseBtn.id = 'projDrawerClose';
projCloseBtn.title = 'Close';
projCloseBtn.innerHTML = '<span class="icon icon-close" style="width:12px;height:12px;"></span>';
projHeader.appendChild(projIdentity);
projHeader.appendChild(projCloseBtn);
// Body
const projBody = document.createElement('div');
projBody.className = 'user-drawer-body';
const mkSection = (title, ...rows) => {
const sec = document.createElement('div');
sec.className = 'user-drawer-section';
const titleEl = document.createElement('div');
titleEl.className = 'user-drawer-section-title';
titleEl.textContent = title;
sec.appendChild(titleEl);
rows.forEach(r => sec.insertAdjacentHTML('beforeend', r));
return sec;
};
projBody.appendChild(mkSection('Identity',
drawerRow('Project ID', proj.id, true),
drawerRow('Owner', proj.owner || '—'),
drawerRow('Folder', proj.folder || 'root'),
));
projBody.appendChild(mkSection('Audio',
drawerRow('Format', proj.audio_format ? proj.audio_format.toUpperCase() : '—'),
drawerRow('Duration', fmtDuration(proj.duration)),
drawerRow('Sample rate', proj.sample_rate > 0 ? `${proj.sample_rate} Hz` : '—'),
drawerRow('MP3 copy', proj.has_audio_mp3 ? 'Yes' : 'No'),
));
projBody.appendChild(mkSection('Content',
drawerRow('Transcript', proj.has_transcript ? 'Yes' : 'No'),
drawerRow('Embeds', String(proj.embed_count)),
));
projBody.appendChild(mkSection('Timestamps',
drawerRow('Created', fmtDatetime(proj.created_at)),
drawerRow('Modified', fmtDatetime(proj.modified_at)),
));
if (canEdit) {
const actSec = document.createElement('div');
actSec.className = 'user-drawer-section';
const actTitle = document.createElement('div');
actTitle.className = 'user-drawer-section-title';
actTitle.textContent = 'Actions';
const actRow = document.createElement('div');
actRow.style.cssText = 'display:flex;gap:0.5rem;flex-wrap:wrap';
const pubBtn = document.createElement('button');
pubBtn.className = 'btn btn-ghost';
pubBtn.id = 'projDrawerPubBtn';
pubBtn.dataset.action = proj.any_with_link ? 'make-private' : 'make-public';
pubBtn.dataset.projId = proj.id;
pubBtn.textContent = proj.any_with_link ? 'Make Private' : 'Make Public';
const delBtn = document.createElement('button');
delBtn.className = 'btn btn-danger';
delBtn.id = 'projDrawerDelBtn';
delBtn.dataset.projId = proj.id;
delBtn.dataset.projLabel = proj.name;
delBtn.textContent = 'Delete project';
actRow.appendChild(pubBtn);
actRow.appendChild(delBtn);
actSec.appendChild(actTitle);
actSec.appendChild(actRow);
projBody.appendChild(actSec);
}
_projDrawerPanel.appendChild(projHeader);
_projDrawerPanel.appendChild(projBody);
document.getElementById('projDrawerClose').addEventListener('click', closeProjectDrawer);
const pubBtn = document.getElementById('projDrawerPubBtn');
if (pubBtn) {
pubBtn.addEventListener('click', async () => {
const makePublic = pubBtn.dataset.action === 'make-public';
await doSetProjectPublic(proj.id, makePublic, pubBtn);
// Re-render drawer with updated state
_drawerProject = _projects.find(p => p.id === proj.id) ?? _drawerProject;
renderProjectDrawerContent();
});
}
const delBtn = document.getElementById('projDrawerDelBtn');
if (delBtn) {
delBtn.addEventListener('click', () => {
openDeleteProjectConfirm(delBtn.dataset.projId, delBtn.dataset.projLabel);
});
}
}
// ── User detail drawer ────────────────────────────────────────────────────────
const _drawerOverlay = document.getElementById('userDrawerOverlay');
const _drawerPanel = document.getElementById('userDrawerPanel');
/**
* Applies or clears the selected-row highlight in the users table.
* @param {string|null} userId - User ID to highlight, or null to clear.
*/
function _setUserRowSelected(userId) {
document.querySelectorAll('#adminUsersTableWrap tr.admin-table-row').forEach(r => {
r.classList.toggle('admin-table-row--selected', !!userId && r.dataset.userId === userId);
});
}
/**
* Opens the user detail drawer, immediately showing list data then fetching the full record.
* @param {string} userId - ID of the user to display.
*/
function openDrawer(userId) {
const listUser = _users.find(u => u.id === userId);
if (!listUser) return;
if (_drawerProject) closeProjectDrawer();
// Show immediately with data we already have (no preferences yet)
_drawerUser = { ...listUser };
renderDrawerContent();
_drawerOverlay.classList.add('open');
_setUserRowSelected(userId);
// Fetch full record (includes preferences)
(async () => {
try {
const headers = {};
if (authToken) headers['X-Auth-Token'] = authToken;
const r = await fetch(`/api/admin/users/${encodeURIComponent(userId)}`, { headers });
const full = r.ok ? await r.json() : null;
if (full && _drawerUser?.id === userId) {
_drawerUser = full;
renderDrawerContent();
}
} catch {}
})();
}
/** Closes the user detail drawer and clears the selection highlight. */
function closeDrawer() {
_drawerOverlay.classList.remove('open');
_drawerUser = null;
_setUserRowSelected(null);
}
/** Re-render the drawer panel from _drawerUser. */
function renderDrawerContent() {
const user = _drawerUser;
if (!user) return;
const isSelf = user.id === _currentAdminId;
const initials = (user.display_name || user.email || '?')
.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
const statusBadge = user.is_active
? '<span class="admin-badge admin-badge--active">Active</span>'
: '<span class="admin-badge admin-badge--inactive">Inactive</span>';
const adminBadge = user.is_admin
? '<span class="admin-badge admin-badge--admin">Admin</span>'
: '';
// Preferences section
const hasPrefs = user.preferences != null;
const prefsHtml = hasPrefs
? renderPreferences(user.preferences)
: '<p class="admin-detail-hint">Loading…</p>';
_drawerPanel.textContent = '';
// Header
const userHeader = document.createElement('div');
userHeader.className = 'user-drawer-header';
const userAvatar = document.createElement('div');
userAvatar.className = 'user-drawer-avatar';
userAvatar.textContent = initials;
const userIdentity = document.createElement('div');
userIdentity.className = 'user-drawer-identity';
const userNameEl = document.createElement('div');
userNameEl.className = 'user-drawer-name';
userNameEl.textContent = user.display_name || '—';
const userEmailEl = document.createElement('div');
userEmailEl.className = 'user-drawer-email';
userEmailEl.textContent = user.email || '—';
const badgeGroupEl = document.createElement('div');
badgeGroupEl.className = 'admin-badge-group';
badgeGroupEl.style.marginTop = '0.4rem';
badgeGroupEl.innerHTML = statusBadge + adminBadge;
userIdentity.appendChild(userNameEl);
userIdentity.appendChild(userEmailEl);
userIdentity.appendChild(badgeGroupEl);
const userCloseBtn = document.createElement('button');
userCloseBtn.className = 'user-drawer-close';
userCloseBtn.id = 'userDrawerClose';
userCloseBtn.title = 'Close';
userCloseBtn.innerHTML = '<span class="icon icon-close" style="width:12px;height:12px;"></span>';
userHeader.appendChild(userAvatar);
userHeader.appendChild(userIdentity);
userHeader.appendChild(userCloseBtn);
// Body
const userBody = document.createElement('div');
userBody.className = 'user-drawer-body';
const mkSec = (title, ...rows) => {
const sec = document.createElement('div');
sec.className = 'user-drawer-section';
const titleEl = document.createElement('div');
titleEl.className = 'user-drawer-section-title';
titleEl.textContent = title;
sec.appendChild(titleEl);
rows.forEach(r => { if (r) sec.insertAdjacentHTML('beforeend', r); });
return sec;
};
userBody.appendChild(mkSec('Identity',
drawerRow('User ID', user.id, true),
drawerRow('Firebase UID', user.external_id, true),
drawerRow('Provider', user.auth_provider),
));
userBody.appendChild(mkSec('Account',
drawerRow('Created', fmtDatetime(user.created_at)),
drawerRow('Last login', fmtDatetime(user.last_login_at)),
drawerRow('Modified', fmtDatetime(user.modified_at)),
user.subscription_id
? drawerRow('Subscription', user.subscription_name ?? user.subscription_id, !user.subscription_name)
: null,
));
if (!isSelf) {
const actSec = document.createElement('div');
actSec.className = 'user-drawer-section';
const actTitle = document.createElement('div');
actTitle.className = 'user-drawer-section-title';
actTitle.textContent = 'Actions';
const actRow = document.createElement('div');
actRow.style.cssText = 'display:flex;gap:0.5rem;flex-wrap:wrap';
const toggleBtn = document.createElement('button');
toggleBtn.className = user.is_active ? 'btn btn-danger' : 'btn btn-primary';
toggleBtn.id = 'drawerToggleBtn';
toggleBtn.dataset.action = user.is_active ? 'deactivate' : 'activate';
toggleBtn.dataset.userId = user.id;
toggleBtn.textContent = user.is_active ? 'Deactivate account' : 'Activate account';
const deleteBtn = document.createElement('button');
deleteBtn.className = 'btn btn-danger';
deleteBtn.id = 'drawerDeleteBtn';
deleteBtn.dataset.userId = user.id;
deleteBtn.textContent = 'Delete account';
actRow.appendChild(toggleBtn);
actRow.appendChild(deleteBtn);
actSec.appendChild(actTitle);
actSec.appendChild(actRow);
userBody.appendChild(actSec);
}
const settingsSec = document.createElement('div');
settingsSec.className = 'user-drawer-section';
const settingsTitle = document.createElement('div');
settingsTitle.className = 'user-drawer-section-title';
settingsTitle.textContent = 'Settings';
settingsSec.appendChild(settingsTitle);
settingsSec.insertAdjacentHTML('beforeend', prefsHtml);
userBody.appendChild(settingsSec);
_drawerPanel.appendChild(userHeader);
_drawerPanel.appendChild(userBody);
document.getElementById('userDrawerClose').addEventListener('click', closeDrawer);
const toggleBtn = document.getElementById('drawerToggleBtn');
if (toggleBtn) {
toggleBtn.addEventListener('click', () => {
doSetUserActive(toggleBtn.dataset.userId, toggleBtn.dataset.action === 'activate', toggleBtn);
});
}
const deleteBtn = document.getElementById('drawerDeleteBtn');
if (deleteBtn) {
deleteBtn.addEventListener('click', () => {
openDeleteConfirm(deleteBtn.dataset.userId, user.display_name || user.email);
});
}
}
/**
* Renders a label/value row for the drawer.
* @param {string} label - Display label.
* @param {string} value - Value to display.
* @param {boolean} mono - Whether to render the value in monospace.
* @returns {string} HTML string for the row.
*/
function drawerRow(label, value, mono = false) {
const valHtml = value
? `<span class="${mono ? 'admin-cell-mono' : ''} user-drawer-row-value-text">${esc(value)}</span>`
: '<span class="admin-cell-muted">—</span>';
return `
<div class="user-drawer-row">
<span class="user-drawer-row-label">${esc(label)}</span>
<span class="user-drawer-row-value">${valHtml}</span>
</div>`;
}
const STARTUP_LABELS = { last_project: 'Open last project', home_screen: 'Show home screen' };
const KNOWN_PREF_KEYS = new Set([
'theme', 'startup_behavior', 'autosave', 'undo_queue_size',
'custom_themes', 'custom_colors_light', 'custom_colors_dark',
'subscription_since', 'survey_answers',
]);
/**
* Renders the preferences object as a series of drawer rows.
* @param {object} prefs - User preferences object.
* @returns {string} HTML string of drawer rows, or a hint message if empty.
*/
function renderPreferences(prefs) {
if (!prefs || !Object.keys(prefs).length) {
return '<p class="admin-detail-hint">No preferences saved.</p>';
}
const rows = [];
if (prefs.theme !== undefined)
rows.push(drawerRow('Theme', prefs.theme));
if (prefs.startup_behavior !== undefined)
rows.push(drawerRow('On startup', STARTUP_LABELS[prefs.startup_behavior] ?? prefs.startup_behavior));
if (prefs.autosave !== undefined)
rows.push(drawerRow('Autosave', prefs.autosave ? 'Enabled' : 'Disabled'));
if (prefs.undo_queue_size !== undefined)
rows.push(drawerRow('Undo queue', `${prefs.undo_queue_size} steps`));
if (prefs.custom_themes?.length)
rows.push(drawerRow('Custom themes', String(prefs.custom_themes.length)));
const hasLightColors = prefs.custom_colors_light && Object.keys(prefs.custom_colors_light).length;
const hasDarkColors = prefs.custom_colors_dark && Object.keys(prefs.custom_colors_dark).length;
if (hasLightColors || hasDarkColors)
rows.push(drawerRow('Custom colors', [
hasLightColors ? 'light' : '',
hasDarkColors ? 'dark' : '',
].filter(Boolean).join(', ')));
if (prefs.subscription_since)
rows.push(drawerRow('Subscriber since', fmtDate(prefs.subscription_since)));
if (prefs.survey_answers && Object.keys(prefs.survey_answers).length) {
for (const [k, v] of Object.entries(prefs.survey_answers)) {
if (v) rows.push(drawerRow(k.replace(/_/g, ' '), String(v)));
}
}
// Any unrecognised keys
for (const [k, v] of Object.entries(prefs)) {
if (!KNOWN_PREF_KEYS.has(k)) {
const display = typeof v === 'object' ? JSON.stringify(v) : String(v);
rows.push(drawerRow(k, display));
}
}
return rows.join('');
}
// ── Folders panel ─────────────────────────────────────────────────────────────
let _folders = [];
let _fldSortCol = 'created_at';
let _fldSortDir = 'desc';
let _fldGroupBy = '';
let _drawerFolder = null;
const FLD_COLS = [
{ key: 'name', label: 'Name', minWidth: '160px' },
{ key: 'owner', label: 'Owner', minWidth: '160px' },
{ key: 'parent_name', label: 'Parent', minWidth: '120px' },
{ key: 'is_public', label: 'Visibility', minWidth: '100px' },
{ key: 'project_count', label: 'Projects', minWidth: '75px' },
{ key: 'subfolder_count', label: 'Subfolders', minWidth: '80px' },
{ key: 'created_at', label: 'Created', minWidth: '110px' },
{ key: 'id', label: 'Folder ID', minWidth: '100px' },
{ key: '_actions', label: '', minWidth: '175px', noSort: true },
];
const FLD_GROUP_OPTIONS = [
{ value: '', label: 'None' },
{ value: 'owner', label: 'Owner' },
{ value: 'parent_name', label: 'Parent' },
{ value: 'is_public', label: 'Visibility'},
];
/**
* Renders a folders table cell as an HTML string for the given column.
* @param {object} col - Column descriptor from FLD_COLS.
* @param {object} fld - Folder data object.
* @returns {string} Inner HTML for the cell.
*/
function fmtFldCell(col, fld) {
const v = fld[col.key];
if (col.key === 'is_public') {
return v
? '<span class="admin-badge admin-badge--admin">Public</span>'
: '<span class="admin-cell-muted">Private</span>';
}
if (col.key === 'project_count' || col.key === 'subfolder_count') {
return `<span class="admin-cell-mono">${v ?? 0}</span>`;
}
if (col.key === 'created_at') {
return `<span title="${esc(v ?? '')}">${fmtDate(v)}</span>`;
}
if (col.key === 'parent_name') {
return v ? `<span class="admin-cell-muted">${esc(v)}</span>`
: '<span class="admin-cell-muted">root</span>';
}
if (col.key === 'id') {
const trunc = v && v.length > 14 ? v.slice(0, 14) + '…' : v;
return v
? `<span class="admin-cell-mono admin-cell-trunc" title="${esc(v)}">${esc(trunc)}</span>`
: '<span class="admin-cell-muted">—</span>';
}
if (col.key === '_actions') {
const canEdit = fld.owner_id === null || fld.owner_id === _currentAdminId;
const disabled = canEdit ? '' : ' disabled title="You do not own this folder"';
const pubBtn = fld.is_public
? `<button class="btn btn-ghost admin-btn-sm" data-fld-action="make-private" data-fld-id="${esc(fld.id)}"${disabled}>Make Private</button>`
: `<button class="btn btn-ghost admin-btn-sm" data-fld-action="make-public" data-fld-id="${esc(fld.id)}"${disabled}>Make Public</button>`;
const delBtn = `<button class="btn btn-danger admin-btn-sm" data-fld-action="delete"
data-fld-id="${esc(fld.id)}"
data-fld-label="${esc(fld.name)}"${disabled}>Delete</button>`;
return `<span style="display:inline-flex;gap:0.3rem">${pubBtn}${delBtn}</span>`;
}
if (!v && v !== false) return '<span class="admin-cell-muted">—</span>';
return `<span>${esc(v)}</span>`;
}
/**
* Returns a sorted copy of the _folders array based on the current sort column and direction.
* @returns {object[]} Sorted array of folder objects.
*/
function sortedFolders() {
return [..._folders].sort((a, b) => {
const cmp = cmpValues(a[_fldSortCol], b[_fldSortCol]);
return _fldSortDir === 'asc' ? cmp : -cmp;
});
}
/** Rebuilds and injects the folders table HTML, then wires up interaction handlers. */
function renderFoldersTable() {
const wrap = document.getElementById('adminFoldersTableWrap');
const count = document.getElementById('adminFoldersCount');
count.textContent = `${_folders.length} folder${_folders.length !== 1 ? 's' : ''}`;
const sorted = sortedFolders();
const thead = FLD_COLS.map(c => {
if (c.noSort) return `<th style="min-width:${c.minWidth}"></th>`;
const active = c.key === _fldSortCol;
const arrow = active ? (_fldSortDir === 'asc' ? ' ↑' : ' ↓') : '';
return `<th class="${active ? 'sorted' : ''}" data-col="${c.key}" style="min-width:${c.minWidth}">${c.label}${arrow}</th>`;
}).join('');
let tbody = '';
if (_fldGroupBy) {
const groups = new Map();
for (const fld of sorted) {
const gv = fld[_fldGroupBy];
const label = _fldGroupBy === 'is_public' ? (gv ? 'Public' : 'Private')
: (gv ? String(gv) : '—');
if (!groups.has(label)) groups.set(label, []);
groups.get(label).push(fld);
}
for (const [label, rows] of groups) {
tbody += `<tr class="admin-group-row"><td colspan="${FLD_COLS.length}">${esc(label)} · ${rows.length}</td></tr>`;
for (const fld of rows) {
tbody += `<tr class="admin-table-row" data-fld-id="${esc(fld.id)}">${FLD_COLS.map(c => `<td>${fmtFldCell(c, fld)}</td>`).join('')}</tr>`;
}
}
} else {
for (const fld of sorted) {
tbody += `<tr class="admin-table-row" data-fld-id="${esc(fld.id)}">${FLD_COLS.map(c => `<td>${fmtFldCell(c, fld)}</td>`).join('')}</tr>`;
}
}
if (!tbody) {
tbody = `<tr><td colspan="${FLD_COLS.length}" class="admin-table-empty">No folders found.</td></tr>`;
}
const table = document.createElement('table');
table.className = 'admin-table';
const thead_el = document.createElement('thead');
const thead_tr = document.createElement('tr');
thead_tr.innerHTML = thead;
thead_el.appendChild(thead_tr);
const tbody_el = document.createElement('tbody');
tbody_el.innerHTML = tbody;
table.appendChild(thead_el);
table.appendChild(tbody_el);
wrap.textContent = '';
wrap.appendChild(table);
wrap.querySelectorAll('thead th[data-col]').forEach(th => {
th.addEventListener('click', () => {
const col = th.dataset.col;
_fldSortDir = (_fldSortCol === col && _fldSortDir === 'asc') ? 'desc' : 'asc';
_fldSortCol = col;
renderFoldersTable();
});
});
if (_drawerFolder) _setFldRowSelected(_drawerFolder.id);
}
/** Fetches the folder list from the API and re-renders the table. */
async function fetchFolders() {
const btn = document.getElementById('adminFoldersRefreshBtn');
btn.disabled = true;
btn.textContent = 'Refreshing…';
try {
const headers = {};
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch('/api/admin/folders', { headers });
if (!resp.ok) throw new Error(resp.status);
_folders = await resp.json();
renderFoldersTable();
} catch {
document.getElementById('adminFoldersCount').textContent = 'Failed to load folders.';
} finally {
btn.disabled = false;
btn.textContent = 'Refresh';
}
}
/** Builds and injects the folders panel toolbar and table into the DOM, then fetches data. */
function initFoldersPanel() {
const section = document.getElementById('tab-folders');
section.textContent = '';
const h2 = document.createElement('h2');
h2.className = 'account-panel-title';
h2.textContent = 'Folders';
section.appendChild(h2);
const toolbar = document.createElement('div');
toolbar.className = 'admin-toolbar';
const countEl = document.createElement('span');
countEl.id = 'adminFoldersCount';
countEl.className = 'admin-row-count';
countEl.textContent = 'Loading…';
const groupLabelEl = document.createElement('label');
groupLabelEl.className = 'admin-toolbar-label';
groupLabelEl.textContent = 'Group by';
const groupSelect = document.createElement('select');
groupSelect.className = 'admin-group-select';
FLD_GROUP_OPTIONS.forEach(opt => {
const o = document.createElement('option');
o.value = opt.value;
o.textContent = opt.label;
groupSelect.appendChild(o);
});
groupSelect.addEventListener('change', () => {
_fldGroupBy = groupSelect.value;
renderFoldersTable();
});
const refreshBtn = document.createElement('button');
refreshBtn.id = 'adminFoldersRefreshBtn';
refreshBtn.className = 'btn btn-ghost';
refreshBtn.textContent = 'Refresh';
refreshBtn.addEventListener('click', fetchFolders);
toolbar.appendChild(countEl);
toolbar.appendChild(groupLabelEl);
toolbar.appendChild(groupSelect);
toolbar.appendChild(refreshBtn);
const tableWrap = document.createElement('div');
tableWrap.id = 'adminFoldersTableWrap';
tableWrap.className = 'admin-table-wrap';
tableWrap.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-fld-action]');
if (btn) {
if (btn.disabled) return;
const { fldAction, fldId, fldLabel } = btn.dataset;
if (fldAction === 'delete') {
openDeleteFolderConfirm(fldId, fldLabel);
} else if (fldAction === 'make-public' || fldAction === 'make-private') {
doSetFolderPublic(fldId, fldAction === 'make-public', btn);
}
return;
}
const row = e.target.closest('tr.admin-table-row[data-fld-id]');
if (row) openFolderDrawer(row.dataset.fldId);
});
section.appendChild(toolbar);
section.appendChild(tableWrap);
fetchFolders();
}
/**
* Opens the delete confirmation dialog for a folder.
* @param {string} folderId - ID of the folder to delete.
* @param {string} label - Display name of the folder shown in the confirmation message.
*/
function openDeleteFolderConfirm(folderId, label) {
_confirmMsg.textContent = `Permanently delete folder "${label}"? All subfolders and projects inside will also be deleted. This cannot be undone.`;
_confirmOverlay.classList.add('open');
_confirmOkBtn.onclick = () => {
closeConfirm();
doDeleteFolder(folderId);
};
}
/**
* Sends a DELETE request for the given folder and removes it from the local list.
* @param {string} folderId - ID of the folder to delete.
*/
async function doDeleteFolder(folderId) {
try {
const headers = { 'Content-Type': 'application/json' };
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch(`/api/admin/folders/${encodeURIComponent(folderId)}`, {
method: 'DELETE', headers,
});
if (!resp.ok) throw new Error(resp.status);
_folders = _folders.filter(f => f.id !== folderId);
if (_drawerFolder?.id === folderId) closeFolderDrawer();
renderFoldersTable();
} catch {
// Silently ignore — user can retry
}
}
/**
* Toggles the public/private state of a folder via PATCH and updates the local list.
* @param {string} folderId - ID of the folder to update.
* @param {boolean} makePublic - True to make the folder public, false to make it private.
* @param {HTMLElement} btn - The button that triggered the action (disabled while in-flight).
*/
async function doSetFolderPublic(folderId, makePublic, btn) {
btn.disabled = true;
btn.textContent = '…';
try {
const headers = { 'Content-Type': 'application/json' };
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch(`/api/admin/folders/${encodeURIComponent(folderId)}/public`, {
method: 'PATCH', headers,
body: JSON.stringify({ public: makePublic }),
});
if (!resp.ok) throw new Error(resp.status);
const fld = _folders.find(f => f.id === folderId);
if (fld) fld.is_public = makePublic;
if (_drawerFolder?.id === folderId) {
_drawerFolder.is_public = makePublic;
renderFolderDrawerContent();
}
renderFoldersTable();
} catch {
btn.disabled = false;
btn.textContent = makePublic ? 'Make Public' : 'Make Private';
}
}
// ── Folder detail drawer ──────────────────────────────────────────────────────
const _fldDrawerOverlay = document.getElementById('folderDrawerOverlay');
const _fldDrawerPanel = document.getElementById('folderDrawerPanel');
/**
* Applies or clears the selected-row highlight in the folders table.
* @param {string|null} folderId - Folder ID to highlight, or null to clear.
*/
function _setFldRowSelected(folderId) {
document.querySelectorAll('#adminFoldersTableWrap tr.admin-table-row').forEach(r => {
r.classList.toggle('admin-table-row--selected', !!folderId && r.dataset.fldId === folderId);
});
}
/**
* Opens the folder detail drawer for the given folder.
* @param {string} folderId - ID of the folder to display.
*/
function openFolderDrawer(folderId) {
const fld = _folders.find(f => f.id === folderId);
if (!fld) return;
if (_drawerUser) closeDrawer();
if (_drawerProject) closeProjectDrawer();
_drawerFolder = fld;
renderFolderDrawerContent();
_fldDrawerOverlay.classList.add('open');
_setFldRowSelected(folderId);
}
/** Closes the folder detail drawer and clears the selection highlight. */
function closeFolderDrawer() {
_fldDrawerOverlay.classList.remove('open');
_drawerFolder = null;
_setFldRowSelected(null);
}
/** Re-renders the folder drawer panel from _drawerFolder. */
function renderFolderDrawerContent() {
const fld = _drawerFolder;
if (!fld) return;
const canEdit = fld.owner_id === null || fld.owner_id === _currentAdminId;
const publicBadge = fld.is_public
? '<span class="admin-badge admin-badge--admin">Public</span>' : '';
const badgeGroup = publicBadge
? `<div class="admin-badge-group" style="margin-top:0.4rem">${publicBadge}</div>` : '';
_fldDrawerPanel.textContent = '';
// Header
const fldHeader = document.createElement('div');
fldHeader.className = 'user-drawer-header';
const fldIdentity = document.createElement('div');
fldIdentity.className = 'user-drawer-identity';
fldIdentity.style.paddingTop = '0.1rem';
const fldNameEl = document.createElement('div');
fldNameEl.className = 'user-drawer-name';
fldNameEl.textContent = fld.name;
const fldEmailEl = document.createElement('div');
fldEmailEl.className = 'user-drawer-email';
fldEmailEl.textContent = fld.owner || '—';
fldIdentity.appendChild(fldNameEl);
fldIdentity.appendChild(fldEmailEl);
if (badgeGroup) fldIdentity.insertAdjacentHTML('beforeend', badgeGroup);
const fldCloseBtn = document.createElement('button');
fldCloseBtn.className = 'user-drawer-close';
fldCloseBtn.id = 'fldDrawerClose';
fldCloseBtn.title = 'Close';
fldCloseBtn.innerHTML = '<span class="icon icon-close" style="width:12px;height:12px;"></span>';
fldHeader.appendChild(fldIdentity);
fldHeader.appendChild(fldCloseBtn);
// Body
const fldBody = document.createElement('div');
fldBody.className = 'user-drawer-body';
const mkFldSec = (title, ...rows) => {
const sec = document.createElement('div');
sec.className = 'user-drawer-section';
const titleEl = document.createElement('div');
titleEl.className = 'user-drawer-section-title';
titleEl.textContent = title;
sec.appendChild(titleEl);
rows.forEach(r => sec.insertAdjacentHTML('beforeend', r));
return sec;
};
fldBody.appendChild(mkFldSec('Identity',
drawerRow('Folder ID', fld.id, true),
drawerRow('Parent', fld.parent_name || 'root' ),
drawerRow('Owner', fld.owner || '—' ),
));
fldBody.appendChild(mkFldSec('Contents',
drawerRow('Projects', String(fld.project_count) ),
drawerRow('Subfolders', String(fld.subfolder_count)),
));
fldBody.appendChild(mkFldSec('Timestamps',
drawerRow('Created', fmtDatetime(fld.created_at)),
));
if (canEdit) {
const actSec = document.createElement('div');
actSec.className = 'user-drawer-section';
const actTitle = document.createElement('div');
actTitle.className = 'user-drawer-section-title';
actTitle.textContent = 'Actions';
const actRow = document.createElement('div');
actRow.style.cssText = 'display:flex;gap:0.5rem;flex-wrap:wrap';
const pubBtn = document.createElement('button');
pubBtn.className = 'btn btn-ghost';
pubBtn.id = 'fldDrawerPubBtn';
pubBtn.dataset.action = fld.is_public ? 'make-private' : 'make-public';
pubBtn.dataset.fldId = fld.id;
pubBtn.textContent = fld.is_public ? 'Make Private' : 'Make Public';
const delBtn = document.createElement('button');
delBtn.className = 'btn btn-danger';
delBtn.id = 'fldDrawerDelBtn';
delBtn.dataset.fldId = fld.id;
delBtn.dataset.fldLabel = fld.name;
delBtn.textContent = 'Delete folder';
actRow.appendChild(pubBtn);
actRow.appendChild(delBtn);
actSec.appendChild(actTitle);
actSec.appendChild(actRow);
fldBody.appendChild(actSec);
}
_fldDrawerPanel.appendChild(fldHeader);
_fldDrawerPanel.appendChild(fldBody);
document.getElementById('fldDrawerClose').addEventListener('click', closeFolderDrawer);
const pubBtn = document.getElementById('fldDrawerPubBtn');
if (pubBtn) {
pubBtn.addEventListener('click', async () => {
const makePublic = pubBtn.dataset.action === 'make-public';
await doSetFolderPublic(fld.id, makePublic, pubBtn);
});
}
const delBtn = document.getElementById('fldDrawerDelBtn');
if (delBtn) {
delBtn.addEventListener('click', () => {
openDeleteFolderConfirm(delBtn.dataset.fldId, delBtn.dataset.fldLabel);
});
}
}
// ── Embeds panel ──────────────────────────────────────────────────────────────
let _embeds = [];
let _embSortCol = 'created_at';
let _embSortDir = 'desc';
let _embGroupBy = '';
let _drawerEmbed = null;
const EMB_COLS = [
{ key: 'id', label: 'Embed ID', minWidth: '120px' },
{ key: 'project_name', label: 'Project', minWidth: '160px' },
{ key: 'owner', label: 'Owner', minWidth: '160px' },
{ key: 'invoke_count', label: 'Invocations',minWidth: '90px' },
{ key: 'created_at', label: 'Created', minWidth: '110px' },
{ key: '_actions', label: '', minWidth: '80px', noSort: true },
];
const EMB_GROUP_OPTIONS = [
{ value: '', label: 'None' },
{ value: 'owner', label: 'Owner' },
{ value: 'project_name', label: 'Project' },
];
/**
* Renders an embeds table cell as an HTML string for the given column.
* @param {object} col - Column descriptor from EMB_COLS.
* @param {object} emb - Embed data object.
* @returns {string} Inner HTML for the cell.
*/
function fmtEmbCell(col, emb) {
const v = emb[col.key];
if (col.key === 'id') {
const trunc = v && v.length > 16 ? v.slice(0, 16) + '…' : v;
return `<span class="admin-cell-mono admin-cell-trunc" title="${esc(v)}">${esc(trunc)}</span>`;
}
if (col.key === 'invoke_count') {
return `<span class="admin-cell-mono">${v ?? 0}</span>`;
}
if (col.key === 'created_at') {
return `<span title="${esc(v ?? '')}">${fmtDate(v)}</span>`;
}
if (col.key === '_actions') {
return `<button class="btn btn-danger admin-btn-sm" data-emb-action="delete"
data-emb-id="${esc(emb.id)}"
data-emb-label="${esc(emb.id.slice(0, 8))}…">Delete</button>`;
}
if (!v) return '<span class="admin-cell-muted">—</span>';
return `<span>${esc(v)}</span>`;
}
/**
* Returns a sorted copy of the _embeds array based on the current sort column and direction.
* @returns {object[]} Sorted array of embed objects.
*/
function sortedEmbeds() {
return [..._embeds].sort((a, b) => {
const cmp = cmpValues(a[_embSortCol], b[_embSortCol]);
return _embSortDir === 'asc' ? cmp : -cmp;
});
}
/** Rebuilds and injects the embeds table HTML, then wires up interaction handlers. */
function renderEmbedsTable() {
const wrap = document.getElementById('adminEmbedsTableWrap');
const count = document.getElementById('adminEmbedsCount');
count.textContent = `${_embeds.length} embed${_embeds.length !== 1 ? 's' : ''}`;
const sorted = sortedEmbeds();
const thead = EMB_COLS.map(c => {
if (c.noSort) return `<th style="min-width:${c.minWidth}"></th>`;
const active = c.key === _embSortCol;
const arrow = active ? (_embSortDir === 'asc' ? ' ↑' : ' ↓') : '';
return `<th class="${active ? 'sorted' : ''}" data-col="${c.key}" style="min-width:${c.minWidth}">${c.label}${arrow}</th>`;
}).join('');
let tbody = '';
if (_embGroupBy) {
const groups = new Map();
for (const emb of sorted) {
const label = emb[_embGroupBy] || '—';
if (!groups.has(label)) groups.set(label, []);
groups.get(label).push(emb);
}
for (const [label, rows] of groups) {
tbody += `<tr class="admin-group-row"><td colspan="${EMB_COLS.length}">${esc(label)} · ${rows.length}</td></tr>`;
for (const emb of rows) {
tbody += `<tr class="admin-table-row" data-emb-id="${esc(emb.id)}">${EMB_COLS.map(c => `<td>${fmtEmbCell(c, emb)}</td>`).join('')}</tr>`;
}
}
} else {
for (const emb of sorted) {
tbody += `<tr class="admin-table-row" data-emb-id="${esc(emb.id)}">${EMB_COLS.map(c => `<td>${fmtEmbCell(c, emb)}</td>`).join('')}</tr>`;
}
}
if (!tbody) {
tbody = `<tr><td colspan="${EMB_COLS.length}" class="admin-table-empty">No embeds found.</td></tr>`;
}
const table = document.createElement('table');
table.className = 'admin-table';
const thead_el = document.createElement('thead');
const thead_tr = document.createElement('tr');
thead_tr.innerHTML = thead;
thead_el.appendChild(thead_tr);
const tbody_el = document.createElement('tbody');
tbody_el.innerHTML = tbody;
table.appendChild(thead_el);
table.appendChild(tbody_el);
wrap.textContent = '';
wrap.appendChild(table);
wrap.querySelectorAll('thead th[data-col]').forEach(th => {
th.addEventListener('click', () => {
const col = th.dataset.col;
_embSortDir = (_embSortCol === col && _embSortDir === 'asc') ? 'desc' : 'asc';
_embSortCol = col;
renderEmbedsTable();
});
});
if (_drawerEmbed) _setEmbRowSelected(_drawerEmbed.id);
}
/** Fetches the embed list from the API and re-renders the table. */
async function fetchEmbeds() {
const btn = document.getElementById('adminEmbedsRefreshBtn');
btn.disabled = true;
btn.textContent = 'Refreshing…';
try {
const headers = {};
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch('/api/admin/embeds', { headers });
if (!resp.ok) throw new Error(resp.status);
_embeds = await resp.json();
renderEmbedsTable();
} catch {
document.getElementById('adminEmbedsCount').textContent = 'Failed to load embeds.';
} finally {
btn.disabled = false;
btn.textContent = 'Refresh';
}
}
/** Builds and injects the embeds panel toolbar and table into the DOM, then fetches data. */
function initEmbedsPanel() {
const section = document.getElementById('tab-embeds');
section.textContent = '';
const h2 = document.createElement('h2');
h2.className = 'account-panel-title';
h2.textContent = 'Embeds';
section.appendChild(h2);
const toolbar = document.createElement('div');
toolbar.className = 'admin-toolbar';
const countEl = document.createElement('span');
countEl.id = 'adminEmbedsCount';
countEl.className = 'admin-row-count';
countEl.textContent = 'Loading…';
const groupLabelEl = document.createElement('label');
groupLabelEl.className = 'admin-toolbar-label';
groupLabelEl.textContent = 'Group by';
const groupSelect = document.createElement('select');
groupSelect.className = 'admin-group-select';
EMB_GROUP_OPTIONS.forEach(opt => {
const o = document.createElement('option');
o.value = opt.value;
o.textContent = opt.label;
groupSelect.appendChild(o);
});
groupSelect.addEventListener('change', () => {
_embGroupBy = groupSelect.value;
renderEmbedsTable();
});
const refreshBtn = document.createElement('button');
refreshBtn.id = 'adminEmbedsRefreshBtn';
refreshBtn.className = 'btn btn-ghost';
refreshBtn.textContent = 'Refresh';
refreshBtn.addEventListener('click', fetchEmbeds);
toolbar.appendChild(countEl);
toolbar.appendChild(groupLabelEl);
toolbar.appendChild(groupSelect);
toolbar.appendChild(refreshBtn);
const tableWrap = document.createElement('div');
tableWrap.id = 'adminEmbedsTableWrap';
tableWrap.className = 'admin-table-wrap';
tableWrap.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-emb-action]');
if (btn) {
if (btn.disabled) return;
if (btn.dataset.embAction === 'delete') {
openDeleteEmbedConfirm(btn.dataset.embId, btn.dataset.embLabel);
}
return;
}
const row = e.target.closest('tr.admin-table-row[data-emb-id]');
if (row) openEmbedDrawer(row.dataset.embId);
});
section.appendChild(toolbar);
section.appendChild(tableWrap);
fetchEmbeds();
}
/**
* Opens the delete confirmation dialog for an embed.
* @param {string} embedId - ID of the embed to delete.
* @param {string} label - Truncated embed ID shown in the confirmation message.
*/
function openDeleteEmbedConfirm(embedId, label) {
_confirmMsg.textContent = `Permanently delete embed "${label}"? Any pages using this embed will stop working.`;
_confirmOverlay.classList.add('open');
_confirmOkBtn.onclick = () => {
closeConfirm();
doDeleteEmbed(embedId);
};
}
/**
* Sends a DELETE request for the given embed and removes it from the local list.
* @param {string} embedId - ID of the embed to delete.
*/
async function doDeleteEmbed(embedId) {
try {
const headers = { 'Content-Type': 'application/json' };
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch(`/api/admin/embeds/${encodeURIComponent(embedId)}`, {
method: 'DELETE', headers,
});
if (!resp.ok) throw new Error(resp.status);
_embeds = _embeds.filter(e => e.id !== embedId);
if (_drawerEmbed?.id === embedId) closeEmbedDrawer();
renderEmbedsTable();
} catch {
// Silently ignore — user can retry
}
}
// ── Embed detail drawer ───────────────────────────────────────────────────────
const _embDrawerOverlay = document.getElementById('embedDrawerOverlay');
const _embDrawerPanel = document.getElementById('embedDrawerPanel');
/**
* Applies or clears the selected-row highlight in the embeds table.
* @param {string|null} embedId - Embed ID to highlight, or null to clear.
*/
function _setEmbRowSelected(embedId) {
document.querySelectorAll('#adminEmbedsTableWrap tr.admin-table-row').forEach(r => {
r.classList.toggle('admin-table-row--selected', !!embedId && r.dataset.embId === embedId);
});
}
/**
* Opens the embed detail drawer for the given embed.
* @param {string} embedId - ID of the embed to display.
*/
function openEmbedDrawer(embedId) {
const emb = _embeds.find(e => e.id === embedId);
if (!emb) return;
if (_drawerUser) closeDrawer();
if (_drawerProject) closeProjectDrawer();
if (_drawerFolder) closeFolderDrawer();
_drawerEmbed = emb;
renderEmbedDrawerContent();
_embDrawerOverlay.classList.add('open');
_setEmbRowSelected(embedId);
}
/** Closes the embed detail drawer and clears the selection highlight. */
function closeEmbedDrawer() {
_embDrawerOverlay.classList.remove('open');
_drawerEmbed = null;
_setEmbRowSelected(null);
}
/** Re-renders the embed drawer panel from _drawerEmbed. */
function renderEmbedDrawerContent() {
const emb = _drawerEmbed;
if (!emb) return;
_embDrawerPanel.textContent = '';
// Header
const embHeader = document.createElement('div');
embHeader.className = 'user-drawer-header';
const embIdentity = document.createElement('div');
embIdentity.className = 'user-drawer-identity';
embIdentity.style.paddingTop = '0.1rem';
const embNameEl = document.createElement('div');
embNameEl.className = 'user-drawer-name';
embNameEl.style.fontSize = 'var(--fs-hint)';
embNameEl.textContent = emb.id;
const embEmailEl = document.createElement('div');
embEmailEl.className = 'user-drawer-email';
embEmailEl.textContent = emb.owner || '—';
embIdentity.appendChild(embNameEl);
embIdentity.appendChild(embEmailEl);
const embCloseBtn = document.createElement('button');
embCloseBtn.className = 'user-drawer-close';
embCloseBtn.id = 'embDrawerClose';
embCloseBtn.title = 'Close';
embCloseBtn.innerHTML = '<span class="icon icon-close" style="width:12px;height:12px;"></span>';
embHeader.appendChild(embIdentity);
embHeader.appendChild(embCloseBtn);
// Body
const embBody = document.createElement('div');
embBody.className = 'user-drawer-body';
const mkEmbSec = (title, ...rows) => {
const sec = document.createElement('div');
sec.className = 'user-drawer-section';
const titleEl = document.createElement('div');
titleEl.className = 'user-drawer-section-title';
titleEl.textContent = title;
sec.appendChild(titleEl);
rows.forEach(r => sec.insertAdjacentHTML('beforeend', r));
return sec;
};
embBody.appendChild(mkEmbSec('Identity',
drawerRow('Embed ID', emb.id, true),
drawerRow('Project', emb.project_name ),
drawerRow('Project ID', emb.project_id, true),
drawerRow('Owner', emb.owner ),
));
embBody.appendChild(mkEmbSec('Stats',
drawerRow('Invocations', String(emb.invoke_count ?? 0)),
drawerRow('Created', fmtDatetime(emb.created_at) ),
));
const embActSec = document.createElement('div');
embActSec.className = 'user-drawer-section';
const embActTitle = document.createElement('div');
embActTitle.className = 'user-drawer-section-title';
embActTitle.textContent = 'Actions';
const embDelBtn = document.createElement('button');
embDelBtn.className = 'btn btn-danger';
embDelBtn.id = 'embDrawerDelBtn';
embDelBtn.dataset.embId = emb.id;
embDelBtn.textContent = 'Delete embed';
embActSec.appendChild(embActTitle);
embActSec.appendChild(embDelBtn);
embBody.appendChild(embActSec);
_embDrawerPanel.appendChild(embHeader);
_embDrawerPanel.appendChild(embBody);
document.getElementById('embDrawerClose').addEventListener('click', closeEmbedDrawer);
document.getElementById('embDrawerDelBtn').addEventListener('click', () => {
openDeleteEmbedConfirm(emb.id, emb.id.slice(0, 8) + '…');
});
}
// ── Subscriptions panel ───────────────────────────────────────────────────────
let _subs = [];
let _subSortCol = 'sort_order';
let _subSortDir = 'asc';
let _subGroupBy = '';
let _drawerSub = null; // null = new, object = editing existing
let _subIsNew = false;
const SUB_GROUP_OPTIONS = [
{ value: '', label: 'None' },
{ value: 'billing_period', label: 'Billing' },
];
const SUB_COLS = [
{ key: 'display_name', label: 'Display Name', minWidth: '110px' },
{ key: 'name', label: 'Key', minWidth: '120px' },
{ key: 'billing_period', label: 'Billing', minWidth: '80px' },
{ key: 'sort_order', label: 'Order', minWidth: '60px' },
{ key: 'price', label: 'Price / mo', minWidth: '90px' },
{ key: 'user_count', label: 'Users', minWidth: '65px' },
{ key: 'stripe_product_id',label: 'Stripe Product',minWidth: '160px' },
{ key: 'modified_at', label: 'Modified', minWidth: '110px' },
{ key: '_actions', label: '', minWidth: '140px', noSort: true },
];
/**
* Extracts a flat sortable value for virtual subscription columns.
* @param {string} col - Column key.
* @param {object} sub - Subscription tier object.
* @returns {*} Sortable value for the column.
*/
function _subVal(col, sub) {
if (col === 'display_name') return sub.features?.display_name ?? sub.name;
if (col === 'sort_order') return sub.features?.sort_order ?? 0;
if (col === 'price') return sub.features?.price_usd_month ?? 0;
return sub[col];
}
/**
* Renders a subscriptions table cell as an HTML string for the given column key.
* @param {string} col - Column key string.
* @param {object} sub - Subscription tier data object.
* @returns {string} Inner HTML for the cell.
*/
function fmtSubCell(col, sub) {
if (col === 'display_name') {
const dn = sub.features?.display_name;
return dn ? `<span>${esc(dn)}</span>` : '<span class="admin-cell-muted">—</span>';
}
if (col === 'sort_order') {
const v = sub.features?.sort_order;
return v != null ? `<span class="admin-cell-mono">${v}</span>` : '<span class="admin-cell-muted">—</span>';
}
if (col === 'price') {
const p = sub.features?.price_usd_month;
return p != null ? `<span class="admin-cell-mono">$${Number(p).toFixed(2)}</span>`
: '<span class="admin-cell-muted">—</span>';
}
if (col === 'user_count') {
return `<span class="admin-cell-mono">${sub.user_count ?? 0}</span>`;
}
if (col === 'billing_period') {
const v = sub[col];
return v ? `<span class="admin-badge admin-badge--active" style="text-transform:none;letter-spacing:0">${esc(v)}</span>`
: '<span class="admin-cell-muted">—</span>';
}
if (col === 'modified_at') {
return `<span title="${esc(sub.modified_at ?? '')}">${fmtDate(sub.modified_at)}</span>`;
}
if (col === 'stripe_product_id') {
const pid = sub.stripe_product_id;
if (pid) return `<span class="admin-cell-mono" title="${esc(pid)}">${esc(pid)}</span>`;
const price = sub.features?.price_usd_month ?? 0;
if (!price) return '<span class="admin-cell-muted">free</span>';
return '<span class="admin-cell-muted">not synced</span>';
}
if (col === '_actions') {
const price = sub.features?.price_usd_month ?? 0;
const needsSync = price > 0 && !sub.stripe_product_id;
const syncBtn = needsSync
? `<button class="btn btn-ghost admin-btn-sm" data-sub-action="sync-stripe"
data-sub-id="${esc(sub.id)}"
data-sub-label="${esc(sub.features?.display_name ?? sub.name)}">Sync Stripe</button> `
: '';
return `${syncBtn}<button class="btn btn-danger admin-btn-sm" data-sub-action="delete"
data-sub-id="${esc(sub.id)}"
data-sub-label="${esc(sub.features?.display_name ?? sub.name)}">Delete</button>`;
}
const v = sub[col];
if (!v && v !== 0) return '<span class="admin-cell-muted">—</span>';
return `<span>${esc(v)}</span>`;
}
/**
* Returns a sorted copy of the _subs array based on the current sort column and direction.
* @returns {object[]} Sorted array of subscription tier objects.
*/
function sortedSubs() {
return [..._subs].sort((a, b) => {
const cmp = cmpValues(_subVal(_subSortCol, a), _subVal(_subSortCol, b));
return _subSortDir === 'asc' ? cmp : -cmp;
});
}
/** Rebuilds and injects the subscriptions table HTML, then wires up interaction handlers. */
function renderSubsTable() {
const wrap = document.getElementById('adminSubsTableWrap');
const count = document.getElementById('adminSubsCount');
count.textContent = `${_subs.length} tier${_subs.length !== 1 ? 's' : ''}`;
const sorted = sortedSubs();
const thead = SUB_COLS.map(c => {
if (c.noSort) return `<th style="min-width:${c.minWidth}"></th>`;
const active = c.key === _subSortCol;
const arrow = active ? (_subSortDir === 'asc' ? ' ↑' : ' ↓') : '';
return `<th class="${active ? 'sorted' : ''}" data-col="${c.key}" style="min-width:${c.minWidth}">${c.label}${arrow}</th>`;
}).join('');
let tbody = '';
if (_subGroupBy) {
const groups = new Map();
for (const sub of sorted) {
const label = _subVal(_subGroupBy, sub) || '—';
if (!groups.has(label)) groups.set(label, []);
groups.get(label).push(sub);
}
for (const [label, rows] of groups) {
tbody += `<tr class="admin-group-row"><td colspan="${SUB_COLS.length}">${esc(String(label))} · ${rows.length}</td></tr>`;
for (const sub of rows) {
tbody += `<tr class="admin-table-row" data-sub-id="${esc(sub.id)}">${SUB_COLS.map(c => `<td>${fmtSubCell(c.key, sub)}</td>`).join('')}</tr>`;
}
}
} else {
for (const sub of sorted) {
tbody += `<tr class="admin-table-row" data-sub-id="${esc(sub.id)}">${SUB_COLS.map(c => `<td>${fmtSubCell(c.key, sub)}</td>`).join('')}</tr>`;
}
}
if (!tbody) tbody = `<tr><td colspan="${SUB_COLS.length}" class="admin-table-empty">No subscription tiers found.</td></tr>`;
const table = document.createElement('table');
table.className = 'admin-table';
const thead_el = document.createElement('thead');
const thead_tr = document.createElement('tr');
thead_tr.innerHTML = thead;
thead_el.appendChild(thead_tr);
const tbody_el = document.createElement('tbody');
tbody_el.innerHTML = tbody;
table.appendChild(thead_el);
table.appendChild(tbody_el);
wrap.textContent = '';
wrap.appendChild(table);
wrap.querySelectorAll('thead th[data-col]').forEach(th => {
th.addEventListener('click', () => {
const col = th.dataset.col;
_subSortDir = (_subSortCol === col && _subSortDir === 'asc') ? 'desc' : 'asc';
_subSortCol = col;
renderSubsTable();
});
});
if (_drawerSub) _setSubRowSelected(_drawerSub?.id ?? null);
}
/** Fetches the subscription tier list from the API and re-renders the table. */
async function fetchSubs() {
const btn = document.getElementById('adminSubsRefreshBtn');
btn.disabled = true;
btn.textContent = 'Refreshing…';
try {
const headers = {};
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch('/api/admin/subscriptions', { headers });
if (!resp.ok) throw new Error(resp.status);
_subs = await resp.json();
renderSubsTable();
} catch {
document.getElementById('adminSubsCount').textContent = 'Failed to load tiers.';
} finally {
btn.disabled = false;
btn.textContent = 'Refresh';
}
}
/** Builds and injects the subscriptions panel toolbar and table into the DOM, then fetches data. */
function initSubsPanel() {
const section = document.getElementById('tab-subscriptions');
section.textContent = '';
const h2 = document.createElement('h2');
h2.className = 'account-panel-title';
h2.textContent = 'Subscriptions';
section.appendChild(h2);
const toolbar = document.createElement('div');
toolbar.className = 'admin-toolbar';
const countEl = document.createElement('span');
countEl.id = 'adminSubsCount';
countEl.className = 'admin-row-count';
countEl.textContent = 'Loading…';
const groupLabelEl = document.createElement('label');
groupLabelEl.className = 'admin-toolbar-label';
groupLabelEl.textContent = 'Group by';
const groupSelect = document.createElement('select');
groupSelect.className = 'admin-group-select';
SUB_GROUP_OPTIONS.forEach(opt => {
const o = document.createElement('option');
o.value = opt.value;
o.textContent = opt.label;
groupSelect.appendChild(o);
});
groupSelect.addEventListener('change', () => {
_subGroupBy = groupSelect.value;
renderSubsTable();
});
const newBtn = document.createElement('button');
newBtn.className = 'btn btn-primary';
newBtn.textContent = '+ New Tier';
newBtn.addEventListener('click', () => openSubDrawer(null));
const refreshBtn = document.createElement('button');
refreshBtn.id = 'adminSubsRefreshBtn';
refreshBtn.className = 'btn btn-ghost';
refreshBtn.textContent = 'Refresh';
refreshBtn.addEventListener('click', fetchSubs);
toolbar.appendChild(countEl);
toolbar.appendChild(groupLabelEl);
toolbar.appendChild(groupSelect);
toolbar.appendChild(newBtn);
toolbar.appendChild(refreshBtn);
const tableWrap = document.createElement('div');
tableWrap.id = 'adminSubsTableWrap';
tableWrap.className = 'admin-table-wrap';
tableWrap.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-sub-action]');
if (btn) {
if (btn.disabled) return;
if (btn.dataset.subAction === 'delete') {
openDeleteSubConfirm(btn.dataset.subId, btn.dataset.subLabel);
} else if (btn.dataset.subAction === 'sync-stripe') {
doSyncStripe(btn, btn.dataset.subId);
}
return;
}
const row = e.target.closest('tr.admin-table-row[data-sub-id]');
if (row) openSubDrawer(row.dataset.subId);
});
section.appendChild(toolbar);
section.appendChild(tableWrap);
fetchSubs();
}
/**
* Opens the delete confirmation dialog for a subscription tier.
* @param {string} tierId - ID of the tier to delete.
* @param {string} label - Display name of the tier shown in the confirmation message.
*/
function openDeleteSubConfirm(tierId, label) {
const sub = _subs.find(s => s.id === tierId);
const userWarn = sub?.user_count
? ` ${sub.user_count} user(s) are currently on this tier and will lose their subscription.`
: '';
_confirmMsg.textContent = `Permanently delete the "${label}" tier?${userWarn} This cannot be undone.`;
_confirmOverlay.classList.add('open');
_confirmOkBtn.onclick = () => {
closeConfirm();
doDeleteSub(tierId);
};
}
/**
* Sends a DELETE request for the given subscription tier and removes it from the local list.
* @param {string} tierId - ID of the tier to delete.
*/
async function doDeleteSub(tierId) {
try {
const headers = { 'Content-Type': 'application/json' };
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch(`/api/admin/subscriptions/${encodeURIComponent(tierId)}`, {
method: 'DELETE', headers,
});
if (!resp.ok) {
let err = {};
try { err = await resp.json(); } catch {}
alert(err.error || 'Delete failed.');
return;
}
_subs = _subs.filter(s => s.id !== tierId);
if (_drawerSub?.id === tierId) closeSubDrawer();
renderSubsTable();
} catch {
// Silently ignore
}
}
/**
* Calls the sync-stripe endpoint for a tier and updates the local list on success.
* @param {HTMLButtonElement} btn - The button that was clicked (used for loading state).
* @param {string} tierId - ID of the tier to sync.
*/
async function doSyncStripe(btn, tierId) {
btn.disabled = true;
btn.textContent = 'Syncing…';
try {
const headers = { 'Content-Type': 'application/json' };
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch(`/api/admin/subscriptions/${encodeURIComponent(tierId)}/sync-stripe`, {
method: 'POST', headers,
});
let body = {};
try { body = await resp.json(); } catch {}
if (!resp.ok) {
alert(body.error || 'Stripe sync failed.');
btn.disabled = false;
btn.textContent = 'Sync Stripe';
return;
}
const sub = _subs.find(s => s.id === tierId);
if (sub) {
sub.stripe_product_id = body.stripe_product_id;
sub.stripe_price_id_monthly = body.stripe_price_id_monthly;
sub.stripe_price_id_yearly = body.stripe_price_id_yearly;
}
renderSubsTable();
} catch {
btn.disabled = false;
btn.textContent = 'Sync Stripe';
}
}
// ── Subscription edit drawer ──────────────────────────────────────────────────
const _subDrawerOverlay = document.getElementById('subDrawerOverlay');
const _subDrawerPanel = document.getElementById('subDrawerPanel');
/**
* Applies or clears the selected-row highlight in the subscriptions table.
* @param {string|null} subId - Subscription tier ID to highlight, or null to clear.
*/
function _setSubRowSelected(subId) {
document.querySelectorAll('#adminSubsTableWrap tr.admin-table-row').forEach(r => {
r.classList.toggle('admin-table-row--selected', !!subId && r.dataset.subId === subId);
});
}
/**
* Opens the subscription edit drawer for an existing tier or for creating a new one.
* @param {string|null} subId - ID of the subscription tier to edit, or null to create a new tier.
*/
function openSubDrawer(subId) {
if (_drawerUser) closeDrawer();
if (_drawerProject) closeProjectDrawer();
if (_drawerFolder) closeFolderDrawer();
if (_drawerEmbed) closeEmbedDrawer();
_subIsNew = subId === null;
_drawerSub = _subIsNew ? null : (_subs.find(s => s.id === subId) ?? null);
renderSubDrawerContent();
_subDrawerOverlay.classList.add('open');
_setSubRowSelected(_subIsNew ? null : subId);
}
/** Closes the subscription edit drawer and clears the selection highlight. */
function closeSubDrawer() {
_subDrawerOverlay.classList.remove('open');
_drawerSub = null;
_setSubRowSelected(null);
}
/** Re-renders the subscription edit drawer panel from _drawerSub. */
function renderSubDrawerContent() {
const sub = _drawerSub;
const title = _subIsNew ? 'New Tier' : (sub?.features?.display_name ?? sub?.name ?? 'Edit Tier');
const name = sub?.name ?? '';
const desc = sub?.description ?? '';
const billing = sub?.billing_period ?? 'monthly';
const featJson = JSON.stringify(sub?.features ?? {}, null, 2);
const userCount = sub?.user_count ?? 0;
_subDrawerPanel.textContent = '';
// Header
const subHeader = document.createElement('div');
subHeader.className = 'user-drawer-header';
const subIdentity = document.createElement('div');
subIdentity.className = 'user-drawer-identity';
subIdentity.style.paddingTop = '0.1rem';
const subTitleEl = document.createElement('div');
subTitleEl.className = 'user-drawer-name';
subTitleEl.textContent = title;
subIdentity.appendChild(subTitleEl);
if (!_subIsNew) {
const subCountEl = document.createElement('div');
subCountEl.className = 'user-drawer-email';
subCountEl.textContent = `${userCount} user${userCount !== 1 ? 's' : ''}`;
subIdentity.appendChild(subCountEl);
}
const subCloseBtn = document.createElement('button');
subCloseBtn.className = 'user-drawer-close';
subCloseBtn.id = 'subDrawerClose';
subCloseBtn.title = 'Close';
subCloseBtn.innerHTML = '<span class="icon icon-close" style="width:12px;height:12px;"></span>';
subHeader.appendChild(subIdentity);
subHeader.appendChild(subCloseBtn);
// Body
const subBody = document.createElement('div');
subBody.className = 'user-drawer-body';
// Details section
const detailsSec = document.createElement('div');
detailsSec.className = 'user-drawer-section';
const detailsTitle = document.createElement('div');
detailsTitle.className = 'user-drawer-section-title';
detailsTitle.textContent = 'Details';
detailsSec.appendChild(detailsTitle);
const mkFormGroup = (labelText, labelFor, inputEl) => {
const grp = document.createElement('div');
grp.className = 'sub-form-group';
const lbl = document.createElement('label');
lbl.className = 'sub-form-label';
lbl.htmlFor = labelFor;
lbl.textContent = labelText;
grp.appendChild(lbl);
grp.appendChild(inputEl);
return grp;
};
const nameInput = document.createElement('input');
nameInput.className = 'sub-form-input';
nameInput.id = 'subFormName';
nameInput.type = 'text';
nameInput.value = name;
nameInput.placeholder = 'e.g. pro_monthly';
detailsSec.appendChild(mkFormGroup('Name (key)', 'subFormName', nameInput));
const descInput = document.createElement('input');
descInput.className = 'sub-form-input';
descInput.id = 'subFormDesc';
descInput.type = 'text';
descInput.value = desc;
descInput.placeholder = 'Short description';
detailsSec.appendChild(mkFormGroup('Description', 'subFormDesc', descInput));
const billingSelect = document.createElement('select');
billingSelect.className = 'sub-form-input';
billingSelect.id = 'subFormBilling';
const optMonthly = document.createElement('option');
optMonthly.value = 'monthly';
optMonthly.textContent = 'Monthly';
optMonthly.selected = billing === 'monthly';
const optYearly = document.createElement('option');
optYearly.value = 'yearly';
optYearly.textContent = 'Yearly';
optYearly.selected = billing === 'yearly';
billingSelect.appendChild(optMonthly);
billingSelect.appendChild(optYearly);
detailsSec.appendChild(mkFormGroup('Billing Period', 'subFormBilling', billingSelect));
subBody.appendChild(detailsSec);
// Features section
const featSec = document.createElement('div');
featSec.className = 'user-drawer-section';
const featTitle = document.createElement('div');
featTitle.className = 'user-drawer-section-title';
featTitle.textContent = 'Features (JSON)';
const featTextarea = document.createElement('textarea');
featTextarea.className = 'sub-features-textarea';
featTextarea.id = 'subFormFeatures';
featTextarea.value = featJson;
const featError = document.createElement('p');
featError.className = 'sub-form-error';
featError.id = 'subFormError';
featSec.appendChild(featTitle);
featSec.appendChild(featTextarea);
featSec.appendChild(featError);
subBody.appendChild(featSec);
// Actions section
const actSec = document.createElement('div');
actSec.className = 'user-drawer-section';
const actTitle = document.createElement('div');
actTitle.className = 'user-drawer-section-title';
actTitle.textContent = 'Actions';
const actRow = document.createElement('div');
actRow.style.cssText = 'display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center';
const saveBtn = document.createElement('button');
saveBtn.className = 'btn btn-primary';
saveBtn.id = 'subFormSave';
saveBtn.textContent = _subIsNew ? 'Create' : 'Save changes';
const cancelBtn = document.createElement('button');
cancelBtn.className = 'btn btn-ghost';
cancelBtn.id = 'subFormCancel';
cancelBtn.textContent = 'Cancel';
actRow.appendChild(saveBtn);
actRow.appendChild(cancelBtn);
if (!_subIsNew) {
const deleteBtn = document.createElement('button');
deleteBtn.className = 'btn btn-danger';
deleteBtn.id = 'subFormDelete';
deleteBtn.dataset.subId = sub.id;
deleteBtn.dataset.subLabel = sub.features?.display_name ?? sub.name;
deleteBtn.textContent = 'Delete tier';
actRow.appendChild(deleteBtn);
}
const saveError = document.createElement('p');
saveError.className = 'sub-form-error';
saveError.id = 'subSaveError';
actSec.appendChild(actTitle);
actSec.appendChild(actRow);
actSec.appendChild(saveError);
subBody.appendChild(actSec);
_subDrawerPanel.appendChild(subHeader);
_subDrawerPanel.appendChild(subBody);
document.getElementById('subDrawerClose').addEventListener('click', closeSubDrawer);
document.getElementById('subFormCancel').addEventListener('click', closeSubDrawer);
document.getElementById('subFormSave').addEventListener('click', () => doSaveSub());
const delBtn = document.getElementById('subFormDelete');
if (delBtn) {
delBtn.addEventListener('click', () => {
openDeleteSubConfirm(delBtn.dataset.subId, delBtn.dataset.subLabel);
});
}
}
/** Validates the subscription form and POSTs or PATCHes the tier via the API. */
async function doSaveSub() {
const nameVal = document.getElementById('subFormName').value.trim();
const descVal = document.getElementById('subFormDesc').value.trim();
const billingVal = document.getElementById('subFormBilling').value;
const featRaw = document.getElementById('subFormFeatures').value;
const featEl = document.getElementById('subFormFeatures');
const errorEl = document.getElementById('subFormError');
const saveErrEl = document.getElementById('subSaveError');
// Validate JSON
let featObj;
try {
featObj = JSON.parse(featRaw);
featEl.classList.remove('error');
errorEl.textContent = '';
} catch (e) {
featEl.classList.add('error');
errorEl.textContent = `Invalid JSON: ${e.message}`;
return;
}
if (!nameVal) {
saveErrEl.textContent = 'Name is required.';
return;
}
const saveBtn = document.getElementById('subFormSave');
saveBtn.disabled = true;
saveBtn.textContent = 'Saving…';
saveErrEl.textContent = '';
try {
const headers = { 'Content-Type': 'application/json' };
if (authToken) headers['X-Auth-Token'] = authToken;
const body = JSON.stringify({
name: nameVal, description: descVal,
billing_period: billingVal, features: featObj,
});
let resp;
if (_subIsNew) {
resp = await fetch('/api/admin/subscriptions', { method: 'POST', headers, body });
} else {
resp = await fetch(`/api/admin/subscriptions/${encodeURIComponent(_drawerSub.id)}`,
{ method: 'PATCH', headers, body });
}
if (!resp.ok) {
let err = {};
try { err = await resp.json(); } catch {}
saveErrEl.textContent = err.error || 'Save failed.';
return;
}
const saved = await resp.json();
saved.user_count = _subIsNew ? 0 : (_drawerSub?.user_count ?? 0);
if (_subIsNew) {
_subs.push(saved);
} else {
const idx = _subs.findIndex(s => s.id === saved.id);
if (idx !== -1) _subs[idx] = saved;
}
_drawerSub = saved;
_subIsNew = false;
renderSubsTable();
renderSubDrawerContent(); // refresh header (new name/user count)
_setSubRowSelected(saved.id);
} catch {
saveErrEl.textContent = 'Unexpected error. Please try again.';
} finally {
const btn = document.getElementById('subFormSave');
if (btn) { btn.disabled = false; btn.textContent = _subIsNew ? 'Create' : 'Save changes'; }
}
}
// ── Click-outside to close drawers ────────────────────────────────────────────
document.addEventListener('click', (e) => {
// Clicks inside an open drawer or on a table row are handled elsewhere
if (e.target.closest('tr.admin-table-row')) return;
if (_drawerUser && !_drawerPanel.contains(e.target)) closeDrawer();
if (_drawerProject && !_projDrawerPanel.contains(e.target)) closeProjectDrawer();
if (_drawerFolder && !_fldDrawerPanel.contains(e.target)) closeFolderDrawer();
if (_drawerEmbed && !_embDrawerPanel.contains(e.target)) closeEmbedDrawer();
if (_drawerSub && !_subDrawerPanel.contains(e.target)) closeSubDrawer();
});
// ── Delete user confirm dialog ────────────────────────────────────────────────
_confirmCancelBtn.addEventListener('click', closeConfirm);
_confirmOverlay.addEventListener('click', (e) => {
if (e.target === _confirmOverlay) closeConfirm();
});
/**
* Opens the delete confirmation dialog for a user account.
* @param {string} userId - ID of the user to delete.
* @param {string} displayLabel - Display name or email shown in the confirmation message.
*/
function openDeleteConfirm(userId, displayLabel) {
_confirmMsg.textContent = `Permanently delete "${displayLabel}"? This cannot be undone. Their projects and folders will remain but become ownerless.`;
_confirmCallback = () => doDeleteUser(userId);
_confirmOverlay.classList.add('open');
_confirmOkBtn.onclick = () => {
closeConfirm();
if (_confirmCallback) _confirmCallback();
_confirmCallback = null;
};
}
/** Closes the confirmation dialog and clears any pending callback. */
function closeConfirm() {
_confirmOverlay.classList.remove('open');
_confirmCallback = null;
}
/**
* Deletes a user account, removes them from _users, closes the drawer, and re-renders the table.
* @param {string} userId - ID of the user to delete.
*/
async function doDeleteUser(userId) {
try {
const headers = { 'Content-Type': 'application/json' };
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch(`/api/admin/users/${encodeURIComponent(userId)}`, {
method: 'DELETE', headers,
});
if (!resp.ok) throw new Error(resp.status);
_users = _users.filter(u => u.id !== userId);
if (_drawerUser?.id === userId) closeDrawer();
renderTable();
} catch {
// Silently ignore — user can retry
}
}
// ── Analytics panel ───────────────────────────────────────────────────────────
const ANALYTICS_METRICS = [
{ key: 'total_users', label: 'Total Users', color: '#60a5fa' },
{ key: 'active_users_30d', label: 'Active Users (30d)', color: '#34d399' },
{ key: 'total_subscribers', label: 'Paid Subscribers', color: '#2dd4bf' },
{ key: 'total_projects', label: 'Total Projects', color: '#a78bfa' },
{ key: 'transcribed_projects', label: 'Transcribed Projects', color: '#fb923c' },
{ key: 'total_audio_seconds', label: 'Audio Duration (hrs)', color: '#f472b6' },
{ key: 'total_embeds', label: 'Total Embeds', color: '#facc15' },
{ key: 'total_embed_views', label: 'Embed Views', color: '#38bdf8' },
{ key: 'storage_bytes', label: 'Storage', color: '#4ade80' },
{ key: 'mrr', label: 'MRR ($)', color: '#f87171' },
];
let _analyticsSnapshots = [];
let _analyticsChart = null;
let _analyticsVisibleKeys = new Set(ANALYTICS_METRICS.map(m => m.key));
let _pageViewsData = [];
let _pageViewsChart = null;
let _analyticsRange = 'all'; // '7d' | '30d' | '90d' | '1y' | 'all' | 'custom'
let _analyticsUnit = 'auto'; // 'auto' | 'day' | 'week' | 'month'
let _analyticsStartDate = ''; // ISO date string (custom range)
let _analyticsEndDate = ''; // ISO date string (custom range)
const ANALYTICS_RANGES = [
{ value: '7d', label: 'Last 7 days' },
{ value: '30d', label: 'Last 30 days' },
{ value: '90d', label: 'Last 90 days' },
{ value: '1y', label: 'Last year' },
{ value: 'all', label: 'All time' },
{ value: 'custom',label: 'Custom…' },
];
const ANALYTICS_UNITS = [
{ value: 'auto', label: 'Auto' },
{ value: 'day', label: 'Day' },
{ value: 'week', label: 'Week' },
{ value: 'month', label: 'Month' },
];
/**
* Returns the {min, max} Date objects for the current analytics range selection.
* @returns {{ min: Date|null, max: Date|null }} Axis bounds; null means unbounded.
*/
function _getAnalyticsAxisRange() {
const now = new Date();
switch (_analyticsRange) {
case '7d': return { min: new Date(now - 7 * 86400000), max: now };
case '30d': return { min: new Date(now - 30 * 86400000), max: now };
case '90d': return { min: new Date(now - 90 * 86400000), max: now };
case '1y': return { min: new Date(now - 365 * 86400000), max: now };
case 'all': return { min: null, max: null };
case 'custom': return {
min: _analyticsStartDate ? new Date(_analyticsStartDate) : null,
max: _analyticsEndDate ? new Date(_analyticsEndDate + 'T23:59:59') : null,
};
}
return { min: null, max: null };
}
/**
* Returns analytics snapshots filtered to the current date range.
* @returns {object[]} Filtered array of snapshot objects.
*/
function _getFilteredSnapshots() {
const { min, max } = _getAnalyticsAxisRange();
return _analyticsSnapshots.filter(s => {
const d = new Date(s.recorded_at);
if (min && d < min) return false;
if (max && d > max) return false;
return true;
});
}
/**
* Formats an analytics metric value for display on a summary card.
* @param {string} key - Metric key (e.g. 'total_audio_seconds', 'storage_bytes').
* @param {number|null} value - Raw metric value.
* @returns {string} Human-readable formatted value.
*/
function _fmtAnalyticsCard(key, value) {
if (value == null) return '—';
switch (key) {
case 'total_audio_seconds': {
const h = value / 3600;
return h >= 1 ? h.toFixed(1) + ' hr' : (value / 60).toFixed(1) + ' min';
}
case 'storage_bytes': return _fmtBytes(value);
case 'mrr':
case 'prev_month_mrr':
case 'est_next_month_mrr': return '$' + Number(value).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
default: return Number(value).toLocaleString();
}
}
/**
* Formats a byte count as a human-readable string (B, KB, MB, GB).
* @param {number} b - Byte count.
* @returns {string} Formatted size string.
*/
function _fmtBytes(b) {
if (b >= 1e9) return (b / 1e9).toFixed(2) + ' GB';
if (b >= 1e6) return (b / 1e6).toFixed(1) + ' MB';
if (b >= 1e3) return (b / 1e3).toFixed(0) + ' KB';
return b + ' B';
}
/**
* Converts a hex colour string and alpha to an rgba() CSS string.
* @param {string} hex - Six-digit hex colour (e.g. '#60a5fa').
* @param {number} alpha - Alpha value between 0 and 1.
* @returns {string} CSS rgba() colour string.
*/
function _hexToRgba(hex, alpha) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
/** Synchronises the metric dropdown value to reflect the current _analyticsVisibleKeys state. */
function _syncMetricDropdown() {
const sel = document.getElementById('adminAnalyticsMetricSelect');
if (!sel) return;
const allVisible = ANALYTICS_METRICS.every(m => _analyticsVisibleKeys.has(m.key));
const isSingle = _analyticsVisibleKeys.size === 1;
let customOpt = sel.querySelector('option[value="custom"]');
if (!allVisible && !isSingle) {
if (!customOpt) {
customOpt = document.createElement('option');
customOpt.value = 'custom';
customOpt.textContent = 'Custom';
sel.appendChild(customOpt);
}
sel.value = 'custom';
} else {
customOpt?.remove();
sel.value = allVisible ? 'all' : [..._analyticsVisibleKeys][0];
}
}
/**
* Toggles visibility of a metric on the analytics chart.
* @param {string} key - Metric key to toggle.
*/
function _toggleMetric(key) {
if (_analyticsVisibleKeys.has(key)) {
if (_analyticsVisibleKeys.size <= 1) return; // keep at least one visible
_analyticsVisibleKeys.delete(key);
} else {
_analyticsVisibleKeys.add(key);
}
_syncMetricDropdown();
const latest = _analyticsSnapshots[_analyticsSnapshots.length - 1];
if (latest) renderAnalyticsCards(latest.data);
renderAnalyticsChart();
}
/**
* Highlights a single metric dataset on the chart, dimming all others.
* @param {string} key - Metric key to highlight.
*/
function _highlightChartMetric(key) {
if (!_analyticsChart || _analyticsVisibleKeys.size <= 1) return;
const visibleMetrics = ANALYTICS_METRICS.filter(m => _analyticsVisibleKeys.has(m.key));
_analyticsChart.data.datasets.forEach((ds, i) => {
const m = visibleMetrics[i];
if (!m) return;
const active = m.key === key;
ds.borderColor = active ? m.color : _hexToRgba(m.color, 0.15);
ds.backgroundColor = active ? m.color + '33' : 'transparent';
ds.borderWidth = active ? 3 : 1;
ds.order = active ? 0 : 1;
});
_analyticsChart.update('none');
}
/** Resets all chart datasets to their default colours after a hover highlight. */
function _resetChartHighlight() {
if (!_analyticsChart) return;
const visibleMetrics = ANALYTICS_METRICS.filter(m => _analyticsVisibleKeys.has(m.key));
_analyticsChart.data.datasets.forEach((ds, i) => {
const m = visibleMetrics[i];
if (!m) return;
ds.borderColor = m.color;
ds.backgroundColor = visibleMetrics.length === 1 ? m.color + '33' : m.color + '22';
ds.borderWidth = 2;
ds.order = 0;
});
_analyticsChart.update('none');
}
/**
* Converts a raw metric value to the unit used on the chart axis.
* @param {string} key - Metric key.
* @param {number} value - Raw metric value.
* @returns {number} Converted value (e.g. seconds → hours, bytes → GB).
*/
function _toChartValue(key, value) {
switch (key) {
case 'total_audio_seconds': return value / 3600;
case 'storage_bytes': return value / 1e9;
default: return value;
}
}
/** Builds and injects the analytics panel UI (toolbar, cards, chart, subscribers table) into the DOM. */
function initAnalyticsPanel() {
const section = document.getElementById('tab-analytics');
const toolbar = document.createElement('div');
toolbar.className = 'admin-toolbar';
const statusEl = document.createElement('span');
statusEl.id = 'adminAnalyticsStatus';
statusEl.className = 'admin-row-count';
statusEl.textContent = 'Loading…';
const refreshBtn = document.createElement('button');
refreshBtn.id = 'adminAnalyticsRefreshBtn';
refreshBtn.className = 'btn btn-ghost';
refreshBtn.textContent = 'Refresh';
refreshBtn.addEventListener('click', fetchAnalytics);
const collectBtn = document.createElement('button');
collectBtn.id = 'adminAnalyticsCollectBtn';
collectBtn.className = 'btn btn-ghost';
collectBtn.textContent = 'Collect Now';
collectBtn.addEventListener('click', collectAnalyticsNow);
toolbar.appendChild(statusEl);
toolbar.appendChild(refreshBtn);
toolbar.appendChild(collectBtn);
const cardsGrid = document.createElement('div');
cardsGrid.id = 'adminAnalyticsCards';
cardsGrid.className = 'analytics-cards';
const chartSection = document.createElement('div');
chartSection.className = 'analytics-chart-section';
// ── Row 1: metric / range / unit selects ──────────────────────────────────
const chartToolbar = document.createElement('div');
chartToolbar.className = 'admin-toolbar';
/**
* Creates a labelled select element and appends it to the chart toolbar.
* @param {string} id - Element ID for the select.
* @param {string} labelText - Label text displayed before the select.
* @param {Array<{value:string,label:string}>} options - Option list.
* @param {string} current - Currently selected value.
* @param {function} onChange - Callback invoked with the new value on change.
*/
function makeSelect(id, labelText, options, current, onChange) {
const lbl = document.createElement('label');
lbl.className = 'admin-toolbar-label';
lbl.textContent = labelText;
const sel = document.createElement('select');
sel.id = id;
sel.className = 'admin-group-select';
options.forEach(o => {
const opt = document.createElement('option');
opt.value = o.value;
opt.textContent = o.label;
opt.selected = o.value === current;
sel.appendChild(opt);
});
sel.addEventListener('change', () => onChange(sel.value));
chartToolbar.appendChild(lbl);
chartToolbar.appendChild(sel);
}
makeSelect('adminAnalyticsMetricSelect', 'Metric',
[{ value: 'all', label: 'All Metrics' }, ...ANALYTICS_METRICS.map(m => ({ value: m.key, label: m.label }))],
'all',
v => {
if (v === 'all') {
_analyticsVisibleKeys = new Set(ANALYTICS_METRICS.map(m => m.key));
} else if (v !== 'custom') {
_analyticsVisibleKeys = new Set([v]);
}
_refreshAnalyticsView();
}
);
// ── Row 2: custom date range (hidden unless range === 'custom') ────────────
const customDateRow = document.createElement('div');
customDateRow.className = 'analytics-date-row';
customDateRow.style.display = _analyticsRange === 'custom' ? 'flex' : 'none';
makeSelect('adminAnalyticsRangeSelect', 'Range', ANALYTICS_RANGES, _analyticsRange, v => {
_analyticsRange = v;
customDateRow.style.display = v === 'custom' ? 'flex' : 'none';
renderAnalyticsChart();
renderPageViewsChart();
});
makeSelect('adminAnalyticsUnitSelect', 'Unit', ANALYTICS_UNITS, _analyticsUnit, v => {
_analyticsUnit = v;
renderAnalyticsChart();
});
/**
* Creates a labelled date input and appends it to the custom date row.
* @param {string} id - Element ID for the input.
* @param {string} labelText - Label text displayed before the input.
* @param {string} stateKey - Either 'start' or 'end' to determine which state variable to update.
*/
function makeDateInput(id, labelText, stateKey) {
const lbl = document.createElement('label');
lbl.className = 'admin-toolbar-label';
lbl.textContent = labelText;
lbl.htmlFor = id;
const inp = document.createElement('input');
inp.type = 'date';
inp.id = id;
inp.className = 'analytics-date-input';
inp.value = stateKey === 'start' ? _analyticsStartDate : _analyticsEndDate;
inp.addEventListener('change', () => {
if (stateKey === 'start') _analyticsStartDate = inp.value;
else _analyticsEndDate = inp.value;
renderAnalyticsChart();
renderPageViewsChart();
});
customDateRow.appendChild(lbl);
customDateRow.appendChild(inp);
}
makeDateInput('adminAnalyticsFrom', 'From', 'start');
makeDateInput('adminAnalyticsTo', 'To', 'end');
const canvasWrap = document.createElement('div');
canvasWrap.className = 'analytics-chart-wrap';
const canvas = document.createElement('canvas');
canvas.id = 'adminAnalyticsChart';
canvasWrap.appendChild(canvas);
chartSection.appendChild(chartToolbar);
chartSection.appendChild(customDateRow);
chartSection.appendChild(canvasWrap);
const subscribersSection = document.createElement('div');
subscribersSection.id = 'adminAnalyticsSubscribers';
subscribersSection.className = 'analytics-subscribers-section';
const revenueHero = document.createElement('div');
revenueHero.id = 'adminAnalyticsRevenueHero';
revenueHero.className = 'analytics-revenue-hero';
const pageViewsSection = document.createElement('div');
pageViewsSection.className = 'analytics-chart-section';
const pageViewsTitle = document.createElement('div');
pageViewsTitle.className = 'analytics-section-title';
pageViewsTitle.textContent = 'Page Views by Route';
const pageViewsCanvasWrap = document.createElement('div');
pageViewsCanvasWrap.className = 'analytics-chart-wrap';
const pageViewsCanvas = document.createElement('canvas');
pageViewsCanvas.id = 'adminPageViewsChart';
pageViewsCanvasWrap.appendChild(pageViewsCanvas);
pageViewsSection.appendChild(pageViewsTitle);
pageViewsSection.appendChild(pageViewsCanvasWrap);
const pageViewsTableWrap = document.createElement('div');
pageViewsTableWrap.id = 'adminPageViewsTable';
pageViewsTableWrap.className = 'admin-table-wrap analytics-subscribers-table-wrap';
const pageViewHero = document.createElement('div');
pageViewHero.id = 'adminAnalyticsPageViewHero';
pageViewHero.className = 'analytics-page-view-hero';
const heroRow = document.createElement('div');
heroRow.className = 'analytics-hero-row';
heroRow.appendChild(revenueHero);
heroRow.appendChild(pageViewHero);
section.appendChild(toolbar);
section.appendChild(heroRow);
section.appendChild(cardsGrid);
section.appendChild(chartSection);
section.appendChild(subscribersSection);
section.appendChild(pageViewsSection);
section.appendChild(pageViewsTableWrap);
fetchAnalytics();
}
/** Fetches all analytics snapshots from the API and triggers a full re-render. */
async function fetchAnalytics() {
const statusEl = document.getElementById('adminAnalyticsStatus');
const refreshBtn = document.getElementById('adminAnalyticsRefreshBtn');
if (refreshBtn) { refreshBtn.disabled = true; refreshBtn.textContent = 'Refreshing…'; }
try {
const headers = {};
if (authToken) headers['X-Auth-Token'] = authToken;
const [snapshotsResp, pvResp] = await Promise.all([
fetch('/api/admin/analytics', { headers }),
fetch('/api/admin/analytics/page-views', { headers }),
]);
if (!snapshotsResp.ok) throw new Error(snapshotsResp.status);
_analyticsSnapshots = await snapshotsResp.json();
_pageViewsData = pvResp.ok ? await pvResp.json() : [];
renderAnalytics();
} catch {
if (statusEl) statusEl.textContent = 'Failed to load analytics.';
} finally {
if (refreshBtn) { refreshBtn.disabled = false; refreshBtn.textContent = 'Refresh'; }
}
}
/** Triggers an immediate analytics collection via the API then refreshes the display. */
async function collectAnalyticsNow() {
const statusEl = document.getElementById('adminAnalyticsStatus');
const collectBtn = document.getElementById('adminAnalyticsCollectBtn');
if (collectBtn) { collectBtn.disabled = true; collectBtn.textContent = 'Collecting…'; }
try {
const headers = { 'Content-Type': 'application/json' };
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch('/api/admin/analytics/collect', { method: 'POST', headers });
if (!resp.ok) throw new Error(resp.status);
await fetchAnalytics();
} catch {
if (statusEl) statusEl.textContent = 'Collection failed.';
} finally {
if (collectBtn) { collectBtn.disabled = false; collectBtn.textContent = 'Collect Now'; }
}
}
/** Updates the analytics status bar and triggers the full analytics view refresh. */
function renderAnalytics() {
const statusEl = document.getElementById('adminAnalyticsStatus');
if (!_analyticsSnapshots.length) {
if (statusEl) statusEl.textContent = 'No data yet — run collect-analytics to generate the first snapshot.';
const cards = document.getElementById('adminAnalyticsCards');
if (cards) cards.innerHTML = '';
return;
}
const latest = _analyticsSnapshots[_analyticsSnapshots.length - 1];
const d = new Date(latest.recorded_at);
const when = d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' })
+ ' · ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const count = _analyticsSnapshots.length;
if (statusEl) statusEl.textContent =
`Last snapshot: ${when} — ${count} snapshot${count !== 1 ? 's' : ''} total`;
_refreshAnalyticsView();
}
/** Refreshes all analytics view components (revenue hero, cards, subscribers table, charts). */
function _refreshAnalyticsView() {
if (!_analyticsSnapshots.length) return;
const latest = _analyticsSnapshots[_analyticsSnapshots.length - 1];
renderRevenueHero(latest.data);
renderPageViewHero();
renderAnalyticsCards(latest.data);
renderSubscribersTable(latest.data);
renderAnalyticsChart();
renderPageViewsChart();
}
/**
* Renders the MRR revenue hero cards from the latest analytics snapshot data.
* @param {object} data - Analytics snapshot data object containing mrr fields.
*/
function renderRevenueHero(data) {
const hero = document.getElementById('adminAnalyticsRevenueHero');
if (!hero) return;
const fmtUsd = v => v != null
? '$' + Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: '—';
const items = [
{ label: 'Current MRR', value: fmtUsd(data?.mrr), color: '#f87171' },
{ label: 'Previous Month MRR', value: fmtUsd(data?.prev_month_mrr), color: '#fbbf24' },
{ label: 'Est. Next Month MRR', value: fmtUsd(data?.est_next_month_mrr), color: '#c084fc' },
];
hero.textContent = '';
items.forEach((item, i) => {
const card = document.createElement('div');
card.className = 'analytics-revenue-card' + (i === 0 ? ' analytics-revenue-card--primary' : '');
card.style.borderColor = item.color;
const labelEl = document.createElement('div');
labelEl.className = 'analytics-revenue-label';
labelEl.textContent = item.label;
const valueEl = document.createElement('div');
valueEl.className = 'analytics-revenue-value';
valueEl.style.color = item.color;
valueEl.textContent = item.value;
card.appendChild(labelEl);
card.appendChild(valueEl);
hero.appendChild(card);
});
}
/** Renders the page view hero cards for the / and /app routes. */
function renderPageViewHero() {
const hero = document.getElementById('adminAnalyticsPageViewHero');
if (!hero) return;
const now = new Date();
const todayStr = now.toISOString().slice(0, 10);
const thisMonthPfx = todayStr.slice(0, 7);
const sumFor = (route, pred) =>
_pageViewsData.filter(r => r.route === route && pred(r.date))
.reduce((s, r) => s + r.count, 0);
const items = [
{ label: 'Landing — Today', value: sumFor('/', d => d === todayStr) },
{ label: 'Landing — This Month', value: sumFor('/', d => d.startsWith(thisMonthPfx)) },
{ label: 'Landing — All Time', value: sumFor('/', () => true) },
{ label: 'App — Today', value: sumFor('/app', d => d === todayStr) },
{ label: 'App — This Month', value: sumFor('/app', d => d.startsWith(thisMonthPfx)) },
{ label: 'App — All Time', value: sumFor('/app', () => true) },
];
hero.textContent = '';
items.forEach(item => {
const card = document.createElement('div');
card.className = 'analytics-revenue-card';
const labelEl = document.createElement('div');
labelEl.className = 'analytics-revenue-label';
labelEl.textContent = item.label;
const valueEl = document.createElement('div');
valueEl.className = 'analytics-revenue-value';
valueEl.textContent = item.value.toLocaleString();
card.appendChild(labelEl);
card.appendChild(valueEl);
hero.appendChild(card);
});
}
/**
* Renders the subscribers-by-tier breakdown table from the latest analytics snapshot data.
* @param {object} data - Analytics snapshot data object containing subscribers_by_tier.
*/
function renderSubscribersTable(data) {
const section = document.getElementById('adminAnalyticsSubscribers');
if (!section) return;
const tiers = data?.subscribers_by_tier ?? [];
if (!tiers.length) { section.textContent = ''; return; }
const totalMrr = tiers.reduce((s, t) => s + (t.mrr ?? 0), 0);
const totalSubs = tiers.reduce((s, t) => s + (t.count ?? 0), 0);
const fmtUsd = v => '$' + Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const rows = tiers.map(t => {
const billingLabel = t.billing_period === 'monthly' ? 'Monthly'
: t.billing_period === 'yearly' ? 'Yearly (÷12 MRR)'
: '—';
const priceLabel = t.monthly_price > 0 ? fmtUsd(t.monthly_price) + '/mo' : '—';
return `<tr>
<td>${esc(t.tier)}</td>
<td class="admin-cell-muted">${billingLabel}</td>
<td>${t.count.toLocaleString()}</td>
<td class="admin-cell-muted">${priceLabel}</td>
<td>${t.mrr > 0 ? fmtUsd(t.mrr) : '—'}</td>
</tr>`;
}).join('');
section.textContent = '';
const sectionTitle = document.createElement('div');
sectionTitle.className = 'analytics-section-title';
sectionTitle.textContent = 'Subscribers by Tier';
const tableWrap = document.createElement('div');
tableWrap.className = 'admin-table-wrap analytics-subscribers-table-wrap';
const subTable = document.createElement('table');
subTable.className = 'admin-table';
const subThead = document.createElement('thead');
const subTheadTr = document.createElement('tr');
subTheadTr.innerHTML = '<th>Tier</th><th>Billing</th><th>Subscribers</th><th>Monthly Price (MRR basis)</th><th>MRR</th>';
subThead.appendChild(subTheadTr);
const subTbody = document.createElement('tbody');
subTbody.innerHTML = rows +
`<tr class="analytics-subscribers-total"><td colspan="2" class="admin-cell-muted">Total</td><td>${totalSubs.toLocaleString()}</td><td></td><td>${fmtUsd(totalMrr)}</td></tr>`;
subTable.appendChild(subThead);
subTable.appendChild(subTbody);
tableWrap.appendChild(subTable);
section.appendChild(sectionTitle);
section.appendChild(tableWrap);
}
/**
* Renders the analytics metric summary cards from the latest snapshot data.
* @param {object} data - Analytics snapshot data object.
*/
function renderAnalyticsCards(data) {
const grid = document.getElementById('adminAnalyticsCards');
if (!grid) return;
grid.innerHTML = '';
ANALYTICS_METRICS.forEach(m => {
const visible = _analyticsVisibleKeys.has(m.key);
const card = document.createElement('div');
card.className = 'analytics-card' + (visible ? '' : ' analytics-card--disabled');
card.style.borderColor = visible ? m.color : '';
card.title = visible ? 'Click to hide' : 'Click to show';
const labelEl = document.createElement('div');
labelEl.className = 'analytics-card-label';
labelEl.textContent = m.label;
const valueEl = document.createElement('div');
valueEl.className = 'analytics-card-value';
valueEl.textContent = _fmtAnalyticsCard(m.key, data?.[m.key]);
card.appendChild(labelEl);
card.appendChild(valueEl);
card.addEventListener('click', () => _toggleMetric(m.key));
card.addEventListener('mouseenter', () => _highlightChartMetric(m.key));
card.addEventListener('mouseleave', () => _resetChartHighlight());
grid.appendChild(card);
});
}
/** Destroys and rebuilds the analytics Chart.js line chart from the current snapshots and filters. */
function renderAnalyticsChart() {
const canvas = document.getElementById('adminAnalyticsChart');
if (!canvas || !_analyticsSnapshots.length) return;
const filtered = _getFilteredSnapshots();
const style = getComputedStyle(document.documentElement);
const gridColor = style.getPropertyValue('--border').trim() || 'rgba(255,255,255,0.1)';
const textColor = style.getPropertyValue('--muted').trim() || '#888';
const surface = style.getPropertyValue('--surface').trim() || '#1a1a2e';
const tickFont = { family: 'IBM Plex Mono', size: 11 };
if (_analyticsChart) { _analyticsChart.destroy(); _analyticsChart = null; }
const { min: axisMin, max: axisMax } = _getAnalyticsAxisRange();
const xAxis = {
type: 'time',
time: {
...((_analyticsUnit !== 'auto') ? { unit: _analyticsUnit } : {}),
tooltipFormat: 'MMM d, yyyy',
displayFormats: { day: 'MMM d', week: 'MMM d', month: 'MMM yyyy', year: 'yyyy' },
},
...(axisMin ? { min: axisMin.toISOString() } : {}),
...(axisMax ? { max: axisMax.toISOString() } : {}),
ticks: { color: textColor, font: tickFont },
grid: { color: gridColor },
};
const visibleMetrics = ANALYTICS_METRICS.filter(m => _analyticsVisibleKeys.has(m.key));
const isMulti = visibleMetrics.length > 1;
let datasets, yAxis, legendCfg, tooltipCallbacks;
if (isMulti) {
const rawByMetric = visibleMetrics.map(m =>
filtered.map(s => _toChartValue(m.key, s.data?.[m.key] ?? 0))
);
datasets = visibleMetrics.map((m, i) => {
const raw = rawByMetric[i];
const lo = Math.min(...raw);
const hi = Math.max(...raw);
const range = hi - lo || 1;
return {
label: m.label,
data: filtered.map((s, j) => ({ x: new Date(s.recorded_at), y: (raw[j] - lo) / range })),
borderColor: m.color,
backgroundColor: m.color + '22',
borderWidth: 2,
pointRadius: 3,
pointHoverRadius: 5,
tension: 0.3,
fill: false,
};
});
yAxis = {
min: -0.05, max: 1.05,
afterBuildTicks: scale => {
scale.ticks = [0, 0.2, 0.4, 0.6, 0.8, 1.0].map(v => ({ value: v }));
},
ticks: { color: textColor, font: tickFont, callback: v => v.toFixed(1) },
grid: { color: gridColor },
};
legendCfg = { display: true, labels: { color: textColor, font: tickFont, boxWidth: 12, padding: 10 } };
tooltipCallbacks = {
label: ctx => {
const m = visibleMetrics[ctx.datasetIndex];
const snap = filtered[ctx.dataIndex];
return ` ${m.label}: ${_fmtAnalyticsCard(m.key, snap?.data?.[m.key] ?? null)}`;
},
};
} else {
const m = visibleMetrics[0];
datasets = [{
label: m.label,
data: filtered.map(s => ({ x: new Date(s.recorded_at), y: _toChartValue(m.key, s.data?.[m.key] ?? 0) })),
borderColor: m.color,
backgroundColor: m.color + '33',
borderWidth: 2,
pointRadius: 3,
pointHoverRadius: 5,
tension: 0.3,
fill: true,
}];
yAxis = { beginAtZero: true, grace: '5%', ticks: { color: textColor, font: tickFont }, grid: { color: gridColor } };
legendCfg = { display: false };
tooltipCallbacks = {};
}
_analyticsChart = new Chart(canvas, {
type: 'line',
data: { datasets },
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: legendCfg,
tooltip: {
backgroundColor: surface,
borderColor: gridColor,
borderWidth: 1,
titleColor: textColor,
bodyColor: textColor,
callbacks: tooltipCallbacks,
},
},
scales: { x: xAxis, y: yAxis },
},
});
}
// ── Page views chart ─────────────────────────────────────────────────────────
const PAGE_VIEW_COLORS = [
'#60a5fa', '#34d399', '#f472b6', '#a78bfa', '#fb923c',
'#facc15', '#38bdf8', '#4ade80', '#f87171', '#2dd4bf', '#c084fc',
];
/**
* Returns page-view rows filtered to the current analytics date range.
* @returns {Array<object>} Filtered page-view data rows.
*/
function _getFilteredPageViews() {
const { min, max } = _getAnalyticsAxisRange();
return _pageViewsData.filter(row => {
const d = new Date(row.date);
if (min && d < min) return false;
if (max && d > max) return false;
return true;
});
}
/** Destroys and rebuilds the page views line chart from the current data and date filter. */
function renderPageViewsChart() {
const canvas = document.getElementById('adminPageViewsChart');
if (!canvas) return;
if (_pageViewsChart) { _pageViewsChart.destroy(); _pageViewsChart = null; }
renderPageViewsTable();
if (!_pageViewsData.length) return;
const filtered = _getFilteredPageViews();
if (!filtered.length) return;
const routes = [...new Set(filtered.map(r => r.route))].sort();
const dates = [...new Set(filtered.map(r => r.date))].sort();
const lookup = {};
filtered.forEach(r => { lookup[`${r.date}|${r.route}`] = r.count; });
const style = getComputedStyle(document.documentElement);
const gridColor = style.getPropertyValue('--border').trim() || 'rgba(255,255,255,0.1)';
const textColor = style.getPropertyValue('--muted').trim() || '#888';
const surface = style.getPropertyValue('--surface').trim() || '#1a1a2e';
const tickFont = { family: 'IBM Plex Mono', size: 11 };
const datasets = routes.map((route, i) => {
const color = PAGE_VIEW_COLORS[i % PAGE_VIEW_COLORS.length];
return {
label: route,
data: dates.map(date => ({ x: date, y: lookup[`${date}|${route}`] ?? 0 })),
borderColor: color,
backgroundColor: color + '22',
borderWidth: 2,
pointRadius: 3,
pointHoverRadius: 5,
tension: 0.3,
fill: false,
};
});
_pageViewsChart = new Chart(canvas, {
type: 'line',
data: { datasets },
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
type: 'time',
time: { tooltipFormat: 'MMM d, yyyy', displayFormats: { day: 'MMM d', week: 'MMM d', month: 'MMM yyyy' } },
ticks: { color: textColor, font: tickFont },
grid: { color: gridColor },
},
y: { beginAtZero: true, ticks: { color: textColor, font: tickFont }, grid: { color: gridColor } },
},
plugins: {
legend: { display: true, labels: { color: textColor, font: tickFont, boxWidth: 12, padding: 10 } },
tooltip: {
backgroundColor: surface,
borderColor: gridColor,
borderWidth: 1,
titleColor: textColor,
bodyColor: textColor,
},
},
},
});
}
/** Renders the page views summary table (routes × fixed time windows) below the chart. */
function renderPageViewsTable() {
const wrap = document.getElementById('adminPageViewsTable');
if (!wrap) return;
if (!_pageViewsData.length) { wrap.textContent = ''; return; }
const now = new Date();
const todayStr = now.toISOString().slice(0, 10);
const yest = new Date(now); yest.setDate(yest.getDate() - 1);
const yesterdayStr = yest.toISOString().slice(0, 10);
const thisMonthPfx = todayStr.slice(0, 7);
const lastMonthDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const lastMonthPfx = `${lastMonthDate.getFullYear()}-${String(lastMonthDate.getMonth() + 1).padStart(2, '0')}`;
const normMeth = v => v || '';
const normErr = v => v === true;
const sumFor = (route, method, error, predicate) =>
_pageViewsData.filter(r =>
r.route === route &&
normMeth(r.method) === method &&
normErr(r.error) === error &&
predicate(r.date)
).reduce((s, r) => s + r.count, 0);
const seen = new Set();
const groups = [];
for (const r of _pageViewsData) {
const m = normMeth(r.method);
const e = normErr(r.error);
const key = `${r.route}\0${m ?? ''}\0${e ?? ''}`;
if (!seen.has(key)) { seen.add(key); groups.push({ route: r.route, method: m, error: e }); }
}
groups.sort((a, b) =>
a.route.localeCompare(b.route) ||
(a.method ?? '').localeCompare(b.method ?? '') ||
(a.error === b.error ? 0 : a.error ? 1 : -1)
);
const columns = [
{ label: 'All Time', fn: date => true },
{ label: 'Last Month', fn: date => date.startsWith(lastMonthPfx) },
{ label: 'This Month', fn: date => date.startsWith(thisMonthPfx) },
{ label: 'Yesterday', fn: date => date === yesterdayStr },
{ label: 'Today', fn: date => date === todayStr },
];
const methodBadge = m => m
? `<span class="admin-badge admin-badge--method-${esc(m.toLowerCase())}">${esc(m)}</span>`
: '';
const errorBadge = e => e
? `<span class="admin-badge admin-badge--error">Error</span>`
: '';
const headerCells = columns.map(c => `<th>${c.label}</th>`).join('');
const rows = groups.map(({ route, method, error }) => {
const cells = columns.map(c => `<td>${sumFor(route, method, error, c.fn).toLocaleString()}</td>`).join('');
const badge = error ? errorBadge(error) : methodBadge(method);
return `<tr><td class="admin-col-badge">${badge}</td><td>${esc(route)}</td>${cells}</tr>`;
});
const totalCells = columns.map(c =>
`<td>${_pageViewsData.filter(r => c.fn(r.date)).reduce((s, r) => s + r.count, 0).toLocaleString()}</td>`
).join('');
const table = document.createElement('table');
table.className = 'admin-table';
const thead_el = document.createElement('thead');
const thead_tr = document.createElement('tr');
thead_tr.innerHTML = `<th class="admin-col-badge">Method</th><th>Route</th>${headerCells}`;
thead_el.appendChild(thead_tr);
const tbody_el = document.createElement('tbody');
tbody_el.innerHTML = rows.join('') +
`<tr class="analytics-subscribers-total"><td></td><td class="admin-cell-muted">Total</td>${totalCells}</tr>`;
table.appendChild(thead_el);
table.appendChild(tbody_el);
wrap.textContent = '';
wrap.appendChild(table);
}
// ── Dispatch ──────────────────────────────────────────────────────────────────
/** Initialises the email dispatch panel, rendering its form and wiring up all controls. */
async function initDispatchPanel() {
const container = document.getElementById('dispatchPanelContent');
container.textContent = '';
const mkEl = (tag, props = {}) => {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(props)) {
if (k === 'class') el.className = v;
else if (k === 'style') el.style.cssText = v;
else if (k === 'text') el.textContent = v;
else el[k] = v;
}
return el;
};
const mkRadioLabel = (name, value, labelText, checked = false) => {
const lbl = mkEl('label', { class: 'dispatch-radio-label' });
const inp = mkEl('input', { type: 'radio', name, value });
if (checked) inp.checked = true;
lbl.appendChild(inp);
lbl.appendChild(document.createTextNode(' ' + labelText));
return lbl;
};
const layout = mkEl('div', { class: 'dispatch-layout' });
const form = mkEl('div', { class: 'dispatch-form' });
// Audience group
const audienceGrp = mkEl('div', { class: 'sub-form-group' });
const audienceLbl = mkEl('label', { class: 'sub-form-label', htmlFor: 'dispatchAudience', text: 'Audience' });
const audienceRadioGrp = mkEl('div', { class: 'dispatch-audience-group' });
audienceRadioGrp.appendChild(mkRadioLabel('dispatchAudience', 'all', 'All accounts', true));
audienceRadioGrp.appendChild(mkRadioLabel('dispatchAudience', 'updates_only', 'Opted-in to updates only' ));
audienceRadioGrp.appendChild(mkRadioLabel('dispatchAudience', 'admins', 'Test (Admins)' ));
const countEl = mkEl('div', { class: 'dispatch-count', id: 'dispatchCount', text: '—' });
audienceGrp.appendChild(audienceLbl);
audienceGrp.appendChild(audienceRadioGrp);
audienceGrp.appendChild(countEl);
// Subject group
const subjectGrp = mkEl('div', { class: 'sub-form-group' });
const subjectLbl = mkEl('label', { class: 'sub-form-label', htmlFor: 'dispatchSubject', text: 'Subject' });
const subjectInput = mkEl('input', { class: 'sub-form-input', type: 'text', id: 'dispatchSubject', maxLength: 200, placeholder: 'Email subject line' });
subjectGrp.appendChild(subjectLbl);
subjectGrp.appendChild(subjectInput);
// Format group
const formatGrp = mkEl('div', { class: 'sub-form-group' });
const formatLbl = mkEl('label', { class: 'sub-form-label', text: 'Format' });
const formatRadioGrp = mkEl('div', { class: 'dispatch-audience-group' });
formatRadioGrp.appendChild(mkRadioLabel('dispatchFormat', 'text', 'Plain text', true));
formatRadioGrp.appendChild(mkRadioLabel('dispatchFormat', 'html', 'HTML' ));
const formatHint = mkEl('div', { class: 'dispatch-format-hint', id: 'dispatchFormatHint' });
formatGrp.appendChild(formatLbl);
formatGrp.appendChild(formatRadioGrp);
formatGrp.appendChild(formatHint);
// Body row
const bodyRow = mkEl('div', { class: 'dispatch-body-row' });
const bodyGrp = mkEl('div', { class: 'sub-form-group dispatch-body-group' });
const bodyLbl = mkEl('label', { class: 'sub-form-label', htmlFor: 'dispatchBody', text: 'Message' });
const bodyTextarea = mkEl('textarea', { class: 'sub-features-textarea dispatch-body', id: 'dispatchBody', placeholder: 'Write your message here. Separate paragraphs with a blank line.' });
bodyGrp.appendChild(bodyLbl);
bodyGrp.appendChild(bodyTextarea);
const snippetGrp = mkEl('div', { class: 'sub-form-group dispatch-snippet-group', id: 'dispatchSnippetGroup', style: 'display:none;' });
const snippetLbl = mkEl('label', { class: 'sub-form-label', text: 'Button snippets' });
const primaryLabel = mkEl('div', { class: 'dispatch-snippet-label', text: 'Primary (blue)' });
const primaryWrap = mkEl('div', { class: 'dispatch-snippet-wrap' });
const primaryPre = mkEl('pre', { class: 'dispatch-snippet', id: 'dispatchSnippetPrimary' });
const primaryCopy = mkEl('button', { class: 'dispatch-snippet-copy', id: 'dispatchSnippetCopyPrimary', title: 'Copy', text: 'Copy' });
primaryWrap.appendChild(primaryPre);
primaryWrap.appendChild(primaryCopy);
const secondaryLabel = mkEl('div', { class: 'dispatch-snippet-label', text: 'Secondary (grey)' });
const secondaryWrap = mkEl('div', { class: 'dispatch-snippet-wrap' });
const secondaryPre = mkEl('pre', { class: 'dispatch-snippet', id: 'dispatchSnippetSecondary' });
const secondaryCopy = mkEl('button', { class: 'dispatch-snippet-copy', id: 'dispatchSnippetCopySecondary', title: 'Copy', text: 'Copy' });
secondaryWrap.appendChild(secondaryPre);
secondaryWrap.appendChild(secondaryCopy);
const changelogLbl = mkEl('label', { class: 'sub-form-label', style: 'margin-top:12px;', text: 'Insert changelog' });
const changelogWrap = mkEl('div', { class: 'dispatch-snippet-wrap' });
const changelogSelectEl = mkEl('select', { class: 'sub-form-input', id: 'dispatchChangelogSelect', style: 'flex:1;' });
const changelogInsert = mkEl('button', { class: 'dispatch-snippet-copy', id: 'dispatchChangelogInsert', type: 'button', text: 'Insert' });
changelogWrap.appendChild(changelogSelectEl);
changelogWrap.appendChild(changelogInsert);
snippetGrp.appendChild(snippetLbl);
snippetGrp.appendChild(primaryLabel);
snippetGrp.appendChild(primaryWrap);
snippetGrp.appendChild(secondaryLabel);
snippetGrp.appendChild(secondaryWrap);
snippetGrp.appendChild(changelogLbl);
snippetGrp.appendChild(changelogWrap);
bodyRow.appendChild(bodyGrp);
bodyRow.appendChild(snippetGrp);
// Actions
const actionsDiv = mkEl('div', { class: 'dispatch-actions' });
const statusSpan = mkEl('span', { class: 'dispatch-status', id: 'dispatchStatus' });
const sendBtn = mkEl('button', { class: 'btn btn-primary', id: 'dispatchSendBtn', text: 'Send Email' });
actionsDiv.appendChild(statusSpan);
actionsDiv.appendChild(sendBtn);
form.appendChild(audienceGrp);
form.appendChild(subjectGrp);
form.appendChild(formatGrp);
form.appendChild(bodyRow);
form.appendChild(actionsDiv);
// Preview pane
const previewPane = mkEl('div', { class: 'dispatch-preview-pane' });
const previewLabel = mkEl('div', { class: 'sub-form-label', text: 'Preview' });
const previewFrameEl = mkEl('iframe', { class: 'dispatch-preview-frame', id: 'dispatchPreviewFrame', sandbox: 'allow-same-origin' });
previewPane.appendChild(previewLabel);
previewPane.appendChild(previewFrameEl);
layout.appendChild(form);
layout.appendChild(previewPane);
container.appendChild(layout);
/** Fetches and displays the recipient count for the currently selected audience. */
async function fetchCount() {
const audience = container.querySelector('input[name="dispatchAudience"]:checked').value;
const countEl = document.getElementById('dispatchCount');
countEl.textContent = '…';
try {
const headers = {};
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch(`/api/admin/dispatch/count?audience=${audience}`, { headers });
if (resp.ok) {
const { count } = await resp.json();
countEl.textContent = `${count} recipient${count !== 1 ? 's' : ''}`;
} else {
countEl.textContent = 'Error fetching count';
}
} catch {
countEl.textContent = 'Error fetching count';
}
}
container.querySelectorAll('input[name="dispatchAudience"]').forEach(r => {
r.addEventListener('change', fetchCount);
});
const hintEl = document.getElementById('dispatchFormatHint');
const bodyEl = document.getElementById('dispatchBody');
const snippetGroup = document.getElementById('dispatchSnippetGroup');
const snippetPrimaryEl = document.getElementById('dispatchSnippetPrimary');
const snippetSecondaryEl = document.getElementById('dispatchSnippetSecondary');
const changelogSelect = document.getElementById('dispatchChangelogSelect');
const htmlHint = 'HTML is embedded inside the standard email card. Use inline styles for buttons and links.';
const textHint = 'Blank lines become paragraph breaks.';
// Load changelogs once and populate the select
let _changelogs = [];
(async () => {
try {
const headers = {};
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch('/api/admin/changelogs', { headers });
if (resp.ok) {
_changelogs = await resp.json();
_changelogs.forEach(cl => {
const opt = document.createElement('option');
opt.value = cl.name;
opt.textContent = cl.name.replace(/\.md$/, '');
changelogSelect.appendChild(opt);
});
}
} catch { /* silently ignore */ }
})();
document.getElementById('dispatchChangelogInsert').addEventListener('click', () => {
const selected = _changelogs.find(cl => cl.name === changelogSelect.value);
if (!selected) return;
bodyEl.value = (bodyEl.value ? bodyEl.value + '\n\n' : '') + selected.html;
});
const SNIPPET_PRIMARY = `<p style="margin:0 0 24px;text-align:center;">
<a href="https://example.com"
target="_blank"
style="display:inline-block;padding:11px 24px;background:#3b82f6;
color:#ffffff;text-decoration:none;border-radius:4px;
font-family:ui-monospace,'IBM Plex Mono',monospace;
font-size:13px;font-weight:600;letter-spacing:0.04em;
mso-padding-alt:0;">
Button label
</a>
</p>`;
const SNIPPET_SECONDARY = `<p style="margin:0 0 24px;text-align:center;">
<a href="https://example.com"
target="_blank"
style="display:inline-block;padding:11px 24px;background:#52525b;
color:#ffffff;text-decoration:none;border-radius:4px;
font-family:ui-monospace,'IBM Plex Mono',monospace;
font-size:13px;font-weight:600;letter-spacing:0.04em;
mso-padding-alt:0;">
Button label
</a>
</p>`;
/** Updates the format hint text and body placeholder to match the selected format. */
function updateFormatHint() {
const fmt = container.querySelector('input[name="dispatchFormat"]:checked').value;
const isHtml = fmt === 'html';
hintEl.textContent = isHtml ? htmlHint : textHint;
bodyEl.placeholder = isHtml
? '<p>Your message here.</p>'
: 'Write your message here. Separate paragraphs with a blank line.';
snippetGroup.style.display = isHtml ? '' : 'none';
snippetPrimaryEl.textContent = SNIPPET_PRIMARY;
snippetSecondaryEl.textContent = SNIPPET_SECONDARY;
}
/**
* Wires a click handler to a button that copies the given text to the clipboard.
* @param {string} btnId - ID of the button element.
* @param {string} text - Text to copy on click.
*/
function makeCopyHandler(btnId, text) {
document.getElementById(btnId).addEventListener('click', () => {
navigator.clipboard.writeText(text).then(() => {
const btn = document.getElementById(btnId);
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = 'Copy'; }, 1500);
});
});
}
makeCopyHandler('dispatchSnippetCopyPrimary', SNIPPET_PRIMARY);
makeCopyHandler('dispatchSnippetCopySecondary', SNIPPET_SECONDARY);
container.querySelectorAll('input[name="dispatchFormat"]').forEach(r => {
r.addEventListener('change', updateFormatHint);
});
updateFormatHint();
// ── Live preview ──────────────────────────────────────────────────────────
const previewFrame = document.getElementById('dispatchPreviewFrame');
let _previewTimer = null;
/**
* Fetches a rendered HTML preview of the current dispatch email and injects it into the preview frame.
*/
async function updatePreview() {
const subject = document.getElementById('dispatchSubject').value;
const body = document.getElementById('dispatchBody').value;
const format = container.querySelector('input[name="dispatchFormat"]:checked').value;
try {
const headers = { 'Content-Type': 'application/json' };
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch('/api/admin/dispatch/preview', {
method: 'POST',
headers,
body: JSON.stringify({ subject, body, format }),
});
if (resp.ok) {
const { html } = await resp.json();
previewFrame.srcdoc = html;
previewFrame.onload = () => {
previewFrame.style.height = previewFrame.contentDocument.body.scrollHeight + 'px';
};
}
} catch { /* silently ignore preview errors */ }
}
/**
* Debounces calls to updatePreview, waiting 400 ms after the last input event.
*/
function schedulePreview() {
clearTimeout(_previewTimer);
_previewTimer = setTimeout(updatePreview, 400);
}
document.getElementById('dispatchSubject').addEventListener('input', schedulePreview);
document.getElementById('dispatchBody').addEventListener('input', schedulePreview);
container.querySelectorAll('input[name="dispatchFormat"]').forEach(r => {
r.addEventListener('change', schedulePreview);
});
updatePreview();
document.getElementById('dispatchSendBtn').addEventListener('click', async () => {
const audience = container.querySelector('input[name="dispatchAudience"]:checked').value;
const format = container.querySelector('input[name="dispatchFormat"]:checked').value;
const subject = document.getElementById('dispatchSubject').value.trim();
const body = document.getElementById('dispatchBody').value.trim();
const statusEl = document.getElementById('dispatchStatus');
const sendBtn = document.getElementById('dispatchSendBtn');
if (!subject) { statusEl.textContent = 'Subject is required.'; statusEl.className = 'dispatch-status dispatch-status--error'; return; }
if (!body) { statusEl.textContent = 'Message body is required.'; statusEl.className = 'dispatch-status dispatch-status--error'; return; }
const countEl = document.getElementById('dispatchCount');
const countText = countEl.textContent;
if (!confirm(`Send to ${countText}?\n\nSubject: ${subject}`)) return;
sendBtn.disabled = true;
statusEl.textContent = 'Sending…';
statusEl.className = 'dispatch-status';
try {
const headers = { 'Content-Type': 'application/json' };
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch('/api/admin/dispatch', {
method: 'POST',
headers,
body: JSON.stringify({ subject, body, audience, format }),
});
const data = await resp.json();
if (resp.ok) {
statusEl.textContent = `Sent to ${data.sent} recipient${data.sent !== 1 ? 's' : ''}.`;
statusEl.className = 'dispatch-status dispatch-status--ok';
document.getElementById('dispatchSubject').value = '';
document.getElementById('dispatchBody').value = '';
} else {
statusEl.textContent = data.error || 'Send failed.';
statusEl.className = 'dispatch-status dispatch-status--error';
}
} catch {
statusEl.textContent = 'Network error.';
statusEl.className = 'dispatch-status dispatch-status--error';
} finally {
sendBtn.disabled = false;
}
});
fetchCount();
}
// ── Schema diagram ────────────────────────────────────────────────────────────
/** Builds and renders the database schema ER diagram into the schema panel container. */
function initSchemaPanel() {
const container = document.getElementById('adminSchemaDiagram');
const definition = `
erDiagram
USERS {
uuid id PK
text external_id
text auth_provider
text email
text display_name
boolean is_active
uuid subscription_id FK
jsonb preferences
timestamptz last_login_at
timestamptz created_at
timestamptz modified_at
}
SUBSCRIPTIONS {
uuid id PK
text name
text description
text billing_period
jsonb features
timestamptz created_at
timestamptz modified_at
}
FOLDERS {
uuid id PK
text name
uuid parent_id FK
uuid owner_id FK
jsonb permissions
timestamptz created_at
}
PROJECTS {
uuid id PK
text name
text version
uuid folder_id FK
uuid owner_id FK
text audio_format
boolean has_audio_mp3
boolean has_transcript
boolean has_peaks
jsonb speakers
jsonb permissions
integer sample_rate
double_precision duration
timestamptz created_at
timestamptz modified_at
}
EMBEDS {
uuid id PK
uuid project_id FK
uuid owner_id FK
integer invoke_count
timestamptz created_at
}
SUBSCRIPTIONS ||--o{ USERS : "subscription_id"
USERS ||--o{ PROJECTS : "owner_id"
USERS ||--o{ FOLDERS : "owner_id"
USERS ||--o{ EMBEDS : "owner_id"
FOLDERS ||--o{ FOLDERS : "parent_id"
FOLDERS ||--o{ PROJECTS : "folder_id"
PROJECTS ||--o{ EMBEDS : "project_id"
ANALYTICS {
uuid id PK
jsonb data
timestamptz recorded_at
}
`.trim();
const pre = document.createElement('pre');
pre.className = 'mermaid';
pre.textContent = definition;
container.appendChild(pre);
if (window.mermaid) {
const isDark = document.documentElement.dataset.theme !== 'light';
mermaid.initialize({
startOnLoad: false,
theme: isDark ? 'dark' : 'default',
er: { layoutDirection: 'LR' },
});
mermaid.run({ nodes: [pre] });
}
}
// ── Auth ──────────────────────────────────────────────────────────────────────
if (LOCAL_MODE) {
(async () => {
try {
const r = await fetch('/api/admin/me');
if (!r.ok) { hideLoading(); return; }
showAdminPage(await r.json());
} catch {
hideLoading();
}
})();
} else if (!firebaseAuth) {
window.location.href = '/';
} else {
loginDialog = new LoginDialog();
let _pendingError = null;
onAuthStateChanged(firebaseAuth, async (fbUser) => {
if (!fbUser) {
hideLoading();
document.getElementById('adminPage').style.display = 'none';
loginDialog.openRequired();
if (_pendingError) {
loginDialog.showConnectError(_pendingError);
_pendingError = null;
}
return;
}
authToken = await fbUser.getIdToken();
let resp;
try {
resp = await fetch('/api/admin/me', { headers: { 'X-Auth-Token': authToken } });
} catch {
_pendingError = 'Could not connect to server.';
authToken = null;
await signOut(firebaseAuth);
return;
}
if (!resp.ok) {
_pendingError = resp.status === 403
? 'This account does not have admin access.'
: 'Server error. Please try again.';
authToken = null;
await signOut(firebaseAuth);
return;
}
loginDialog.close();
showAdminPage(await resp.json());
});
}