Files
UmbraTrans/web/assets/admin-view.js
umbra audio 3e0568b9d1 Generate backend endpoints for language-project workflow
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
2026-10-25 11:04:53 -07:00

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);