The pipeline was hard-coded to Simplified Chinese in three places: the drafter was told to translate into Chinese, the critic was told to compare English with Chinese, and the glossary's translated forms were read from a "zh" key. A production that declared target_language: es-ES was therefore translated into Chinese and filed under a Spanish name - measured on oldbox, deal-hunter-es held 3,038 paragraphs of Chinese and was reported complete. A new registry, tools/translation/languages.py, is the single source of truth for what a target language is: its names, its script, its punctuation, its glossary key, its typography and its chapter-numbering convention. Ten languages are registered. The prompt scaffolding stays in Chinese - that is what the models read best - and the target is named with its Chinese, English and native names so there is no room to misread it. The registry now reaches every stage: - prompts.py: the analyst, drafter, critic and glossary blocks take the target. The hard rules about this book - Deal Hunter is the ship's name, the AI's name and the book's title, so it stays in English - are the language's own table for Chinese and the global rules for everything else, so a language with no table does not lose them. The Chinese rules are byte-for-byte what they were. - pipeline.py: --target-language, a run-directory guard that refuses to reuse a directory belonging to another language (from the manifest, or by measuring the text it already holds), a draft-keyed-by-target reader, and a refusal of a draft that came back in the wrong script. - book.py: per-language chapter-title and subtitle tables. A language with no table falls back to the English title, never to another language's; a language with a table and a missing chapter gets a visible marker in its own language. - qa.py: the target's quote marks, punctuation, script count and forbidden title forms. The Latin-residue check is skipped for a Latin target, where it would flag the whole book. - align.py: the aligner and verifier prompts name the target, and the sentence and clause splitting uses the target's own terminators. - render.py: the fonts, the language and country codes, the writing direction, the chapter headings and the fixed editorial strings around the translation are the target's. The Latin-run span is skipped for a Latin target, where marking the text as English would tell the renderer it is English. - project_status.py: the dashboard measures a sample of a production's text and reports a production whose text is not in the language it declares as "mismatched", not "complete". A chapter count is a claim about the files; the script check is a measurement of the text. Also fixes: the dashboard gated every language's production on the source production existing, so a Spanish production could never be reported even when it was there. The Chinese render is byte-identical to before on the real book, and the Chinese rules are unchanged. Suite: 620 passed, the same 68 pre-existing environment failures, +49 new tests.
1729 lines
73 KiB
JavaScript
1729 lines
73 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('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' };
|
|
if (state === 'mismatched') return { text: 'Wrong language', tone: 'danger' };
|
|
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';
|
|
}
|
|
|
|
// A production whose text is not in the language it declares says why,
|
|
// rather than just looking unfinished.
|
|
var problem = tr.language_problem || '';
|
|
var badgeNode = el('span', { class: 'status-pill status-pill--' + badge.tone, text: badge.text });
|
|
if (problem) {
|
|
badgeNode.setAttribute('title', problem);
|
|
}
|
|
|
|
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' }, [ badgeNode ]),
|
|
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) : '';
|
|
var isSource = state.languageCode === 'en';
|
|
|
|
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.' })
|
|
])
|
|
]));
|
|
|
|
// Metadata form - all fields editable
|
|
var formPanel = el('div', { class: 'form-grid' }, [
|
|
el('div', { class: 'form-grid__full' }, [
|
|
el('label', { text: 'Cover image' }),
|
|
el('div', { class: 'cover-upload' }, [
|
|
el('input', { class: 'input', type: 'file', accept: 'image/*', placeholder: 'Cover image' }),
|
|
el('button', { class: 'btn btn--ghost', type: 'button', text: 'Upload', on: { click: function () {
|
|
var fileInput = main.querySelector('input[type="file"]');
|
|
if (fileInput && fileInput.files && fileInput.files[0]) {
|
|
var file = fileInput.files[0];
|
|
// Validate cover image before upload
|
|
var errors = [];
|
|
if (file.size > 2 * 1024 * 1024) {
|
|
errors.push('Cover image must be under 2MB');
|
|
}
|
|
if (errors.length > 0) {
|
|
api.showToast(errors.join('\n'), 'error');
|
|
return;
|
|
}
|
|
var formData = new FormData();
|
|
formData.append('file', file);
|
|
api.request('/api/workspace/' + encodeURIComponent(state.programId) + '/cover', {
|
|
method: 'POST',
|
|
body: formData
|
|
}).then(
|
|
function () { api.showToast('Cover image uploaded', 'success'); },
|
|
function (err) { api.showToast(api.describeError(err), 'error'); }
|
|
);
|
|
}
|
|
} })
|
|
])
|
|
]),
|
|
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 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 (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(formPanel);
|
|
|
|
// Publish section
|
|
var publishPanel = el('div', { class: 'publish-panel' });
|
|
publishPanel.appendChild(el('h3', { text: 'Publish' }));
|
|
publishPanel.appendChild(el('div', { class: 'publish-checks' }));
|
|
publishPanel.appendChild(el('div', { class: 'publish-actions' }, [
|
|
el('button', { class: 'btn btn--ghost', type: 'button', text: 'Run preflight check', on: { click: function () {
|
|
runPublishChecks(main, program, state.languageCode);
|
|
} } }),
|
|
el('button', { class: 'btn', type: 'button', text: 'Publish', disabled: true, 'data-publish-disabled': 'true', on: { click: function () {
|
|
api.showToast('Publish functionality requires finalized outputs.', 'info');
|
|
} } })
|
|
]));
|
|
main.appendChild(publishPanel);
|
|
|
|
// Output generation section
|
|
var outputPanel = el('div', { class: 'output-panel' });
|
|
outputPanel.appendChild(el('h3', { text: 'Generate outputs' }));
|
|
outputPanel.appendChild(el('div', { class: 'output-buttons' }, [
|
|
el('button', { class: 'btn', type: 'button', text: 'Generate EPUB', 'data-format': 'epub', on: { click: function () {
|
|
api.request('/api/projects/' + encodeURIComponent(state.programId) + '/output/epub', {
|
|
method: 'POST',
|
|
body: { language_tag: state.languageCode }
|
|
}).then(
|
|
function () { api.showToast('EPUB generation queued', 'success'); },
|
|
function (err) { api.showToast(api.describeError(err), 'error'); }
|
|
);
|
|
} } }),
|
|
el('button', { class: 'btn', type: 'button', text: 'Generate PDF', 'data-format': 'pdf', on: { click: function () {
|
|
api.request('/api/projects/' + encodeURIComponent(state.programId) + '/output/pdf', {
|
|
method: 'POST',
|
|
body: { language_tag: state.languageCode }
|
|
}).then(
|
|
function () { api.showToast('PDF generation queued', 'success'); },
|
|
function (err) { api.showToast(api.describeError(err), 'error'); }
|
|
);
|
|
} } }),
|
|
el('button', { class: 'btn', type: 'button', text: 'Generate Audio', 'data-format': 'audio', on: { click: function () {
|
|
api.request('/api/projects/' + encodeURIComponent(state.programId) + '/output/audio', {
|
|
method: 'POST',
|
|
body: { language_tag: state.languageCode }
|
|
}).then(
|
|
function () { api.showToast('Audio generation queued', 'success'); },
|
|
function (err) { api.showToast(api.describeError(err), 'error'); }
|
|
);
|
|
} } })
|
|
]));
|
|
outputPanel.appendChild(el('div', { class: 'output-downloads', style: 'margin-top: 1rem;' }));
|
|
|
|
main.appendChild(outputPanel);
|
|
|
|
main.appendChild(el('div', { class: 'umbra-inline-actions' }, [
|
|
el('button', { class: 'btn btn--ghost', type: 'button', text: 'Cancel', on: { click: function () { writeHash('program', state.programId || '', state.languageCode || ''); } } })
|
|
]));
|
|
}
|
|
|
|
}
|
|
function runPublishChecks(main, program, languageCode) {
|
|
var checksPanel = main.querySelector('.publish-checks');
|
|
api.clear(checksPanel);
|
|
var checks = [];
|
|
var allPassed = true;
|
|
|
|
// Check 1: Cover image
|
|
checks.push(function (cb) {
|
|
api.getFormatMetadata(state.programId, state.languageCode).then(function (data) {
|
|
if (data && data.saved && data.cover) {
|
|
checksPanel.appendChild(el('div', { class: 'check-pass' }, [
|
|
el('span', { class: 'check-icon', text: '✓' }),
|
|
el('span', { class: 'check-text', text: 'Cover image uploaded' })
|
|
]));
|
|
cb(true);
|
|
} else {
|
|
checksPanel.appendChild(el('div', { class: 'check-fail' }, [
|
|
el('span', { class: 'check-icon', text: '✗' }),
|
|
el('span', { class: 'check-text', text: 'Cover image not uploaded - upload a cover image first' })
|
|
]));
|
|
cb(false);
|
|
}
|
|
}, function () {
|
|
checksPanel.appendChild(el('div', { class: 'check-fail' }, [
|
|
el('span', { class: 'check-icon', text: '✗' }),
|
|
el('span', { class: 'check-text', text: 'Could not verify cover image' })
|
|
]));
|
|
cb(false);
|
|
});
|
|
});
|
|
|
|
// Check 2: ISBN present
|
|
checks.push(function (cb) {
|
|
var isbn = main.querySelector('input[placeholder="ISBN-13"]');
|
|
if (isbn && isbn.value.trim()) {
|
|
checksPanel.appendChild(el('div', { class: 'check-pass' }, [
|
|
el('span', { class: 'check-icon', text: '✓' }),
|
|
el('span', { class: 'check-text', text: 'ISBN entered: ' + isbn.value.trim() })
|
|
]));
|
|
cb(true);
|
|
} else {
|
|
checksPanel.appendChild(el('div', { class: 'check-fail' }, [
|
|
el('span', { class: 'check-icon', text: '✗' }),
|
|
el('span', { class: 'check-text', text: 'ISBN not provided - ISBN-13 is required for publication' })
|
|
]));
|
|
cb(false);
|
|
}
|
|
});
|
|
|
|
// Check 3: Blurb present
|
|
checks.push(function (cb) {
|
|
var blurb = main.querySelector('textarea[placeholder="Translated blurb"]');
|
|
if (blurb && blurb.value.trim()) {
|
|
checksPanel.appendChild(el('div', { class: 'check-pass' }, [
|
|
el('span', { class: 'check-icon', text: '✓' }),
|
|
el('span', { class: 'check-text', text: 'Translated blurb provided' })
|
|
]));
|
|
cb(true);
|
|
} else {
|
|
checksPanel.appendChild(el('div', { class: 'check-fail' }, [
|
|
el('span', { class: 'check-icon', text: '✗' }),
|
|
el('span', { class: 'check-text', text: 'Translated blurb not provided - required for EPUB metadata' })
|
|
]));
|
|
cb(false);
|
|
}
|
|
});
|
|
|
|
// Run all checks sequentially
|
|
var index = 0;
|
|
function next() {
|
|
if (index < checks.length) {
|
|
checks[index](function (passed) {
|
|
if (!passed) allPassed = false;
|
|
index++;
|
|
next();
|
|
});
|
|
} else {
|
|
// Enable/disable publish button
|
|
var publishBtn = main.querySelector('button[data-publish-disabled]');
|
|
if (publishBtn) {
|
|
if (allPassed) {
|
|
publishBtn.disabled = false;
|
|
publishBtn.removeAttribute('data-publish-disabled');
|
|
publishBtn.textContent = 'Publish';
|
|
publishBtn.title = 'Publish this language project';
|
|
} else {
|
|
publishBtn.disabled = true;
|
|
publishBtn.setAttribute('data-publish-disabled', 'true');
|
|
publishBtn.textContent = 'Fix issues first';
|
|
publishBtn.title = 'Fix the issues above before publishing';
|
|
}
|
|
}
|
|
api.showToast(allPassed ? 'All checks passed - ready to publish' : 'Fix the issues above before publishing', allPassed ? 'success' : 'error');
|
|
}
|
|
}
|
|
next();
|
|
}
|
|
|
|
/* ----------------------------------------------------------- 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);
|