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

1571 lines
66 KiB
JavaScript

// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
/*\
* Umbra Tools shell behaviour — Program / Project model.
*
* Two-level navigation:
* 1. Activity: Control Panel, Program, Edit, Audio, Format
* 2. Program / language: topbar selector lists all languages; switching
* changes the main panel without leaving the program.
*
* A Program is a book being translated and produced as audiobooks. Each
* language under a program is a "project" (full manuscript: front + body +
* back matter, independent per language).
*
* Workflow (per language, English first):
* 1. Review / validate English text
* 2. Queue audio generation for English
* 3. After audio done + final approval, start text translation per language
* 4. After translation, review utterances by chapter (speaker alignment)
* 5. Queue audio generation for the language
* 6. Edit front matter (ISBN, copyright, etc.) in parallel with audio
* 7. Format EPUB / PDF, generate output, download
*
* The control panel is mounted inline from assets/admin-view.js.
*/
(function (global) {
'use strict';
var api = global.UmbraApi;
if (!api) {
return;
}
var el = api.el;
var q = api.query;
var ACTIVITIES = ['control', 'program', 'edit', 'audio', 'format', 'review'];
var state = {
activity: null,
section: null,
programId: null,
languageCode: null,
chapterId: null,
session: null,
workspace: null,
programs: [],
programById: {}
};
/* ---------------------------------------------------------------- routing */
function readHash() {
var raw = (global.location.hash || '').replace(/^#/, '');
if (!raw) {
return { activity: null, section: null, programId: null, languageCode: null };
}
var parts = raw.split('/');
var activity = parts[0];
if (ACTIVITIES.indexOf(activity) === -1) {
return { activity: null, section: null, programId: null, languageCode: null };
}
var langActivities = ['program', 'edit', 'audio', 'format', 'review'];
return {
activity: activity,
section: activity === 'control' ? (parts[1] || null) : null,
programId: langActivities.indexOf(activity) !== -1 ? (parts[1] || null) : null,
languageCode: langActivities.indexOf(activity) !== -1 ? (parts[2] || null) : null,
chapterId: activity === 'review' ? (parts[3] || null) : null
};
}
function writeHash(activity, extra1, extra2, extra3) {
var hash = '#' + activity;
if (extra1) {
hash += '/' + extra1;
if (extra2) {
hash += '/' + extra2;
if (extra3) {
hash += '/' + extra3;
}
}
}
if (global.location.hash !== hash) {
global.location.hash = hash;
}
}
/* ----------------------------------------------------- banner / identity */
function isAdmin(user) {
return user && (user.role === 'platform_admin' || user.role === 'organization_admin');
}
function paintIdentity(session) {
var user = (session && session.user) || session || {};
var name = user.display_name || user.username || 'User';
api.setText(q('#topbar-username'), name);
api.setText(q('#topbar-role'), api.label(user.role) || '—');
api.setText(q('#topbar-avatar'), api.initials(user.display_name, user.username) || '··');
var option = q('#activity-select option[value="control"]');
if (option) {
option.hidden = !isAdmin(user);
option.disabled = !isAdmin(user);
}
}
/* -------------------------------------------------------------- left rail */
var ADMIN_SECTIONS = [
{ id: 'dashboard', label: 'Dashboard', glyph: '▦' },
{ id: 'accounts', label: 'Accounts', glyph: '♙' },
{ id: 'organizations', label: 'Organizations', glyph: '◈' },
{ id: 'projects', label: 'Projects', glyph: '◇' },
{ id: 'audit', label: 'Audit log', glyph: '≡' }
];
function railSection(title, nodes) {
var section = el('div', { class: 'umbra-sidebar__section' });
if (title) {
section.appendChild(el('div', { class: 'umbra-sidebar__title', text: title }));
}
nodes.forEach(function (node) { section.appendChild(node); });
return section;
}
function railButton(text, options) {
options = options || {};
var props = {
class: 'umbra-nav__item' + (options.active ? ' umbra-nav__item--active' : ''),
type: 'button',
text: text
};
if (options.onClick) {
props.on = { click: options.onClick };
}
return el('button', props);
}
function renderSidebar() {
var sidebar = q('#sidebar');
if (!sidebar) {
return;
}
api.clear(sidebar);
if (state.activity === 'control') {
sidebar.appendChild(railSection('Administration', ADMIN_SECTIONS.map(function (item) {
return railButton(item.glyph + ' ' + item.label, {
active: (state.section || 'dashboard') === item.id,
onClick: function () { writeHash('control', item.id); }
});
})));
sidebar.appendChild(railSection('Nodes', [
railButton('◉ Nodes', { onClick: function () { global.location.href = 'uadmin/nodes.html'; } })
]));
return;
}
if (state.activity === 'program') {
if (state.languageCode) {
// Language project level.
var langNodes = [
railButton('Overview', { active: true }),
railButton('Metadata', {}),
railButton('Translate', {}),
railButton('Audio', {}),
railButton('Format', {}),
railButton('Publish', {})
];
sidebar.appendChild(railSection('Project: ' + languageTagLabel(state.languageCode), langNodes));
return;
}
// Program level.
var programNodes = state.programId
? [
railButton('Overview', { active: true }),
railButton('Languages', {}),
railButton('Import', {}),
railButton('Export', {}),
railButton('Build', {}),
railButton('Analyze', {})
]
: [
railButton('Create', {}),
railButton('Import', {}),
railButton('Export', {}),
railButton('Build', {}),
railButton('Analyze', {})
];
sidebar.appendChild(railSection('Program', programNodes));
return;
}
if (state.activity === 'edit') {
sidebar.appendChild(railSection('Binder', [
railButton('Front matter', { active: true }),
railButton('Chapters', {}),
railButton('Scenes', {}),
railButton('Sections', {}),
railButton('Characters', {}),
railButton('Research', {}),
railButton('Trash', {})
]));
sidebar.appendChild(railSection('History', [
railButton('Save version', {})
]));
return;
}
if (state.activity === 'audio') {
sidebar.appendChild(railSection('Audio', [
railButton('Overview', { active: true }),
railButton('Speakers', {}),
railButton('Proper names', {}),
railButton('Chapters', {}),
railButton('Assignments', {}),
railButton('Runs', {}),
railButton('Queue', {})
]));
return;
}
if (state.activity === 'format') {
sidebar.appendChild(railSection('Format', [
railButton('EPUB', { active: true }),
railButton('PDF', {}),
railButton('Metadata', {}),
railButton('ISBN', {}),
railButton('Cover', {}),
railButton('Publish', {})
]));
return;
}
}
/* ------------------------------------------------------ control panel */
function renderControlPanel() {
var main = q('#main');
api.clear(main);
if (!global.UmbraAdminView || typeof global.UmbraAdminView.mount !== 'function') {
main.appendChild(el('div', { class: 'empty-state' }, [
el('span', { class: 'empty-state__icon', text: '⚙' }),
el('p', { class: 'empty-state__title', text: 'Control panel module unavailable' }),
el('p', { class: 'empty-state__body', text: 'The administration views could not be loaded. Open the standalone control panel at uadmin/.' })
]));
return;
}
global.UmbraAdminView.mount(main, {
section: state.section || 'dashboard',
onSectionChange: function (id) { writeHash('control', id); }
});
}
/* ------------------------------------------------------- program activity */
function statusPill(status) {
var tone =
status === 'active' ? 'success'
: status === 'on_hold' ? 'warning'
: status === 'released' ? 'info'
: 'muted';
var text = {
active: 'Active', on_hold: 'On hold', released: 'Released', archived: 'Archived'
}[status] || api.label(status);
return el('span', { class: 'status-pill status-pill--' + tone, text: text });
}
function languageTagLabel(code) {
var map = {
'en': 'English (source)',
'zh-Hans': 'Simplified Chinese (zh-CN)',
'es-ES': 'Spanish (es-ES)',
'fr-FR': 'French (fr-FR)',
'de-DE': 'German (de-DE)',
'it-IT': 'Italian (it-IT)',
'pt-BR': 'Portuguese (pt-BR)',
'ru-RU': 'Russian (ru-RU)',
'ja-JP': 'Japanese (ja-JP)',
'ko-KR': 'Korean (ko-KR)',
'ar-SA': 'Arabic (ar-SA)'
};
return map[code] || api.label(code) || code;
}
// Target language order for program detail view (English source first).
var TARGET_LANGUAGE_ORDER = ['en', 'zh-Hans', 'es-ES', 'fr-FR', 'de-DE', 'it-IT', 'pt-BR', 'ru-RU', 'ja-JP', 'ko-KR', 'ar-SA'];
function translationStatusLabel(state) {
if (state === 'complete') return { text: 'Text translated', tone: 'success' };
if (state === 'in_progress') return { text: 'In progress', tone: 'info' };
if (state === 'needs_review') return { text: 'Needs review', tone: 'warning' };
return { text: 'Not started', tone: 'muted' };
}
function programCard(program) {
var card = el('article', {
class: 'project-card project-card--' + program.status,
role: 'button', tabIndex: '0'
}, [
el('div', { class: 'project-card__main' }, [
el('span', { class: 'project-card__name', text: program.name }),
el('div', { class: 'project-card__meta' }, [
el('span', { class: 'project-card__slug', text: '/' + program.slug }),
el('span', { text: (program.assigned_count || 0) === 1 ? '1 person assigned' : (program.assigned_count || 0) + ' assigned' })
])
]),
el('div', { class: 'project-card__side' }, [
statusPill(program.status),
program.assigned_to_me ? el('span', { class: 'status-pill marker--mine', text: 'On your programs' })
: (!program.assigned_count ? el('span', { class: 'status-pill marker--unclaimed', text: 'Needs someone' }) : null)
].filter(Boolean))
]);
card.addEventListener('click', function () { writeHash('program', program.id); });
card.addEventListener('keydown', function (ev) {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
writeHash('program', program.id);
}
});
return card;
}
function renderProgramList() {
var main = q('#main');
api.clear(main);
main.appendChild(el('div', { class: 'umbra-heading' }, [
el('div', {}, [
el('p', { class: 'eyebrow', text: 'Programs' }),
el('h1', { text: 'Select a program' }),
el('p', { class: 'lede', text: 'A program is a book being translated and produced as audiobooks. Choose one, then select a language project.' })
])
]));
var programs = state.programs || [];
var list = el('div', { class: 'project-list' });
if (!programs.length) {
list.appendChild(el('div', { class: 'empty-state' }, [
el('span', { class: 'empty-state__icon', text: '◇' }),
el('p', { class: 'empty-state__title', text: 'No programs in scope' }),
el('p', { class: 'empty-state__body', text: 'No programs are visible to this account, or none exist yet.' })
]));
} else {
programs.forEach(function (program) { list.appendChild(programCard(program)); });
}
main.appendChild(el('p', { class: 'list-summary', text: programs.length === 1 ? 'Showing 1 program.' : 'Showing ' + programs.length + ' programs.' }));
main.appendChild(list);
}
function languageDropdownOptions(program) {
// All configured languages for this program, in fixed order.
return TARGET_LANGUAGE_ORDER.map(function (code) {
return el('option', { value: code, text: languageTagLabel(code) + ' — ' + program.name });
});
}
function languageActions(program, code, status) {
var isSource = code === 'en';
var tr = status && status.translation || {};
var out = status && status.outputs || {};
var actions = [];
function onQueueOk(result) {
api.showToast('Queued: ' + languageTagLabel(code), 'success');
// Refresh status to reflect new state
if (typeof api.projectStatus === 'function') {
api.projectStatus(program.id).then(function () { applyRoute(); });
}
}
function onQueueErr(err) {
api.showToast(api.describeError(err), 'error');
}
if (isSource) {
// English source: full workflow options.
actions.push(el('button', {
class: 'btn btn--ghost', type: 'button', text: 'Edit front matter',
on: { click: function () { writeHash('edit', program.id, code); } }
}));
actions.push(el('button', {
class: 'btn', type: 'button', text: 'Review text',
on: { click: function () { writeHash('review', program.id, code, 'ch-6'); } }
}));
actions.push(el('button', {
class: 'btn', type: 'button', text: 'Queue audio',
on: { click: function () {
api.queueAudio(program.id, code, {}).then(onQueueOk, onQueueErr);
} }
}));
actions.push(el('button', {
class: 'btn btn--subtle', type: 'button', text: 'Format EPUB',
on: { click: function () { writeHash('format', program.id, code); } }
}));
actions.push(el('button', {
class: 'btn btn--subtle', type: 'button', text: 'Format PDF',
on: { click: function () { writeHash('format', program.id, code); } }
}));
} else if (tr.state === 'complete') {
// Translation complete: edit front matter, review utterances, queue audio, format.
actions.push(el('button', {
class: 'btn btn--ghost', type: 'button', text: 'Edit front matter',
on: { click: function () { writeHash('edit', program.id, code); } }
}));
actions.push(el('button', {
class: 'btn btn--subtle', type: 'button', text: 'Review utterances',
on: { click: function () { writeHash('review', program.id, code, 'ch-6'); } }
}));
actions.push(el('button', {
class: 'btn', type: 'button', text: 'Queue audio',
on: { click: function () {
api.queueAudio(program.id, code, {}).then(onQueueOk, onQueueErr);
} }
}));
actions.push(el('button', {
class: 'btn btn--subtle', type: 'button', text: 'Format EPUB',
on: { click: function () { writeHash('format', program.id, code); } }
}));
actions.push(el('button', {
class: 'btn btn--subtle', type: 'button', text: 'Format PDF',
on: { click: function () { writeHash('format', program.id, code); } }
}));
if (out.epub) {
actions.push(el('a', { class: 'btn btn--ghost', href: out.epub.path, text: 'EPUB ↓', download: '' }));
}
if (out['pdf-paperback']) {
actions.push(el('a', { class: 'btn btn--ghost', href: out['pdf-paperback'].path, text: 'PDF ↓', download: '' }));
}
} else {
// Translation not started or in progress: queue translation.
actions.push(el('button', {
class: 'btn', type: 'button', text: 'Queue translation',
disabled: tr.state === 'in_progress',
title: tr.state === 'in_progress' ? 'Translation already queued.' : undefined,
on: { click: function () {
api.queueTranslation(program.id, code).then(onQueueOk, onQueueErr);
} }
}));
}
return actions;
}
function languageProjectCard(program, code, status) {
var label = languageTagLabel(code);
var isSource = code === 'en';
var tr = status && status.translation || {};
var out = status && status.outputs || {};
var badge = translationStatusLabel(tr.state || 'not_started');
var chapters = '';
if (tr.chapters_done != null && tr.chapters_total != null) {
chapters = tr.chapters_done + '/' + tr.chapters_total + ' chapters';
} else if (tr.state === 'complete') {
chapters = 'Complete';
}
return el('div', { class: 'language-row' + (code === state.languageCode ? ' language-row--selected' : ''), role: 'button', tabIndex: '0' }, [
el('div', { class: 'language-row__label' }, [
el('strong', { text: label }),
el('span', { class: 'language-row__role', text: isSource ? 'Source' : chapters })
]),
el('div', { class: 'language-row__status' }, [
el('span', { class: 'status-pill status-pill--' + badge.tone, text: badge.text })
]),
el('div', { class: 'language-row__actions' }, languageActions(program, code, status))
]);
}
function renderProgramDetail() {
var main = q('#main');
api.clear(main);
var program = state.programById[state.programId] || null;
if (!program) {
main.appendChild(el('div', { class: 'empty-state' }, [
el('span', { class: 'empty-state__icon', text: '◇' }),
el('p', { class: 'empty-state__title', text: 'Program not found' }),
el('p', { class: 'empty-state__body', text: 'This program is not in your scope, or it has been removed.' })
]));
return;
}
// Program selector: all languages, always visible.
var programSelect = q('#program-select');
programSelect.classList.remove('umbra-topbar__mode--hidden');
api.clear(programSelect);
programSelect.appendChild(el('option', { value: '', text: program.name + ' — select a language' }));
languageDropdownOptions(program).forEach(function (opt) { programSelect.appendChild(opt); });
programSelect.value = state.languageCode || '';
if (state.languageCode) {
// -------- Language project view --------
var langLabel = languageTagLabel(state.languageCode);
api.setText(q('#topbar-language-name'), state.languageCode.toUpperCase() + ' — ' + program.name);
main.appendChild(el('div', { class: 'umbra-heading' }, [
el('div', {}, [
el('p', { class: 'eyebrow', text: 'Language project' }),
el('h1', { text: langLabel }),
el('p', { class: 'lede', text: program.name + ' · ' + langLabel + '. Full manuscript: front, body, and back matter, independent per language.' })
])
]));
main.appendChild(el('div', { class: 'umbra-inline-actions' }, [
el('button', { class: 'btn btn--ghost', type: 'button', text: '← Programs', on: { click: function () { writeHash('program', program.id); } } })
]));
if (typeof api.projectStatus === 'function') {
api.projectStatus(state.programId).then(function (data) {
var langs = (data && data.languages) || [];
var match = null;
langs.forEach(function (l) { if (l.language_tag === state.languageCode) { match = l; } });
renderLanguageProject(main, program, state.languageCode, match);
}, function (err) {
main.appendChild(el('p', { class: 'list-summary list-summary--error', text: 'Status could not be loaded: ' + api.describeError(err) }));
main.appendChild(languageProjectCard(program, state.languageCode, null));
});
} else {
main.appendChild(languageProjectCard(program, state.languageCode, null));
}
return;
}
// -------- Program view (no language selected) --------
api.setText(q('#topbar-language-name'), program.name);
main.appendChild(el('div', { class: 'umbra-heading' }, [
el('div', {}, [
el('p', { class: 'eyebrow', text: 'Program' }),
el('h1', { text: program.name }),
el('p', { class: 'lede', text: 'Select a language to open its language project. English is reviewed first, then translations per language.' })
])
]));
main.appendChild(el('div', { class: 'umbra-inline-actions' }, [
el('button', { class: 'btn btn--ghost', type: 'button', text: '← All programs', on: { click: function () { writeHash('program'); } } })
]));
var rows = el('div', { class: 'project-rows' });
main.appendChild(rows);
if (typeof api.projectStatus === 'function') {
api.projectStatus(state.programId).then(function (data) {
var langs = (data && data.languages) || [];
var pending = {};
langs.forEach(function (l) { pending[l.language_tag] = l; });
TARGET_LANGUAGE_ORDER.filter(function (code) { return pending[code] != null; }).forEach(function (code) {
rows.appendChild(languageProjectCard(program, code, pending[code] || null));
});
if (rows.children.length === 0) {
rows.appendChild(el('p', { class: 'list-summary', text: 'No languages defined for this program yet.' }));
}
}, function (err) {
api.clear(rows);
rows.appendChild(el('p', { class: 'list-summary list-summary--error', text: 'Status could not be loaded: ' + api.describeError(err) }));
TARGET_LANGUAGE_ORDER.forEach(function (code) {
rows.appendChild(languageProjectCard(program, code, null));
});
});
} else {
TARGET_LANGUAGE_ORDER.forEach(function (code) {
rows.appendChild(languageProjectCard(program, code, null));
});
if (rows.children.length === 0) {
rows.appendChild(el('p', { class: 'list-summary', text: 'No languages defined for this program yet.' }));
}
}
}
/* ----------------------------------------------- language project dashboard */
function renderLanguageProject(main, program, code, status) {
var tr = status && status.translation || {};
var out = status && status.outputs || {};
var badge = translationStatusLabel(tr.state || 'not_started');
main.appendChild(languageProjectCard(program, code, status));
// Translation status panel.
var detail = el('div', { class: 'language-detail' });
detail.appendChild(el('div', { class: 'umbra-heading' }, [
el('div', {}, [
el('p', { class: 'eyebrow', text: 'Translation status' }),
el('h2', { text: badge.text }),
el('p', { class: 'lede', text: tr.chapters_done != null && tr.chapters_total != null
? tr.chapters_done + ' of ' + tr.chapters_total + ' chapters translated.'
: 'Translation status reported by the pipeline.' })
])
]));
detail.appendChild(el('div', { class: 'umbra-inline-actions' }, [
el('button', { class: 'btn btn--ghost', type: 'button', text: 'Edit front matter', on: { click: function () { writeHash('edit', program.id, code); } } }),
(tr.state === 'complete') ? el('button', {
class: 'btn btn--subtle', type: 'button', text: 'Review utterances by chapter',
on: { click: function () { writeHash('review', program.id, code, 'ch-6'); } }
}) : null,
(tr.state === 'complete') ? el('button', {
class: 'btn', type: 'button', text: 'Queue audio',
on: { click: function () {
api.queueAudio(program.id, code, {}).then(
function () { api.showToast('Audio queued for ' + languageTagLabel(code), 'success'); applyRoute(); },
function (err) { api.showToast(api.describeError(err), 'error'); }
);
} }
}) : null
].filter(Boolean)));
// Utterance-by-chapter review section (shown when translation complete).
if (tr.state === 'complete') {
detail.appendChild(el('div', { class: 'umbra-heading' }, [
el('div', {}, [
el('p', { class: 'eyebrow', text: 'Utterance review by chapter' }),
el('h2', { text: 'Speaker alignment & audio chapters' }),
el('p', { class: 'lede', text: 'Review utterances per chapter to confirm speaker assignments before audio generation. English utterance boundaries may differ from translation paragraph boundaries.' })
])
]));
detail.appendChild(el('div', { class: 'chapter-review-placeholder' }, [
el('div', { class: 'empty-state' }, [
el('span', { class: 'empty-state__icon', text: '⋮' }),
el('p', { class: 'empty-state__title', text: 'Utterance review view' }),
el('p', { class: 'empty-state__body', text: 'Per-chapter utterance listing with speaker assignments comes from the audio backend (UmbraAudio structure). Select a chapter to review its utterances and speaker alignment.' })
])
]));
}
// Outputs panel.
detail.appendChild(el('div', { class: 'umbra-heading' }, [
el('div', {}, [
el('p', { class: 'eyebrow', text: 'Outputs' }),
el('h2', { text: 'EPUB / PDF / Audio' }),
el('p', { class: 'lede', text: 'Generated outputs appear here once the language project is formatted and produced.' })
])
]));
var outRows = [];
if (out.epub) outRows.push(['EPUB', out.epub.path]);
if (out['pdf-paperback']) outRows.push(['PDF (paperback)', out['pdf-paperback'].path]);
if (!outRows.length) {
outRows.push(['EPUB', null]);
outRows.push(['PDF (paperback)', null]);
}
var outTable = el('table', { class: 'admin-table' }, [
el('thead', {}, [el('tr', {}, [
el('th', { scope: 'col', text: 'Format' }),
el('th', { scope: 'col', text: 'Download' })
])]),
el('tbody', {}, outRows.map(function (row) {
return el('tr', {}, [
el('td', { text: row[0] }),
el('td', {}, row[1] ? el('a', { class: 'btn btn--ghost', href: row[1], text: 'Download', download: '' })
: el('span', { class: 'status-pill status-pill--muted', text: 'Not produced' }))
]);
}))
]);
detail.appendChild(outTable);
main.appendChild(detail);
}
/* ------------------------------------------------------------ edit screen */
function renderEdit() {
var main = q('#main');
api.clear(main);
var program = state.programId ? (state.programById[String(state.programId)] || null) : null;
var langLabel = state.languageCode ? languageTagLabel(state.languageCode) : '';
var isSource = state.languageCode === 'en';
main.appendChild(el('div', { class: 'umbra-heading umbra-heading--row' }, [
el('div', {}, [
el('p', { class: 'eyebrow', text: 'Edit' }),
el('h1', { text: program ? program.name : 'Edit' }),
el('p', { class: 'lede', text: langLabel ? ('Front matter and text for ' + langLabel + '.') : 'Front matter and manuscript structure.' })
]),
el('div', { class: 'versions' }, [
el('span', { class: 'versions__label', text: 'Versions' }),
el('button', { class: 'version-pill', type: 'button', text: 'current' }),
el('button', { class: 'btn', type: 'button', text: 'Save version', disabled: true, title: 'Revision storage is not implemented yet.' })
])
]));
// Load project status to determine what this language project shows.
if (!state.languageCode) {
main.appendChild(el('div', { class: 'empty-state' }, [
el('span', { class: 'empty-state__icon', text: '◇' }),
el('p', { class: 'empty-state__title', text: 'No language selected' }),
el('p', { class: 'empty-state__body', text: 'Use the program selector above to choose a language project.' })
]));
return;
}
if (typeof api.projectStatus === 'function') {
api.projectStatus(state.programId).then(function (data) {
var langs = (data && data.languages) || [];
var match = null;
langs.forEach(function (l) { if (l.language_tag === state.languageCode) { match = l; } });
renderEditContent(main, program, state.languageCode, match);
}, function (err) {
renderEditContent(main, program, state.languageCode, null);
});
} else {
renderEditContent(main, program, state.languageCode, null);
}
}
function renderEditContent(main, program, code, status) {
var isSource = code === 'en';
var tr = status && status.translation || {};
var trState = tr.state || 'not_started';
// ---------- Translation not started: only queue translation ----------
if (trState !== 'complete') {
main.appendChild(el('div', { class: 'empty-state' }, [
el('span', { class: 'empty-state__icon', text: '◇' }),
el('h2', { text: languageTagLabel(code) + ' — not yet translated' }),
el('p', { class: 'lede', text: 'No translation exists for this language yet. Queue a translation job to begin.' })
]));
main.appendChild(el('div', { class: 'umbra-inline-actions' }, [
el('button', { class: 'btn', type: 'button', text: 'Queue translation',
on: { click: function () {
api.queueTranslation(program.id, code).then(
function () { api.showToast('Translation queued for ' + languageTagLabel(code), 'success'); applyRoute(); },
function (err) { api.showToast(api.describeError(err), 'error'); }
);
} }
}),
el('button', { class: 'btn btn--ghost', type: 'button', text: '← Back', on: { click: function () { writeHash('program', program.id || ''); } } })
]));
return;
}
// ---------- Translation complete: fetch chapters, build sidebar tree, render editor ----------
var sidebar = q('#sidebar');
var chapterTree = el('div', { class: 'binder__tree', 'aria-label': 'Binder' });
chapterTree.appendChild(el('div', { class: 'tree__group', text: isSource ? 'Source text' : 'Translation' }));
var editorSurface = el('div', {
class: 'binder__surface', contenteditable: 'true', spellcheck: 'true',
'aria-label': 'Editable text'
}, [el('p', { text: 'Select a chapter from the left menu to edit its text.' })]);
editorSurface.style.display = 'none';
var uttPanel = el('div', { class: 'utterance-review-panel' });
uttPanel.style.display = 'none';
uttPanel.appendChild(el('h3', { text: isSource ? 'English source — review before validation' : 'Translated text — review before audio' }));
var uttList = el('div', { class: 'utterance-list' });
uttPanel.appendChild(uttList);
// Front matter items panel (metadata like ISBN, cover, etc.).
var fmPanel = el('div', { class: 'front-matter-panel' });
fmPanel.style.display = 'none';
fmPanel.appendChild(el('h3', { text: 'Front matter metadata' }));
var currentChapterId = null;
var currentParagraphs = []; // {id, speaker, type, text}
function buildChapterTree(chapters) {
api.clear(chapterTree);
// Audio format label at the top
chapterTree.appendChild(el('div', { class: 'tree__group', text: 'Audio Format' }));
chapterTree.appendChild(el('div', { class: 'tree__group', text: isSource ? 'Source text' : 'Translation' }));
// Front matter section (matter starts with 'front')
var fmChapters = chapters.filter(function (c) { return c.matter && c.matter.startsWith('front'); });
if (fmChapters.length) {
var fmGroup = el('div', { class: 'tree__group', text: 'Front matter' });
fmChapters.forEach(function (ch) {
var item = el('div', { class: 'tree__item tree__item--child tree__item--chapter', 'data-chapter': ch.id, text: ch.title });
fmGroup.appendChild(item);
});
chapterTree.appendChild(fmGroup);
}
// Back matter section (matter starts with 'back')
var bmChapters = chapters.filter(function (c) { return c.matter && c.matter.startsWith('back'); });
if (bmChapters.length) {
var bmGroup = el('div', { class: 'tree__group', text: 'Back matter' });
bmChapters.forEach(function (ch) {
var item = el('div', { class: 'tree__item tree__item--child tree__item--chapter', 'data-chapter': ch.id, text: ch.title });
bmGroup.appendChild(item);
});
chapterTree.appendChild(bmGroup);
}
// Body section
var bodyChapters = chapters.filter(function (c) { return !c.matter || c.matter === 'body'; });
if (bodyChapters.length) {
var bodyGroup = el('div', { class: 'tree__group', text: 'Body' });
bodyChapters.forEach(function (ch) {
var item = el('div', { class: 'tree__item tree__item--child tree__item--chapter', 'data-chapter': ch.id, text: ch.title });
bodyGroup.appendChild(item);
});
chapterTree.appendChild(bodyGroup);
}
// Click handlers for chapter items
chapterTree.querySelectorAll('.tree__item--chapter').forEach(function (item) {
item.addEventListener('click', function () {
chapterTree.querySelectorAll('.tree__item--chapter').forEach(function (i) {
i.classList.remove('tree__item--active');
});
item.classList.add('tree__item--active');
currentChapterId = item.getAttribute('data-chapter');
loadChapter(currentChapterId);
});
});
// Place chapter tree in the LEFT SIDEBAR
api.clear(sidebar);
sidebar.appendChild(chapterTree);
}
function loadChapter(chapterId) {
editorSurface.style.display = 'none';
uttPanel.style.display = 'none';
fmPanel.style.display = 'none';
// Find chapter metadata to determine matter
var chapterInfo = allChapters.find(function (c) { return c.id === chapterId; });
var isFrontMatter = chapterInfo && chapterInfo.matter && chapterInfo.matter !== 'body';
if (isFrontMatter && typeof api.getChapterContent === 'function') {
// Front matter: show editable text with pandoc formatting
editorSurface.style.display = 'block';
api.clear(editorSurface);
editorSurface.appendChild(el('p', { class: 'lede', text: 'Loading chapter content…' }));
api.getChapterContent(program.id, code, chapterId).then(function (data) {
api.clear(editorSurface);
var paragraphs = (data && data.paragraphs) || [];
if (!paragraphs.length) {
editorSurface.appendChild(el('p', { class: 'lede', text: 'No content for ' + chapterId + '.' }));
return;
}
currentParagraphs = paragraphs.map(function (p) {
return { chapterId: chapterId, id: p.id, speaker: p.speaker || 'narrator', type: p.type || 'narration', text: p.text || '' };
});
currentParagraphs.forEach(function (p) {
var paraEl = el('p', { 'data-para': p.id });
paraEl.textContent = p.text;
paraEl.addEventListener('input', function () {
p.text = paraEl.textContent;
});
editorSurface.appendChild(paraEl);
});
editorSurface.style.display = 'block';
toolbar.style.display = 'block';
}, function (err) {
api.clear(editorSurface);
editorSurface.appendChild(el('p', { class: 'list-summary list-summary--error', text: 'Chapter content could not be loaded: ' + api.describeError(err) }));
editorSurface.style.display = 'block';
toolbar.style.display = 'block';
});
return;
}
// Body chapter: utterance-by-chapter review
var paraData = currentParagraphs.filter(function (p) { return p.chapterId === chapterId; });
if (!paraData.length && typeof api.getUtteranceReview === 'function') {
editorSurface.style.display = 'none';
api.clear(editorSurface);
uttPanel.style.display = 'block';
api.clear(uttList);
uttList.appendChild(el('p', { class: 'lede', text: 'Loading utterances…' }));
api.getUtteranceReview(program.id, code, chapterId).then(function (data) {
api.clear(uttList);
var utterances = (data && data.utterances) || [];
var speakers = (data && data.speakers) || [];
if (!utterances.length) {
uttList.appendChild(el('p', { class: 'lede', text: 'No utterances found for ' + chapterId + '.' }));
return;
}
uttList.appendChild(el('div', { class: 'utterance-header' }, [
el('div', { class: 'utterance__speaker', text: 'Speaker' }),
el('div', { class: 'utterance__variation', text: 'Variation' }),
el('div', { class: 'utterance__text', text: 'Text' }),
el('div', { class: 'utterance__actions', text: 'Actions' })
]));
utterances.forEach(function (utt) {
uttList.appendChild(el('div', { class: 'utterance-row' }, [
el('div', { class: 'utterance__speaker' }, [
el('select', { class: 'input', 'data-utt': utt.id }, speakers.map(function (spk) {
var sel = spk.id === (utt.speaker || 'narrator');
return el('option', { value: spk.id, text: spk.name, selected: sel });
}))
]),
el('div', { class: 'utterance__variation' }, [
el('select', { class: 'input', 'data-utt': utt.id }, [
el('option', { value: 'neutral', text: 'Neutral', selected: true }),
el('option', { value: 'angry', text: 'Angry' }),
el('option', { value: 'joyful', text: 'Joyful' }),
el('option', { value: 'terrified', text: 'Terrified' }),
el('option', { value: 'griefstricken', text: 'Griefstricken' }),
el('option', { value: 'shocked', text: 'Shocked' }),
el('option', { value: 'furious', text: 'Furious' }),
el('option', { value: 'tense', text: 'Tense' }),
el('option', { value: 'urgent', text: 'Urgent' }),
el('option', { value: 'horror', text: 'Horror' }),
el('option', { value: 'slow', text: 'Slow' }),
el('option', { value: 'moderate', text: 'Moderate' }),
el('option', { value: 'fast', text: 'Fast' })
])
]),
el('div', { class: 'utterance__text', text: utt.text }),
el('div', { class: 'utterance__actions' }, [
el('button', { class: 'btn btn--ghost', type: 'button', text: '▶', title: 'Play audio', disabled: true, 'data-utt': utt.id }),
el('button', { class: 'btn btn--ghost', type: 'button', text: '⟳', title: 'Regenerate audio', disabled: true, 'data-utt': utt.id }),
el('button', { class: 'btn btn--ghost', type: 'button', text: '✕', title: 'Delete', disabled: true, 'data-utt': utt.id })
])
]));
});
currentParagraphs = utterances.map(function (utt) {
return { chapterId: chapterId, id: utt.id, speaker: utt.speaker || 'narrator', type: 'dialogue', text: utt.text };
});
}, function (err) {
api.clear(uttList);
uttList.appendChild(el('p', { class: 'list-summary list-summary--error', text: 'Utterances could not be loaded: ' + api.describeError(err) }));
});
return;
}
if (paraData.length && paraData.some(function (p) { return p.text; })) {
editorSurface.style.display = 'block';
api.clear(editorSurface);
paraData.forEach(function (p) {
var paraEl = el('p', { 'data-para': p.id });
paraEl.textContent = p.text;
paraEl.addEventListener('input', function () {
p.text = paraEl.textContent;
});
editorSurface.appendChild(paraEl);
});
editorSurface.style.display = 'block';
toolbar.style.display = 'block';
} else {
uttPanel.style.display = 'block';
api.clear(uttList);
uttList.appendChild(el('p', { class: 'lede', text: 'No editable text for ' + chapterId + '.' }));
}
}
function applyFormat(openTag, closeTag) {
var sel = window.getSelection();
if (!sel.rangeCount) return;
var range = sel.getRangeAt(0);
var selectedText = range.toString();
if (!selectedText) return;
var newText = openTag + selectedText + closeTag;
range.deleteContents();
range.insertNode(document.createTextNode(newText));
var selEl = sel.anchorNode.parentElement;
var paraEl = selEl.closest('p[data-para]');
if (paraEl) {
var paraId = paraEl.getAttribute('data-para');
var p = currentParagraphs.find(function (x) { return x.id === paraId; });
if (p) p.text = paraEl.textContent;
}
}
// Fetch chapters and build sidebar tree
var allChapters = [];
if (typeof api.getChapters === 'function') {
api.getChapters(program.id, code).then(function (chapters) {
allChapters = chapters;
buildChapterTree(chapters);
if (chapters.length) {
var firstChapter = chapters[0];
currentChapterId = firstChapter.id;
var firstItem = chapterTree.querySelector('.tree__item--chapter[data-chapter="' + firstChapter.id + '"]');
if (firstItem) {
firstItem.classList.add('tree__item--active');
}
loadChapter(firstChapter.id);
}
}, function (err) {
api.clear(main);
main.appendChild(el('p', { class: 'list-summary list-summary--error', text: 'Chapters could not be loaded: ' + api.describeError(err) }));
});
}
main.appendChild(el('div', { class: 'edit-main-content' }, [editorSurface, uttPanel]));
// Format toolbar (shown when front matter text is editable).
var toolbar = el('div', { class: 'format-toolbar' });
toolbar.style.display = 'none';
toolbar.appendChild(el('button', { class: 'btn btn--ghost', type: 'button', text: 'Bold', title: 'Bold (**text**)',
on: { click: function () { applyFormat('**', '**'); } } }));
toolbar.appendChild(el('button', { class: 'btn btn--ghost', type: 'button', text: 'Italic', title: 'Italic (*text*)',
on: { click: function () { applyFormat('*', '*'); } } }));
toolbar.appendChild(el('button', { class: 'btn btn--ghost', type: 'button', text: 'Underline', title: 'Underline (<u>text</u>)',
on: { click: function () { applyFormat('<u>', '</u>'); } } }));
toolbar.appendChild(el('button', { class: 'btn btn--ghost', type: 'button', text: 'Heading', title: 'Heading (# text)',
on: { click: function () { applyFormat('# ', '\n'); } } }));
main.appendChild(toolbar);
// Show toolbar when editor is visible
var _origLoad = loadChapter;
loadChapter = function (chapterId) {
_origLoad(chapterId);
toolbar.style.display = (editorSurface.style.display === 'block') ? 'block' : 'none';
};
// Front matter items per language (metadata).
if (typeof api.getFrontMatter === 'function') {
api.getFrontMatter(program.id, code).then(function (data) {
var items = (data && data.items) || [];
fmPanel.style.display = 'block';
items.forEach(function (item) {
fmPanel.appendChild(el('div', { class: 'fm-item' }, [
el('label', { text: item.label || item.key }),
el('input', {
class: 'input', type: 'text', value: item.value || '',
'data-key': item.key,
'data-output': item.output_page || ''
}),
el('select', { class: 'input', 'data-key': item.key }, [
el('option', { value: '', text: 'None' }),
el('option', { value: 'epub', text: 'EPUB' }),
el('option', { value: 'pdf', text: 'PDF' }),
el('option', { value: 'audio', text: 'Audio' })
])
]));
});
fmPanel.appendChild(el('button', {
class: 'btn', type: 'button', text: 'Save front matter metadata',
on: { click: function () {
var out = [];
fmPanel.querySelectorAll('.fm-item input').forEach(function (inp) {
var key = inp.getAttribute('data-key');
var sel = fmPanel.querySelector('.fm-item select[data-key="' + key + '"]');
out.push({
key: key,
label: inp.previousElementSibling ? inp.previousElementSibling.textContent : key,
value: inp.value,
output_page: sel ? sel.value : ''
});
});
api.saveFrontMatter(program.id, code, out).then(
function () { api.showToast('Front matter metadata saved', 'success'); },
function (err) { api.showToast(api.describeError(err), 'error'); }
);
} }
}));
main.appendChild(fmPanel);
}, function () {
// Non-fatal: front matter may not exist yet.
});
}
main.appendChild(el('div', { class: 'umbra-inline-actions' }, [
el('button', { class: 'btn btn--ghost', type: 'button', text: '← Back', on: { click: function () { writeHash('program', program.id || ''); } } })
]));
}
/* ----------------------------------------------------------- audio screen */
function renderAudio() {
var main = q('#main');
api.clear(main);
var program = state.programId ? (state.programById[String(state.programId)] || null) : null;
var langLabel = state.languageCode ? languageTagLabel(state.languageCode) : '';
main.appendChild(el('div', { class: 'umbra-heading' }, [
el('div', {}, [
el('p', { class: 'eyebrow', text: 'Audio' }),
el('h1', { text: langLabel ? (langLabel + ' audio') : 'Audio production' }),
el('p', { class: 'lede', text: program ? (program.name + ' · ' + langLabel) : 'Select a language project.' })
])
]));
main.appendChild(el('div', { class: 'audio-layout' }, [
el('div', { class: 'audio-sidebar' }, [
el('p', { class: 'eyebrow', text: 'Audio sections' }),
el('ul', { class: 'audio-section-list' }, [
el('li', { text: 'Overview' }),
el('li', { text: 'Speakers (voices)' }),
el('li', { text: 'Proper names' }),
el('li', { text: 'Chapters' }),
el('li', { text: 'Assignments' }),
el('li', { text: 'Runs' }),
el('li', { text: 'Queue' })
])
]),
el('div', { class: 'audio-main' }, [
el('div', { class: 'empty-state' }, [
el('span', { class: 'empty-state__icon', text: '▶' }),
el('p', { class: 'empty-state__title', text: 'Audio workspace not yet wired' }),
el('p', { class: 'empty-state__body', text: 'Audio sections follow the UmbraAudio build system: speakers/voices, proper names, chapters, assignments, runs, queue. Real run data needs the audio backend endpoints.' })
])
])
]));
}
/* ---------------------------------------------------------- format screen */
function renderFormat() {
var main = q('#main');
api.clear(main);
var program = state.programId ? (state.programById[String(state.programId)] || null) : null;
var langLabel = state.languageCode ? languageTagLabel(state.languageCode) : '';
main.appendChild(el('div', { class: 'umbra-heading umbra-heading--row' }, [
el('div', {}, [
el('p', { class: 'eyebrow', text: 'Format' }),
el('h1', { text: langLabel ? (langLabel + ' metadata') : 'Format' }),
el('p', { class: 'lede', text: program ? (program.name + ' · ' + langLabel) : 'Select a language project.' })
]),
el('div', { class: 'format-tabs' }, [
el('button', { class: 'format-tab format-tab--active', type: 'button', text: 'EPUB' }),
el('button', { class: 'format-tab', type: 'button', text: 'PDF' })
])
]));
main.appendChild(el('div', { class: 'form-grid' }, [
el('div', { class: 'form-grid__full' }, [
el('label', { text: 'Cover' }),
el('input', { class: 'input', type: 'text', placeholder: 'Cover image' })
]),
el('div', { class: 'form-grid__full' }, [
el('label', { text: 'ISBN' }),
el('input', { class: 'input', type: 'text', placeholder: 'ISBN-13' })
]),
el('div', { class: 'form-grid__full' }, [
el('label', { text: 'Translated blurb' }),
el('textarea', { class: 'input', placeholder: 'Translated blurb' })
]),
el('div', { class: 'form-grid__full' }, [
el('label', { text: 'Translated Amazon search terms' }),
el('input', { class: 'input', type: 'text', placeholder: 'Comma-separated keywords' })
]),
el('div', { class: 'form-grid__full' }, [
el('label', { text: 'Print profile' }),
el('input', { class: 'input', type: 'text', placeholder: 'Paperback / Hardback' })
]),
el('div', { class: 'form-grid__full' }, [
el('label', { text: 'Page count / spine' }),
el('input', { class: 'input', type: 'text', placeholder: 'Measured on render' })
])
]));
// Load existing format metadata per language.
if (state.languageCode && typeof api.getFormatMetadata === 'function') {
api.getFormatMetadata(state.programId, state.languageCode).then(function (data) {
if (data && data.saved) {
var cover = main.querySelector('input[placeholder="Cover image"]');
var isbn = main.querySelector('input[placeholder="ISBN-13"]');
var blurb = main.querySelector('textarea[placeholder="Translated blurb"]');
var terms = main.querySelector('input[placeholder="Comma-separated keywords"]');
var profile = main.querySelector('input[placeholder="Paperback / Hardback"]');
var pages = main.querySelector('input[placeholder="Measured on render"]');
if (cover) cover.value = data.cover || '';
if (isbn) isbn.value = data.isbn || '';
if (blurb) blurb.value = data.blurb || '';
if (terms) terms.value = data.amazon_search_terms || '';
if (profile) profile.value = data.print_profile || '';
if (pages) pages.value = data.page_count || '';
}
}, function () {
// Non-fatal: format metadata may not exist yet.
});
}
main.appendChild(el('div', { class: 'umbra-inline-actions' }, [
el('button', { class: 'btn', type: 'button', text: 'Save', on: { click: function () {
var cover = main.querySelector('input[placeholder="Cover image"]').value;
var isbn = main.querySelector('input[placeholder="ISBN-13"]').value;
var blurb = main.querySelector('textarea[placeholder="Translated blurb"]').value;
var terms = main.querySelector('input[placeholder="Comma-separated keywords"]').value;
var profile = main.querySelector('input[placeholder="Paperback / Hardback"]').value;
var pages = main.querySelector('input[placeholder="Measured on render"]').value;
api.setFormatMetadata(state.programId, state.languageCode, {
cover: cover || null,
isbn: isbn || null,
blurb: blurb || null,
amazon_search_terms: terms || null,
print_profile: profile || null,
page_count: pages || null
}).then(
function () { api.showToast('Format metadata saved', 'success'); },
function (err) { api.showToast(api.describeError(err), 'error'); }
);
} } }),
el('button', { class: 'btn btn--ghost', type: 'button', text: 'Cancel', on: { click: function () { writeHash('program', state.programId || '', state.languageCode || ''); } } })
]));
}
/* ----------------------------------------------------------- review screen */
function renderReview() {
var main = q('#main');
api.clear(main);
var program = state.programId ? (state.programById[String(state.programId)] || null) : null;
var langLabel = state.languageCode ? languageTagLabel(state.languageCode) : '';
var chapterId = state.chapterId || 'ch-6';
var isSource = state.languageCode === 'en';
main.appendChild(el('div', { class: 'umbra-heading' }, [
el('div', {}, [
el('p', { class: 'eyebrow', text: 'Review utterances by chapter' }),
el('h1', { text: langLabel ? (langLabel + ' · ' + chapterId) : 'Utterance review' }),
el('p', { class: 'lede', text: program ? (program.name + ' · ' + langLabel + ' · ' + chapterId) : 'Select a language project.' })
])
]));
main.appendChild(el('div', { class: 'umbra-inline-actions' }, [
el('button', { class: 'btn btn--ghost', type: 'button', text: '← Back', on: { click: function () { writeHash('program', state.programId || '', state.languageCode || ''); } } })
]));
// Check translation state before showing utterances.
// Only languages with complete translation can be reviewed.
if (typeof api.projectStatus === 'function') {
api.projectStatus(program.id).then(function (data) {
var langs = (data && data.languages) || [];
var match = null;
langs.forEach(function (l) { if (l.language_tag === state.languageCode) { match = l; } });
var trState = match ? (match.translation && match.translation.state) : 'not_started';
if (trState !== 'complete') {
renderReviewNotTranslated(main, program, state.languageCode, trState);
return;
}
renderReviewUtterances(main, program, state.languageCode, chapterId, isSource);
}, function (err) {
// Fall through to showing utterances if status can't be loaded.
renderReviewUtterances(main, program, state.languageCode, chapterId, isSource);
});
} else {
renderReviewUtterances(main, program, state.languageCode, chapterId, isSource);
}
}
function renderReviewNotTranslated(main, program, code, trState) {
var isSource = code === 'en';
var label = languageTagLabel(code);
var stateText = trState === 'in_progress' ? 'in progress' : 'not yet started';
main.appendChild(el('div', { class: 'empty-state' }, [
el('span', { class: 'empty-state__icon', text: '◇' }),
el('h2', { text: label + ' — translation ' + stateText }),
el('p', { class: 'lede', text: 'No translation exists for this language yet. Queue a translation job to begin.' })
]));
main.appendChild(el('div', { class: 'umbra-inline-actions' }, [
el('button', { class: 'btn', type: 'button', text: 'Queue translation',
on: { click: function () {
api.queueTranslation(program.id, code).then(
function () { api.showToast('Translation queued for ' + label, 'success'); applyRoute(); },
function (err) { api.showToast(api.describeError(err), 'error'); }
);
} }
}),
el('button', { class: 'btn btn--ghost', type: 'button', text: '← Back', on: { click: function () { writeHash('program', program.id || ''); } } })
]));
}
function renderReviewUtterances(main, program, code, chapterId, isSource) {
var uttPanel = el('div', { class: 'utterance-review-panel' });
uttPanel.appendChild(el('h3', { text: isSource ? 'English source — review before validation' : 'Translated text — review before audio' }));
var uttList = el('div', { class: 'utterance-list' });
uttPanel.appendChild(uttList);
main.appendChild(uttPanel);
var allChapters = [];
var currentChapterId = chapterId;
function buildChapterTree(chapters) {
allChapters = chapters;
var sidebar = q('#sidebar');
api.clear(sidebar);
var chapterTree = el('div', { class: 'binder__tree', 'aria-label': 'Binder' });
chapterTree.appendChild(el('div', { class: 'tree__group', text: 'Audio Format' }));
chapterTree.appendChild(el('div', { class: 'tree__group', text: isSource ? 'Source text' : 'Translation' }));
var fmChapters = chapters.filter(function (c) { return c.matter && c.matter.startsWith('front'); });
if (fmChapters.length) {
var fmGroup = el('div', { class: 'tree__group', text: 'Front matter' });
fmChapters.forEach(function (ch) {
var active = ch.id === currentChapterId ? ' tree__item--active' : '';
var item = el('div', { class: 'tree__item tree__item--child tree__item--chapter' + active, 'data-chapter': ch.id, text: ch.title });
fmGroup.appendChild(item);
});
chapterTree.appendChild(fmGroup);
}
var bmChapters = chapters.filter(function (c) { return c.matter && c.matter.startsWith('back'); });
if (bmChapters.length) {
var bmGroup = el('div', { class: 'tree__group', text: 'Back matter' });
bmChapters.forEach(function (ch) {
var active = ch.id === currentChapterId ? ' tree__item--active' : '';
var item = el('div', { class: 'tree__item tree__item--child tree__item--chapter' + active, 'data-chapter': ch.id, text: ch.title });
bmGroup.appendChild(item);
});
chapterTree.appendChild(bmGroup);
}
var bodyChapters = chapters.filter(function (c) { return !c.matter || c.matter === 'body'; });
if (bodyChapters.length) {
var bodyGroup = el('div', { class: 'tree__group', text: 'Body' });
bodyChapters.forEach(function (ch) {
var active = ch.id === currentChapterId ? ' tree__item--active' : '';
var item = el('div', { class: 'tree__item tree__item--child tree__item--chapter' + active, 'data-chapter': ch.id, text: ch.title });
bodyGroup.appendChild(item);
});
chapterTree.appendChild(bodyGroup);
}
chapterTree.querySelectorAll('.tree__item--chapter').forEach(function (item) {
item.addEventListener('click', function () {
chapterTree.querySelectorAll('.tree__item--chapter').forEach(function (i) {
i.classList.remove('tree__item--active');
});
item.classList.add('tree__item--active');
currentChapterId = item.getAttribute('data-chapter');
writeHash('review', program.id || '', state.languageCode || '', currentChapterId);
loadChapter(currentChapterId);
});
});
sidebar.appendChild(chapterTree);
}
function loadChapter(chapId) {
api.clear(uttList);
uttList.appendChild(el('p', { class: 'lede', text: 'Loading utterances…' }));
if (typeof api.getUtteranceReview === 'function') {
api.getUtteranceReview(program.id, code, chapId).then(function (data) {
api.clear(uttList);
var utterances = (data && data.utterances) || [];
var speakers = (data && data.speakers) || [];
if (!utterances.length) {
uttList.appendChild(el('p', { class: 'lede', text: 'No utterances found for ' + chapId + '.' }));
return;
}
// UmbraAudio column layout: speaker, variation, text, play, regenerate, delete
uttList.appendChild(el('div', { class: 'utterance-header' }, [
el('div', { class: 'utterance__speaker', text: 'Speaker' }),
el('div', { class: 'utterance__variation', text: 'Variation' }),
el('div', { class: 'utterance__text', text: 'Text' }),
el('div', { class: 'utterance__actions', text: 'Actions' })
]));
utterances.forEach(function (utt) {
uttList.appendChild(el('div', { class: 'utterance-row' }, [
el('div', { class: 'utterance__speaker' }, [
el('select', { class: 'input', 'data-utt': utt.id }, speakers.map(function (spk) {
var sel = spk.id === (utt.speaker || 'narrator');
return el('option', { value: spk.id, text: spk.name, selected: sel });
}))
]),
el('div', { class: 'utterance__variation' }, [
el('select', { class: 'input', 'data-utt': utt.id }, [
el('option', { value: 'neutral', text: 'Neutral', selected: true }),
el('option', { value: 'angry', text: 'Angry' }),
el('option', { value: 'joyful', text: 'Joyful' }),
el('option', { value: 'terrified', text: 'Terrified' }),
el('option', { value: 'griefstricken', text: 'Griefstricken' }),
el('option', { value: 'shocked', text: 'Shocked' }),
el('option', { value: 'furious', text: 'Furious' }),
el('option', { value: 'tense', text: 'Tense' }),
el('option', { value: 'urgent', text: 'Urgent' }),
el('option', { value: 'horror', text: 'Horror' }),
el('option', { value: 'slow', text: 'Slow' }),
el('option', { value: 'moderate', text: 'Moderate' }),
el('option', { value: 'fast', text: 'Fast' })
])
]),
el('div', { class: 'utterance__text', text: utt.text }),
el('div', { class: 'utterance__actions' }, [
el('button', { class: 'btn btn--ghost', type: 'button', text: '▶', title: 'Play audio', disabled: true, 'data-utt': utt.id }),
el('button', { class: 'btn btn--ghost', type: 'button', text: '⟳', title: 'Regenerate audio', disabled: true, 'data-utt': utt.id }),
el('button', { class: 'btn btn--ghost', type: 'button', text: '✕', title: 'Delete', disabled: true, 'data-utt': utt.id })
])
]));
});
}, function (err) {
api.clear(uttList);
uttList.appendChild(el('p', { class: 'list-summary list-summary--error', text: 'Utterances could not be loaded: ' + api.describeError(err) }));
});
}
}
if (typeof api.getChapters === 'function') {
api.getChapters(program.id, code).then(function (chapters) {
buildChapterTree(chapters);
loadChapter(currentChapterId);
}, function (err) {
api.clear(main);
main.appendChild(el('p', { class: 'list-summary list-summary--error', text: 'Chapters could not be loaded: ' + api.describeError(err) }));
});
} else {
loadChapter(currentChapterId);
}
main.appendChild(el('div', { class: 'umbra-inline-actions' }, [
el('button', { class: 'btn', type: 'button', text: 'Save speaker assignments', disabled: true, title: 'Speaker assignment persistence needs the audio backend.' })
]));
}
/* ------------------------------------------------------------- rendering */
var RENDERERS = {
control: renderControlPanel,
program: function () {
if (state.programId) {
renderProgramDetail();
} else {
renderProgramList();
}
},
edit: renderEdit,
audio: renderAudio,
format: renderFormat,
review: renderReview
};
function applyRoute() {
var route = readHash();
var activity = route.activity;
if (!activity) {
activity = isAdmin(state.session && state.session.user) ? 'control' : 'program';
}
if (activity === 'control' && !isAdmin(state.session && state.session.user)) {
activity = 'program';
}
state.activity = activity;
state.section = route.section;
state.programId = route.programId;
state.languageCode = route.languageCode;
// Resolve program from workspace snapshot.
var programs = (state.workspace && state.workspace.projects) || [];
state.programs = programs;
state.programById = {};
programs.forEach(function (p) { state.programById[String(p.id)] = p; });
var select = q('#activity-select');
if (select && select.value !== activity) {
select.value = activity;
}
// Program selector visibility: only meaningful for program/edit/audio/format/review.
var programSelect = q('#program-select');
if (programSelect) {
if (activity === 'program' || activity === 'edit' || activity === 'audio' || activity === 'format' || activity === 'review') {
programSelect.classList.remove('umbra-topbar__mode--hidden');
// Populate options when a program is selected, regardless of activity.
var prog = state.programById[String(state.programId)] || null;
if (prog) {
api.clear(programSelect);
programSelect.appendChild(el('option', { value: '', text: prog.name + ' — select a language' }));
languageDropdownOptions(prog).forEach(function (opt) { programSelect.appendChild(opt); });
programSelect.value = state.languageCode || '';
}
} else {
programSelect.classList.add('umbra-topbar__mode--hidden');
}
}
// Topbar: show language tag + program name when a language project is selected.
var nameEl = q('#topbar-language-name');
if (state.languageCode && state.programId) {
var program = state.programById[String(state.programId)] || null;
api.setText(nameEl, state.languageCode.toUpperCase() + ' — ' + (program ? program.name : ''));
} else {
api.setText(nameEl, '');
}
state.chapterId = route.chapterId || null;
renderSidebar();
if (activity === 'program' && !route.programId) {
renderProgramList();
} else {
(RENDERERS[activity] || renderProgramList)();
}
}
/* -------------------------------------------------------------------- init */
function bindBanner() {
var select = q('#activity-select');
if (select) {
select.addEventListener('change', function () {
var value = this.value;
if (value === 'control') {
writeHash('control', state.section || 'dashboard');
} else if (value === 'program') {
writeHash('program', state.programId || '');
} else if (value === 'review') {
writeHash('review', state.programId || '', state.languageCode || '', state.chapterId || 'ch-6');
} else {
writeHash(value, state.programId || '', state.languageCode || '');
}
});
}
var programSelect = q('#program-select');
if (programSelect) {
programSelect.addEventListener('change', function () {
var code = this.value;
if (!code) {
writeHash('program', state.programId || '');
return;
}
if (state.activity === 'program') {
writeHash('program', state.programId || '', code);
} else if (state.activity === 'review') {
writeHash('review', state.programId || '', code, state.chapterId || 'ch-6');
} else {
writeHash(state.activity, state.programId || '', code);
}
});
}
var menu = q('#account-menu');
if (menu) {
menu.addEventListener('change', function () {
var value = this.value;
this.value = '';
if (value === 'settings') {
global.location.href = 'settings.html';
} else if (value === 'signout') {
api.logout().then(
function () { global.location.href = 'index.html'; },
function () { global.location.href = 'index.html'; }
);
}
});
}
global.addEventListener('hashchange', applyRoute);
}
function init() {
bindBanner();
api.me().then(function (session) {
state.session = session;
paintIdentity(session);
return api.workspace().then(
function (data) { state.workspace = data; },
function () { state.workspace = null; }
);
}).then(function () {
applyRoute();
}, function () {
// Session could not be loaded; api.js navigates away.
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})(window);