Files
UmbraTrans/web/assets/app.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

840 lines
30 KiB
JavaScript

// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
/*
* UmbraTrans sign-in and workspace page behaviour.
*
* This file is shared by `index.html` and `workspace.html`. Every DOM lookup
* is guarded so the two pages can share it: each setup function returns
* immediately when the markup it drives is not present.
*
* Sign-in posts the account *name* (never an email address) to
* `POST /api/auth/login`, then routes by role. A session that must replace its
* temporary password is held on the page and navigation is locked until the
* change succeeds.
*/
(function (global) {
'use strict';
var api = global.UmbraApi;
if (!api) {
return;
}
var query = api.query;
var queryAll = api.queryAll;
/* -------------------------------------------------------- first-run setup */
var setupEntry = query('#setup-entry');
var setupLink = query('#setup-link');
/*
* Offer the setup page only when the service says it is outstanding.
*
* Asked rather than assumed, and a failed probe hides the link: advertising a
* setup page that is certain to refuse is worse than not offering it.
*/
function offerSetupIfRequired() {
api.request('/api/setup/status', { method: 'GET' }).then(
function (data) {
if (data && data.setup_required) {
api.show(setupEntry, true);
api.show(setupLink, true);
}
},
function () {
api.show(setupEntry, false);
api.show(setupLink, false);
}
);
}
/* ------------------------------------------------------------- sign-in */
function setMessage(node, text, tone) {
if (!node) {
return;
}
node.textContent = text;
node.className = 'form-message' + (tone ? ' form-message--' + tone : '');
}
function destinationFor(role) {
// Every session lands in the Umbra Tools shell, which hosts the control
// panel, project work, the editor, and audio as activities. Previously this
// sent administrators straight to `uadmin/` and members to `workspace.html`,
// which meant the shell was never reached and the two old pages drifted
// apart. The shell now owns the top banner, left rail, and activity switch,
// and embeds the control panel rather than replacing the navigation with it.
return 'umbra-app.html';
}
function showPasswordToggle(button, input) {
if (!button || !input) {
return;
}
/*
* The button names the action a click will take. This was inverted: the value
* below was called `reveal` but held `input.type === 'password'` - true when the
* password is HIDDEN - and then chose the label, so a masked field offered
* "Hide" and clicking it revealed the password and relabelled itself "Show".
*
* Renamed to `visible`, which is what the value means. A correct-looking name is
* not what prevents this; the name agreeing with the value is.
*/
var visible = input.type === 'text';
button.setAttribute('aria-pressed', visible ? 'true' : 'false');
button.textContent = visible ? 'Hide' : 'Show';
button.setAttribute('aria-label', (visible ? 'Hide' : 'Show') + ' ' + (input.getAttribute('data-field-label') || 'password'));
}
function wirePasswordToggle(buttonId, inputId) {
var button = query('#' + buttonId);
var input = query('#' + inputId);
if (!button || !input) {
return;
}
showPasswordToggle(button, input);
button.addEventListener('click', function () {
input.type = input.type === 'text' ? 'password' : 'text';
showPasswordToggle(button, input);
});
}
function setupSignIn() {
var loginForm = query('#login-form');
var passwordForm = query('#password-form');
var loginView = query('#login-view');
var passwordView = query('#password-view');
if (!loginView || !passwordView) {
return;
}
var loginMessage = query('#login-message');
var passwordMessage = query('#password-message');
var loginSubmit = query('#login-submit');
var passwordSubmit = query('#password-submit');
var usernameInput = query('#login-email');
var passwordInput = query('#login-password');
var verifyPanel = query('#verify-panel');
var verifyPanelText = query('#verify-panel-text');
var verifyPanelMessage = query('#verify-panel-message');
var resendButton = query('#resend-verification');
var currentInput = query('#current-password');
var newInput = query('#new-password');
var confirmInput = query('#confirm-password');
var adminEntry = query('#admin-entry');
var adminLink = query('#admin-link');
var serviceNote = query('#service-note');
var navigationLocked = false;
var currentUsername = '';
function onBeforeUnload(event) {
if (!navigationLocked) {
return undefined;
}
event.preventDefault();
event.returnValue = '';
return '';
}
function setNavigationLocked(locked) {
navigationLocked = Boolean(locked);
if (navigationLocked) {
global.addEventListener('beforeunload', onBeforeUnload);
} else {
global.removeEventListener('beforeunload', onBeforeUnload);
}
}
// While a mandatory password change is outstanding, no link on this page
// may navigate away. The session stays usable for the change itself.
document.addEventListener(
'click',
function (event) {
if (!navigationLocked) {
return;
}
var target = event.target;
var anchor = target && typeof target.closest === 'function' ? target.closest('a[href]') : null;
if (!anchor) {
return;
}
event.preventDefault();
setMessage(
passwordMessage,
'Finish changing your password before leaving this page.',
'error'
);
},
true
);
function openPasswordView() {
// The settings page needs a usable session, and this view is the one
// state where there is not one yet, so the link stays hidden here.
api.show(passwordSettings, false);
api.show(loginView, false);
api.show(passwordView, true);
api.show(adminEntry, false);
api.show(adminLink, false);
setNavigationLocked(true);
setMessage(passwordMessage, '', null);
renderPolicyHint();
if (global.history && global.history.replaceState) {
global.history.replaceState(null, '', '#change-password');
}
if (currentInput) {
currentInput.focus();
}
}
function openLoginView() {
setNavigationLocked(false);
api.show(passwordView, false);
api.show(loginView, true);
api.show(adminEntry, true);
api.show(adminLink, true);
}
function busy(isBusy) {
api.disable(loginSubmit, isBusy);
api.disable(passwordSubmit, isBusy);
api.setBusy(loginForm, isBusy);
api.setBusy(passwordForm, isBusy);
}
function describeLoginFailure(error) {
if (!error) {
return 'Sign in failed. Try again.';
}
if (error.code === 'invalid_credentials' || error.status === 401) {
// Deliberately identical for an unknown account, a wrong password, a
// locked account, and a suspended tenant. The response must not be
// usable to discover which accounts exist.
return 'Sign in failed. Check your email address and password.';
}
if (error.code === 'password_policy') {
return api.describeRequirements(error);
}
return api.describeError(error);
}
if (loginForm) {
loginForm.addEventListener('submit', function (event) {
event.preventDefault();
var email = usernameInput ? usernameInput.value.trim() : '';
var password = passwordInput ? passwordInput.value : '';
if (!email || !password) {
setMessage(loginMessage, 'Enter your email address and password to continue.', 'error');
return;
}
setMessage(loginMessage, 'Signing in…', null);
hideVerifyPanel();
busy(true);
api.login(email, password).then(
function (data) {
busy(false);
if (passwordInput) {
passwordInput.value = '';
}
currentUsername = (data && data.user && data.user.username) || '';
if (data && data.must_change_password) {
openPasswordView();
return;
}
var role = data && data.user ? data.user.role : null;
global.location.replace(destinationFor(role));
},
function (error) {
busy(false);
if (passwordInput) {
passwordInput.value = '';
}
// Reached only once the password has already been proven correct, so
// naming the reason here does not turn the form into a way to test
// whether an address has an account.
if (error && error.code === 'email_not_verified') {
showVerifyPanel(email);
return;
}
setMessage(loginMessage, describeLoginFailure(error), 'error');
if (usernameInput) {
usernameInput.focus();
}
}
);
});
}
function showVerifyPanel(email) {
if (verifyPanelText) {
verifyPanelText.textContent =
'Your password is correct, but ' + email + ' has not been confirmed yet. ' +
'Open the confirmation link in your inbox, or paste it below.';
}
if (verifyPanel) {
verifyPanel.hidden = false;
}
if (loginMessage) {
setMessage(loginMessage, '', null);
}
}
function hideVerifyPanel() {
if (verifyPanel) {
verifyPanel.hidden = true;
}
if (verifyPanelMessage) {
setMessage(verifyPanelMessage, '', null);
}
}
if (resendButton) {
resendButton.addEventListener('click', function () {
var email = usernameInput ? usernameInput.value.trim() : '';
if (!email) {
setMessage(verifyPanelMessage, 'Enter your email address above first.', 'error');
return;
}
api.disable(resendButton, true);
setMessage(verifyPanelMessage, 'Sending a new link…', null);
api.resendVerification(email).then(
function () {
api.disable(resendButton, false);
// The same answer whether or not the address has an account, so this
// wording is deliberate and must not be made more specific.
setMessage(
verifyPanelMessage,
'If that address has an account awaiting confirmation, a new link is on its way.',
'success'
);
},
function (error) {
api.disable(resendButton, false);
setMessage(verifyPanelMessage, api.describeError(error), 'error');
}
);
});
}
if (passwordForm) {
passwordForm.addEventListener('submit', function (event) {
event.preventDefault();
var current = currentInput ? currentInput.value : '';
var next = newInput ? newInput.value : '';
var confirmation = confirmInput ? confirmInput.value : '';
if (!current || !next) {
setMessage(passwordMessage, 'Enter your current password and a new password.', 'error');
return;
}
if (next !== confirmation) {
setMessage(passwordMessage, 'The two new passwords do not match.', 'error');
return;
}
if (next.length < api.PASSWORD_MIN_LENGTH) {
setMessage(
passwordMessage,
'The new password must be at least ' + api.PASSWORD_MIN_LENGTH + ' characters.',
'error'
);
return;
}
setMessage(passwordMessage, 'Updating your password…', null);
busy(true);
api.changeFirstPassword(current, next).then(
function (data) {
busy(false);
if (currentInput) {
currentInput.value = '';
}
if (newInput) {
newInput.value = '';
}
if (confirmInput) {
confirmInput.value = '';
}
if (data && data.must_change_password) {
setMessage(
passwordMessage,
'The password was not changed. Choose a new one and try again.',
'error'
);
return;
}
setNavigationLocked(false);
if (global.history && global.history.replaceState) {
global.history.replaceState(null, '', global.location.pathname);
}
var role = data && data.user ? data.user.role : null;
global.location.replace(destinationFor(role));
},
function (error) {
busy(false);
if (currentInput) {
currentInput.value = '';
}
if (newInput) {
newInput.value = '';
}
if (confirmInput) {
confirmInput.value = '';
}
if (error && (error.code === 'invalid_credentials' || error.status === 401)) {
if (error.code === 'invalid_credentials') {
setMessage(
passwordMessage,
'The temporary password you entered is not correct.',
'error'
);
} else {
// No usable session: the change cannot be attempted. Return to
// the sign-in form so the account can sign in again.
openLoginView();
setMessage(loginMessage, 'Your session ended. Sign in again to continue.', 'error');
}
} else if (error && error.code === 'password_policy') {
setMessage(passwordMessage, api.describeRequirements(error), 'error');
} else {
setMessage(passwordMessage, api.describeError(error), 'error');
}
if (currentInput) {
currentInput.focus();
}
}
);
});
}
var signoutInstead = query('#password-signout');
if (signoutInstead) {
signoutInstead.addEventListener('click', function () {
busy(true);
// Whether or not the revocation count comes back, the server clears the
// session cookie on this route, so the page always returns to sign-in.
var leave = function () {
setNavigationLocked(false);
if (global.history && global.history.replaceState) {
global.history.replaceState(null, '', global.location.pathname);
}
global.location.replace(global.location.pathname);
};
api.logout().then(leave, leave);
});
}
var policyUsername = query('#policy-username');
if (policyUsername) {
policyUsername.textContent = 'Must not contain your account name.';
renderPolicyHint();
}
// The policy forbids a password containing the account's username, which is a
// display handle and is often absent. It is deliberately not the sign-in
// field: that collects an email address, and telling the user their password
// must avoid their own email while the rule checks something else would be
// worse than saying nothing.
function renderPolicyHint() {
if (!policyUsername) {
return;
}
var handle = currentUsername || '';
policyUsername.textContent = handle
? 'Must not contain “' + handle + '”.'
: 'Must not contain your account name.';
}
wirePasswordToggle('toggle-login-password', 'login-password');
wirePasswordToggle('toggle-current-password', 'current-password');
wirePasswordToggle('toggle-new-password', 'new-password');
// `GET /api/health` is unauthenticated and issues the CSRF cookie the
// mandatory change needs, so it runs before any form is submitted.
api.health().then(
function (data) {
if (!serviceNote) {
return;
}
var parts = [];
if (data && data.service) {
parts.push(data.service);
}
if (data && data.version) {
parts.push('version ' + data.version);
}
if (data && data.environment) {
parts.push(data.environment);
}
serviceNote.textContent = parts.length > 0 ? parts.join(' · ') : 'Service reachable.';
serviceNote.classList.add('prototype-note--live');
},
function () {
if (serviceNote) {
serviceNote.textContent = 'The service could not be reached. Sign-in may fail.';
}
}
);
if (global.location.hash === '#change-password') {
// Another page redirected here because this session still owes a
// password change. Show the view without trying to resolve the session:
// `GET /api/auth/me` rejects a session in this state by design.
openPasswordView();
} else {
openLoginView();
}
if (usernameInput) {
usernameInput.focus();
}
}
/* ----------------------------------------------------------- workspace */
function setupWorkspace() {
var main = query('#overview');
if (!main) {
return;
}
api.setSessionLostHandler(function () {
api.forgetLocalState();
global.location.replace('index.html');
});
api.setPasswordChangeHandler(function () {
global.location.replace('index.html#change-password');
});
function renderIdentity(session) {
var user = (session && session.user) || {};
var role = (session && session.role) || user.role || 'member';
var displayName = user.display_name || user.username || '';
var organization = session && session.organization_id ? session.organization_id : null;
var nameNode = query('#rail-display-name');
if (nameNode) {
nameNode.textContent = displayName || 'Unknown account';
}
var roleNode = query('#rail-role');
if (roleNode) {
roleNode.textContent = organization
? api.label(role) + ' · org ' + organization
: api.label(role);
}
var avatar = query('#rail-avatar');
if (avatar) {
avatar.textContent = api.initials(displayName, user.username);
}
var breadcrumb = query('#breadcrumb-identity');
if (breadcrumb) {
breadcrumb.textContent = user.username || displayName || 'Account';
}
var title = query('#workspace-title');
if (title) {
title.textContent = displayName || 'Workspace';
}
var subtitle = query('#workspace-subtitle');
if (subtitle) {
subtitle.textContent =
'Signed in as ' + (user.username || 'this account') +
' with the ' + api.label(role).toLowerCase() + ' role.';
}
var pill = query('#workspace-session-pill');
if (pill) {
pill.className = 'status-pill status-pill--success';
pill.textContent = 'Session active';
}
var isAdmin = role === 'platform_admin' || role === 'organization_admin';
['#workspace-admin-link', '#workspace-admin-topbar', '#workspace-admin-hero'].forEach(
function (selector) {
var node = query(selector);
if (node) {
api.show(node, isAdmin);
}
}
);
}
/* ------------------------------------------------------- the dashboard */
// The one response that fills the page. Rendered from `snapshot` and
// re-filtered from it locally, so switching between "all" and "assigned to
// me" can never show a mix of two fetches - and so a filter can never widen
// what the server decided this session may see.
var snapshot = null;
var filter = 'all';
var STATUS_TEXT = {
active: 'Active',
on_hold: 'On hold',
released: 'Released',
archived: 'Archived'
};
function statusPill(status) {
var tone =
status === 'active' ? 'success'
: status === 'on_hold' ? 'warning'
: status === 'released' ? 'info'
: 'muted';
return api.el('span', {
class: 'status-pill status-pill--' + tone,
text: STATUS_TEXT[status] || api.label(status)
});
}
function projectCard(project) {
var meta = [
api.el('span', { class: 'project-card__slug', text: '/' + project.slug }),
api.el('span', { text: project.assigned_count === 1 ? '1 person assigned' : project.assigned_count + ' assigned' })
];
if (project.updated_at) {
meta.push(api.el('span', { text: 'updated ' + api.formatDateTime(project.updated_at) }));
}
// Who is working on it is a fact about the project; whether that includes
// *you* is a fact about the viewer. Both are shown, and neither filters the
// row out. Visibility inside an organization is not per-assignment.
var side = [statusPill(project.status)];
if (project.assigned_to_me) {
side.push(api.el('span', { class: 'status-pill marker--mine', text: 'On your projects' }));
} else if (!project.assigned_count) {
side.push(api.el('span', { class: 'status-pill marker--unclaimed', text: 'Needs someone' }));
}
var card = api.el('article', { class: 'project-card project-card--' + project.status, role: 'button', tabIndex: 0 }, [
api.el('div', { class: 'project-card__main' }, [
api.el('span', { class: 'project-card__name', text: project.name }),
api.el('div', { class: 'project-card__meta' }, meta)
]),
api.el('div', { class: 'project-card__side' }, side)
]);
card.addEventListener('click', function () {
try {
var url = 'workspace-project.html?id=' + encodeURIComponent(project.id);
window.location.href = url;
} catch (e) {
// ignore
}
});
card.addEventListener('keydown', function (ev) {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
card.click();
}
});
return card;
}
function setMetric(selector, value) {
var node = query(selector);
if (!node) {
return;
}
// A zero is a real measurement and is shown as 0. A dash means "we do not
// know yet", and is kept visually distinct so it is not read as zero.
node.textContent = value === null || value === undefined ? '—' : String(value);
if (value === null || value === undefined) {
node.dataset.empty = 'true';
} else {
delete node.dataset.empty;
}
}
function renderProjects() {
var list = query('#projects-list');
var empty = query('#projects-empty');
var summary = query('#projects-summary');
var heading = query('#projects-title');
var eyebrow = query('#projects-eyebrow');
if (!list || !snapshot) {
return;
}
var organization = snapshot.organization;
if (eyebrow) {
// The organization is the heading the work hangs under, so it is the
// heading - not the person's name, which is in the rail.
eyebrow.textContent = organization ? organization.name : 'Platform';
}
if (heading) {
heading.textContent = filter === 'mine' ? 'Your projects' : 'Work in this organization';
}
var all = snapshot.projects || [];
var mine = snapshot.assigned_to_me || [];
var visible = filter === 'mine' ? mine : all;
api.clear(list);
visible.forEach(function (project) {
list.appendChild(projectCard(project));
});
if (summary) {
summary.className = 'list-summary';
if (!organization) {
// A platform administrator belongs to no tenant, so there is no
// organization's work to show. Said plainly rather than as zero.
summary.textContent =
'You administer the platform rather than working in one organization. ' +
'Choose an organization from the control panel to see its projects.';
} else if (visible.length === 0) {
summary.textContent =
filter === 'mine'
? 'No projects are assigned to you in ' + organization.name + ' yet.'
: 'No projects in ' + organization.name + ' yet.';
} else if (filter === 'mine') {
summary.textContent =
'Showing ' + visible.length + ' of ' + all.length + ' project' +
(all.length === 1 ? '' : 's') + ' in ' + organization.name + '.';
} else {
summary.textContent =
'Showing all ' + all.length + ' project' + (all.length === 1 ? '' : 's') +
' in ' + organization.name + '. Everyone here sees every project; ' +
'being assigned says who is working on it.';
}
}
if (empty) {
var nothingAtAll = all.length === 0;
var nothingForMe = !nothingAtAll && visible.length === 0;
api.show(empty, nothingAtAll || nothingForMe);
var title = query('#projects-empty-title');
var body = query('#projects-empty-body');
if (title) {
title.textContent = nothingAtAll ? 'No projects yet' : 'Nothing assigned to you';
}
if (body) {
body.textContent = nothingAtAll
? 'This organization has no projects yet. An organization administrator can create the first one from the control panel.'
: filter === 'mine'
? 'No project in ' + organization.name + ' is assigned to you. You can still see all ' + all.length + ' of them below the filter.'
: '';
}
}
['#project-filter-all', '#project-filter-mine'].forEach(function (selector) {
var button = query(selector);
if (!button) {
return;
}
var active = button.id === (filter === 'mine' ? 'project-filter-mine' : 'project-filter-all');
button.className = active ? 'chip chip--active' : 'chip';
button.setAttribute('aria-pressed', active ? 'true' : 'false');
});
}
function renderMetrics() {
if (!snapshot) {
return;
}
var all = snapshot.projects || [];
var unclaimed = all.filter(function (project) { return !project.assigned_count; }).length;
var inProgress = all.filter(function (project) { return project.status === 'active'; }).length;
setMetric('#metric-projects', all.length);
setMetric('#metric-assigned', (snapshot.assigned_to_me || []).length);
setMetric('#metric-active', inProgress);
setMetric('#metric-unclaimed', unclaimed);
}
function bindFilter() {
[['#project-filter-all', 'all'], ['#project-filter-mine', 'mine']].forEach(function (entry) {
var button = query(entry[0]);
if (!button) {
return;
}
button.addEventListener('click', function () {
if (filter === entry[1]) {
return;
}
filter = entry[1];
renderProjects();
});
});
}
function loadWorkspace() {
return api.workspace().then(function (data) {
snapshot = data;
var organization = data.organization;
var title = query('#workspace-title');
if (title) {
// The organization names the page, because that is what the work
// belongs to. The account's own name stays in the rail.
title.textContent = organization ? organization.name : 'Platform';
}
var railRole = query('#rail-role');
if (railRole && organization) {
railRole.textContent = api.label((data.user && data.user.role) || 'member') + ' · ' + organization.name;
}
renderMetrics();
renderProjects();
}, function (error) {
var summary = query('#projects-summary');
if (summary && error && error.status !== 401 && error.code !== 'password_change_required') {
// Named as an error rather than left blank, so an empty list is never
// read as "this organization has no projects" when in fact nothing
// loaded.
summary.className = 'list-summary list-summary--error';
summary.textContent = 'Projects could not be loaded: ' + api.describeError(error);
}
});
}
function renderFailure(message) {
var subtitle = query('#workspace-subtitle');
if (subtitle) {
subtitle.textContent = message;
}
var pill = query('#workspace-session-pill');
if (pill) {
pill.className = 'status-pill status-pill--warning';
pill.textContent = 'Session unavailable';
}
}
// The session cookie is cleared by the server on this route, so the page
// returns to sign-in whether or not the revocation count comes back.
var leaveWorkspace = function () {
global.location.replace('index.html');
};
queryAll('[data-signout]').forEach(function (button) {
button.addEventListener('click', function () {
api.disable(button, true);
api.logout().then(leaveWorkspace, leaveWorkspace);
});
});
bindFilter();
// The session identifies the caller; the workspace says what they can see.
// Both are needed and neither subsumes the other, so they are two requests -
// but the dashboard renders from the second one alone, and never from a
// mixture of the two.
api.me().then(function (session) {
renderIdentity(session);
return loadWorkspace();
}, function (error) {
// A 401 and a 403 password_change_required are both already being
// handled by the redirect handlers above. This covers anything else.
if (error && error.status === 401) {
return;
}
if (error && error.code === 'password_change_required') {
return;
}
renderFailure(api.describeError(error));
});
}
setupSignIn();
setupWorkspace();
offerSetupIfRequired();
})(window);
try { var p = new URL(window.location.href); if (p.searchParams.get('shell') === 'umbra') { window.location.href = 'umbra-app.html'; } } catch(e){}