Add REST endpoints and frontend wiring for per-language project actions:
- POST /api/workspace/{project}/translation/{lang}/queue — queue translation
- POST /api/workspace/{project}/audio/{lang}/queue — queue audio generation
- GET/POST /api/workspace/{project}/format/{lang} — format metadata (EPUB/PDF)
- GET /api/workspace/{project}/review/{lang}/chapters/{chapter} — utterance review
- GET/POST /api/workspace/{project}/front-matter/{lang} — front matter editor
- GET /api/workspace/{project}/chapters/{lang} — chapter list
- GET /api/workspace/{project}/chapter-content/{lang}/{chapter} — chapter text
Frontend fixes:
- Review page shows per-language text (English source, Chinese translation, etc.)
- Non-translated languages show 'Queue translation' button only
- Speaker data from English source matched by paragraph ID
- UmbraAudio column layout: Speaker, Variation, Text, Actions
- Chapter tree in left sidebar: Front matter, Back matter, Body
- Audio Format label at top of sidebar
- Pandoc formatting toolbar for front matter text editing
358 lines
13 KiB
JavaScript
358 lines
13 KiB
JavaScript
// Copyright 2026 Umbra Tools (umbratools.com)
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
/*
|
|
* Embeddable administration views.
|
|
*
|
|
* `UmbraAdminView.mount(container, { section, onSectionChange })` renders one
|
|
* administration section into `container` and returns a handle with `show()` and
|
|
* `destroy()`. The Umbra Tools shell mounts this inline; the standalone `uadmin/`
|
|
* page keeps its own shell and script and does not use this module.
|
|
*
|
|
* Why this exists as a separate file: the control panel is served with
|
|
* `X-Frame-Options: DENY` and `frame-ancestors 'none'` on purpose, so it must never
|
|
* be framed. Embedding means rendering the same views in the same document, not
|
|
* loading the other page inside a frame.
|
|
*
|
|
* Only what the shell's rail exposes is implemented here: dashboard, accounts,
|
|
* organizations, projects, audit. Nodes stays on its standalone page (it has its
|
|
* own pairing flow) and is linked, not embedded.
|
|
*/
|
|
(function (global) {
|
|
'use strict';
|
|
|
|
var api = global.UmbraApi;
|
|
if (!api) {
|
|
return;
|
|
}
|
|
|
|
var el = api.el;
|
|
var q = api.query;
|
|
|
|
var SUMMARY_CARDS = [
|
|
{ key: 'total_organizations', label: 'Organizations', note: 'Tenants in scope' },
|
|
{ key: 'active_organizations', label: 'Active organizations', note: 'Accept new accounts' },
|
|
{ key: 'total_accounts', label: 'Accounts', note: 'Every account in scope' },
|
|
{ key: 'active_accounts', label: 'Active accounts', note: 'Can open a session' },
|
|
{ key: 'suspended_accounts', label: 'Suspended accounts', note: 'Sessions revoked' },
|
|
{ key: 'pending_first_login', label: 'Awaiting first sign-in', note: 'Must change a temporary password' },
|
|
{ key: 'organization_admin_count', label: 'Organization admins', note: 'Administer their own tenant' }
|
|
];
|
|
|
|
var STATUS_TEXT = {
|
|
active: 'Active', suspended: 'Suspended', deactivated: 'Deactivated',
|
|
on_hold: 'On hold', released: 'Released', archived: 'Archived'
|
|
};
|
|
|
|
function statusPill(status) {
|
|
var tone =
|
|
status === 'active' ? 'success'
|
|
: status === 'suspended' || status === 'on_hold' ? 'warning'
|
|
: status === 'released' ? 'info'
|
|
: 'muted';
|
|
return el('span', { class: 'status-pill status-pill--' + tone, text: STATUS_TEXT[status] || api.label(status) });
|
|
}
|
|
|
|
function emptyBlock(title, body, action) {
|
|
var children = [
|
|
el('p', { class: 'empty-state__title', text: title }),
|
|
el('p', { class: 'empty-state__body', text: body })
|
|
];
|
|
if (action) {
|
|
children.push(action);
|
|
}
|
|
return el('div', { class: 'empty-state' }, children);
|
|
}
|
|
|
|
function heading(eyebrow, title, body, action) {
|
|
var right = el('div', {});
|
|
if (action) {
|
|
right.appendChild(action);
|
|
}
|
|
return el('div', { class: 'admin-heading' }, [
|
|
el('div', {}, [
|
|
el('p', { class: 'eyebrow', text: eyebrow }),
|
|
el('h1', { text: title }),
|
|
el('p', { text: body })
|
|
]),
|
|
right
|
|
]);
|
|
}
|
|
|
|
function loadingNote(text) {
|
|
return el('p', { class: 'loading-note', text: text });
|
|
}
|
|
|
|
function statGrid() {
|
|
return el('div', { class: 'admin-stat-grid' }, [loadingNote('Loading summary…')]);
|
|
}
|
|
|
|
/* ---------------------------------------------------------- dashboard */
|
|
|
|
function renderDashboard(container) {
|
|
var grid = statGrid();
|
|
var activity = el('div', { class: 'audit-list' }, [loadingNote('Loading recent events…')]);
|
|
|
|
container.appendChild(heading('Administration', 'System overview', 'Counts cover only the organizations and accounts you are authorized to see.'));
|
|
container.appendChild(el('section', { class: 'admin-card' }, [
|
|
el('div', { class: 'admin-card-heading' }, [
|
|
el('div', {}, [el('p', { class: 'eyebrow', text: 'Totals' }), el('h2', { text: 'Account and organization summary' })])
|
|
]),
|
|
grid
|
|
]));
|
|
container.appendChild(el('section', { class: 'admin-card' }, [
|
|
el('div', { class: 'admin-card-heading' }, [
|
|
el('div', {}, [el('p', { class: 'eyebrow', text: 'Traceability' }), el('h2', { text: 'Recent activity' })])
|
|
]),
|
|
activity
|
|
]));
|
|
|
|
api.getSummary().then(function (data) {
|
|
api.clear(grid);
|
|
SUMMARY_CARDS.forEach(function (card) {
|
|
var value = data && typeof data[card.key] === 'number' ? data[card.key] : 0;
|
|
grid.appendChild(el('article', { class: 'admin-stat' }, [
|
|
el('span', { class: 'admin-stat-label', text: card.label }),
|
|
el('strong', { text: String(value) }),
|
|
el('small', { text: card.note })
|
|
]));
|
|
});
|
|
}, function (error) {
|
|
api.clear(grid);
|
|
grid.appendChild(emptyBlock('Summary unavailable', api.describeError(error)));
|
|
});
|
|
|
|
api.listAudit({ limit: 10 }).then(function (data) {
|
|
api.clear(activity);
|
|
var events = (data && data.items) || [];
|
|
if (!events.length) {
|
|
activity.appendChild(emptyBlock('No recent activity', 'Nothing has been recorded yet.'));
|
|
return;
|
|
}
|
|
events.forEach(function (event) {
|
|
activity.appendChild(el('div', { class: 'audit-row' }, [
|
|
el('span', { class: 'audit-row__when', text: api.formatDateTime(event.occurred_at) }),
|
|
el('span', { class: 'audit-row__action', text: event.action }),
|
|
statusPill(event.outcome === 'success' ? 'active' : 'suspended')
|
|
]));
|
|
});
|
|
}, function (error) {
|
|
api.clear(activity);
|
|
activity.appendChild(emptyBlock('Activity unavailable', api.describeError(error)));
|
|
});
|
|
}
|
|
|
|
/* ----------------------------------------------------------- accounts */
|
|
|
|
function renderAccounts(container) {
|
|
container.appendChild(heading('Identity', 'Accounts', 'Every account inside your administrative scope, with credential and session state.'));
|
|
|
|
var tbody = el('tbody', {});
|
|
container.appendChild(el('section', { class: 'admin-card' }, [
|
|
el('div', { class: 'admin-table-wrap' }, [
|
|
el('table', { class: 'admin-table admin-table--wide' }, [
|
|
el('thead', {}, [el('tr', {}, [
|
|
el('th', { scope: 'col', text: 'Account' }),
|
|
el('th', { scope: 'col', text: 'Email' }),
|
|
el('th', { scope: 'col', text: 'Role' }),
|
|
el('th', { scope: 'col', text: 'Status' })
|
|
])]),
|
|
tbody
|
|
])
|
|
])
|
|
]));
|
|
|
|
tbody.appendChild(el('tr', {}, [el('td', { colspan: '4' }, [loadingNote('Loading accounts…')])]));
|
|
|
|
api.listAccounts({ limit: 50 }).then(function (data) {
|
|
api.clear(tbody);
|
|
var items = (data && data.items) || [];
|
|
if (!items.length) {
|
|
tbody.appendChild(el('tr', {}, [el('td', { colspan: '4' }, [emptyBlock('No accounts', 'No accounts are in scope.')])]));
|
|
return;
|
|
}
|
|
items.forEach(function (account) {
|
|
tbody.appendChild(el('tr', {}, [
|
|
el('td', { text: account.display_name || account.username || '—' }),
|
|
el('td', { text: account.email || '—' }),
|
|
el('td', { text: api.label(account.role) }),
|
|
el('td', {}, [statusPill(account.status)])
|
|
]));
|
|
});
|
|
}, function (error) {
|
|
api.clear(tbody);
|
|
tbody.appendChild(el('tr', {}, [el('td', { colspan: '4' }, [emptyBlock('Accounts unavailable', api.describeError(error))])]));
|
|
});
|
|
}
|
|
|
|
/* ------------------------------------------------------- organizations */
|
|
|
|
function renderOrganizations(container) {
|
|
container.appendChild(heading('Tenancy', 'Organizations', 'Tenants that own accounts.'));
|
|
|
|
var tbody = el('tbody', {});
|
|
container.appendChild(el('section', { class: 'admin-card' }, [
|
|
el('div', { class: 'admin-table-wrap' }, [
|
|
el('table', { class: 'admin-table admin-table--wide' }, [
|
|
el('thead', {}, [el('tr', {}, [
|
|
el('th', { scope: 'col', text: 'Organization' }),
|
|
el('th', { scope: 'col', text: 'Slug' }),
|
|
el('th', { scope: 'col', text: 'Status' }),
|
|
el('th', { scope: 'col', text: 'Members' })
|
|
])]),
|
|
tbody
|
|
])
|
|
])
|
|
]));
|
|
|
|
tbody.appendChild(el('tr', {}, [el('td', { colspan: '4' }, [loadingNote('Loading organizations…')])]));
|
|
|
|
api.listOrganizations({ limit: 100 }).then(function (data) {
|
|
api.clear(tbody);
|
|
var items = (data && data.items) || [];
|
|
if (!items.length) {
|
|
tbody.appendChild(el('tr', {}, [el('td', { colspan: '4' }, [emptyBlock('No organizations', 'No organizations are in scope.')])]));
|
|
return;
|
|
}
|
|
items.forEach(function (organization) {
|
|
tbody.appendChild(el('tr', {}, [
|
|
el('td', { text: organization.name }),
|
|
el('td', { text: organization.slug }),
|
|
el('td', {}, [statusPill(organization.status)]),
|
|
el('td', { text: String(organization.member_count == null ? '—' : organization.member_count) })
|
|
]));
|
|
});
|
|
}, function (error) {
|
|
api.clear(tbody);
|
|
tbody.appendChild(el('tr', {}, [el('td', { colspan: '4' }, [emptyBlock('Organizations unavailable', api.describeError(error))])]));
|
|
});
|
|
}
|
|
|
|
/* ----------------------------------------------------------- projects */
|
|
|
|
function renderProjects(container) {
|
|
container.appendChild(heading('Work', 'Projects', 'Units of work inside your organization. Everyone in the organization sees every project.'));
|
|
|
|
var tbody = el('tbody', {});
|
|
container.appendChild(el('section', { class: 'admin-card' }, [
|
|
el('div', { class: 'admin-table-wrap' }, [
|
|
el('table', { class: 'admin-table' }, [
|
|
el('thead', {}, [el('tr', {}, [
|
|
el('th', { scope: 'col', text: 'Project' }),
|
|
el('th', { scope: 'col', text: 'Slug' }),
|
|
el('th', { scope: 'col', text: 'Status' }),
|
|
el('th', { scope: 'col', text: 'Working on it' })
|
|
])]),
|
|
tbody
|
|
])
|
|
])
|
|
]));
|
|
|
|
tbody.appendChild(el('tr', {}, [el('td', { colspan: '4' }, [loadingNote('Loading projects…')])]));
|
|
|
|
api.listProjects({ limit: 200 }).then(function (data) {
|
|
api.clear(tbody);
|
|
// /api/uadmin/projects answers with `projects`, unlike the other admin
|
|
// lists, which answer with `items`. Accept either rather than rendering an
|
|
// empty table against a response that is not empty.
|
|
var items = (data && (data.projects || data.items)) || [];
|
|
if (!items.length) {
|
|
tbody.appendChild(el('tr', {}, [el('td', { colspan: '4' }, [emptyBlock('No projects yet', 'Create the first project from the control panel.')])]));
|
|
return;
|
|
}
|
|
items.forEach(function (project) {
|
|
tbody.appendChild(el('tr', {}, [
|
|
el('td', { text: project.name }),
|
|
el('td', { text: project.slug }),
|
|
el('td', {}, [statusPill(project.status)]),
|
|
el('td', { text: (project.assigned_count || 0) === 1 ? '1 person' : (project.assigned_count || 0) + ' people' })
|
|
]));
|
|
});
|
|
}, function (error) {
|
|
api.clear(tbody);
|
|
tbody.appendChild(el('tr', {}, [el('td', { colspan: '4' }, [emptyBlock('Projects unavailable', api.describeError(error))])]));
|
|
});
|
|
}
|
|
|
|
/* -------------------------------------------------------------- audit */
|
|
|
|
function renderAudit(container) {
|
|
container.appendChild(heading('Traceability', 'Audit log', 'Sanitized identity events.'));
|
|
|
|
var tbody = el('tbody', {});
|
|
container.appendChild(el('section', { class: 'admin-card' }, [
|
|
el('div', { class: 'admin-table-wrap' }, [
|
|
el('table', { class: 'admin-table admin-table--audit' }, [
|
|
el('thead', {}, [el('tr', {}, [
|
|
el('th', { scope: 'col', text: 'Occurred' }),
|
|
el('th', { scope: 'col', text: 'Action' }),
|
|
el('th', { scope: 'col', text: 'Outcome' })
|
|
])]),
|
|
tbody
|
|
])
|
|
])
|
|
]));
|
|
|
|
tbody.appendChild(el('tr', {}, [el('td', { colspan: '3' }, [loadingNote('Loading audit events…')])]));
|
|
|
|
api.listAudit({ limit: 50 }).then(function (data) {
|
|
api.clear(tbody);
|
|
var events = (data && data.items) || [];
|
|
if (!events.length) {
|
|
tbody.appendChild(el('tr', {}, [el('td', { colspan: '3' }, [emptyBlock('No audit events', 'Nothing has been recorded yet.')])]));
|
|
return;
|
|
}
|
|
events.forEach(function (event) {
|
|
tbody.appendChild(el('tr', {}, [
|
|
el('td', { text: api.formatDateTime(event.occurred_at) }),
|
|
el('td', { text: event.action }),
|
|
el('td', { text: event.outcome })
|
|
]));
|
|
});
|
|
}, function (error) {
|
|
api.clear(tbody);
|
|
tbody.appendChild(el('tr', {}, [el('td', { colspan: '3' }, [emptyBlock('Audit log unavailable', api.describeError(error))])]));
|
|
});
|
|
}
|
|
|
|
/* ------------------------------------------------------------- mount */
|
|
|
|
var RENDERERS = {
|
|
dashboard: renderDashboard,
|
|
accounts: renderAccounts,
|
|
organizations: renderOrganizations,
|
|
projects: renderProjects,
|
|
audit: renderAudit
|
|
};
|
|
|
|
function mount(container, options) {
|
|
options = options || {};
|
|
var current = options.section || 'dashboard';
|
|
|
|
function draw() {
|
|
api.clear(container);
|
|
var render = RENDERERS[current] || renderDashboard;
|
|
try {
|
|
render(container);
|
|
} catch (error) {
|
|
container.appendChild(emptyBlock('Section failed to render', api.describeError(error)));
|
|
}
|
|
}
|
|
|
|
draw();
|
|
|
|
return {
|
|
show: function (section) {
|
|
if (section && section !== current) {
|
|
current = section;
|
|
draw();
|
|
}
|
|
},
|
|
destroy: function () { api.clear(container); },
|
|
section: function () { return current; }
|
|
};
|
|
}
|
|
|
|
global.UmbraAdminView = { mount: mount };
|
|
})(window);
|