added translation tab
This commit is contained in:
160
common.php
160
common.php
@ -202,6 +202,166 @@ function rbac_client_filter(array $tokenRecord, string $clientAlias = 'client'):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Translations: German (de) is the canonical source language ---
|
||||||
|
define('QDB_SOURCE_LANGUAGE', 'de');
|
||||||
|
|
||||||
|
function qdb_ensure_source_language(PDO $pdo): void {
|
||||||
|
$pdo->prepare("INSERT INTO language (languageCode, name) VALUES (:lc, :n)
|
||||||
|
ON CONFLICT(languageCode) DO NOTHING")
|
||||||
|
->execute([':lc' => QDB_SOURCE_LANGUAGE, ':n' => 'German']);
|
||||||
|
}
|
||||||
|
|
||||||
|
function qdb_upsert_source_translation(PDO $pdo, string $type, string $id, string $text): void {
|
||||||
|
qdb_ensure_source_language($pdo);
|
||||||
|
$lang = QDB_SOURCE_LANGUAGE;
|
||||||
|
if ($type === 'question') {
|
||||||
|
$pdo->prepare("INSERT INTO question_translation (questionID, languageCode, text)
|
||||||
|
VALUES (:id, :lang, :t)
|
||||||
|
ON CONFLICT(questionID, languageCode) DO UPDATE SET text = excluded.text")
|
||||||
|
->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
|
||||||
|
} elseif ($type === 'answer_option') {
|
||||||
|
$pdo->prepare("INSERT INTO answer_option_translation (answerOptionID, languageCode, text)
|
||||||
|
VALUES (:id, :lang, :t)
|
||||||
|
ON CONFLICT(answerOptionID, languageCode) DO UPDATE SET text = excluded.text")
|
||||||
|
->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
|
||||||
|
} elseif ($type === 'string') {
|
||||||
|
$pdo->prepare("INSERT INTO string_translation (stringKey, languageCode, text)
|
||||||
|
VALUES (:id, :lang, :t)
|
||||||
|
ON CONFLICT(stringKey, languageCode) DO UPDATE SET text = excluded.text")
|
||||||
|
->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function qdb_require_non_empty_german(string $text, string $label = 'German text'): void {
|
||||||
|
if ($text === '') {
|
||||||
|
json_error('MISSING_FIELDS', "$label is required", 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build translation entry lists for one questionnaire.
|
||||||
|
* Returns ['stringEntries' => [...], 'contentEntries' => [...]] (questions + options interleaved).
|
||||||
|
*/
|
||||||
|
function qdb_translation_entry_lists(PDO $pdo, string $qnID): array {
|
||||||
|
$qStmt = $pdo->prepare("
|
||||||
|
SELECT q.questionID, q.defaultText, q.configJson, q.orderIndex
|
||||||
|
FROM question q WHERE q.questionnaireID = :id ORDER BY q.orderIndex
|
||||||
|
");
|
||||||
|
$qStmt->execute([':id' => $qnID]);
|
||||||
|
$dbQuestions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$stringKeys = [];
|
||||||
|
$stringEntries = [];
|
||||||
|
$contentEntries = [];
|
||||||
|
|
||||||
|
foreach ($dbQuestions as $dbQ) {
|
||||||
|
$qOrder = (int)($dbQ['orderIndex'] ?? 0);
|
||||||
|
$contentEntries[] = [
|
||||||
|
'key' => $dbQ['defaultText'],
|
||||||
|
'type' => 'question',
|
||||||
|
'entityId' => $dbQ['questionID'],
|
||||||
|
'sortOrder' => $qOrder * 1000,
|
||||||
|
];
|
||||||
|
|
||||||
|
$aoStmt = $pdo->prepare("
|
||||||
|
SELECT answerOptionID, defaultText, orderIndex
|
||||||
|
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex
|
||||||
|
");
|
||||||
|
$aoStmt->execute([':qid' => $dbQ['questionID']]);
|
||||||
|
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
||||||
|
$contentEntries[] = [
|
||||||
|
'key' => $ao['defaultText'],
|
||||||
|
'type' => 'answer_option',
|
||||||
|
'entityId' => $ao['answerOptionID'],
|
||||||
|
'sortOrder' => $qOrder * 1000 + 10 + (int)($ao['orderIndex'] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = json_decode($dbQ['configJson'] ?: '{}', true) ?: [];
|
||||||
|
foreach (['textKey', 'textKey1', 'textKey2', 'hint', 'hint1', 'hint2'] as $field) {
|
||||||
|
if (!empty($config[$field])) {
|
||||||
|
$stringKeys[$config[$field]] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($config['symptoms']) && is_array($config['symptoms'])) {
|
||||||
|
foreach ($config['symptoms'] as $s) {
|
||||||
|
$stringKeys[$s] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$stringOrder = 0;
|
||||||
|
$skList = array_keys($stringKeys);
|
||||||
|
sort($skList, SORT_STRING);
|
||||||
|
foreach ($skList as $sk) {
|
||||||
|
$stringEntries[] = [
|
||||||
|
'key' => $sk,
|
||||||
|
'type' => 'string',
|
||||||
|
'entityId' => $sk,
|
||||||
|
'sortOrder' => $stringOrder++,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['stringEntries' => $stringEntries, 'contentEntries' => $contentEntries];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attach translation texts and germanText to entry rows. */
|
||||||
|
function qdb_attach_translation_texts(array &$entries, array $translations): void {
|
||||||
|
foreach ($entries as &$e) {
|
||||||
|
$tr = $translations[$e['entityId']] ?? [];
|
||||||
|
$e['translations'] = (object)$tr;
|
||||||
|
$de = trim($tr[QDB_SOURCE_LANGUAGE] ?? '');
|
||||||
|
$e['germanText'] = $de !== '' ? $de : $e['key'];
|
||||||
|
}
|
||||||
|
unset($e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load all translation rows for a flat entry list. */
|
||||||
|
function qdb_load_translations_for_entries(PDO $pdo, array $entries): array {
|
||||||
|
$translations = [];
|
||||||
|
|
||||||
|
$qIds = array_values(array_filter(array_map(
|
||||||
|
fn($e) => $e['type'] === 'question' ? $e['entityId'] : null,
|
||||||
|
$entries
|
||||||
|
)));
|
||||||
|
if ($qIds) {
|
||||||
|
$ph = implode(',', array_fill(0, count($qIds), '?'));
|
||||||
|
$stmt = $pdo->prepare("SELECT questionID AS entityId, languageCode, text FROM question_translation WHERE questionID IN ($ph)");
|
||||||
|
$stmt->execute($qIds);
|
||||||
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||||
|
$translations[$r['entityId']][$r['languageCode']] = $r['text'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$aoIds = array_values(array_filter(array_map(
|
||||||
|
fn($e) => $e['type'] === 'answer_option' ? $e['entityId'] : null,
|
||||||
|
$entries
|
||||||
|
)));
|
||||||
|
if ($aoIds) {
|
||||||
|
$ph = implode(',', array_fill(0, count($aoIds), '?'));
|
||||||
|
$stmt = $pdo->prepare("SELECT answerOptionID AS entityId, languageCode, text FROM answer_option_translation WHERE answerOptionID IN ($ph)");
|
||||||
|
$stmt->execute($aoIds);
|
||||||
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||||
|
$translations[$r['entityId']][$r['languageCode']] = $r['text'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$sKeys = array_values(array_filter(array_map(
|
||||||
|
fn($e) => $e['type'] === 'string' ? $e['entityId'] : null,
|
||||||
|
$entries
|
||||||
|
)));
|
||||||
|
if ($sKeys) {
|
||||||
|
$ph = implode(',', array_fill(0, count($sKeys), '?'));
|
||||||
|
$stmt = $pdo->prepare("SELECT stringKey AS entityId, languageCode, text FROM string_translation WHERE stringKey IN ($ph)");
|
||||||
|
$stmt->execute($sKeys);
|
||||||
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||||
|
$translations[$r['entityId']][$r['languageCode']] = $r['text'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $translations;
|
||||||
|
}
|
||||||
|
|
||||||
// --- AES-256-CBC: IV(16) + CIPHERTEXT ---
|
// --- AES-256-CBC: IV(16) + CIPHERTEXT ---
|
||||||
function aes256_cbc_encrypt_bytes(string $plain, string $key): string {
|
function aes256_cbc_encrypt_bytes(string $plain, string $key): string {
|
||||||
$key = str_pad(substr($key, 0, 32), 32, "\0");
|
$key = str_pad(substr($key, 0, 32), 32, "\0");
|
||||||
|
|||||||
@ -35,6 +35,7 @@ case 'POST':
|
|||||||
$id = bin2hex(random_bytes(16));
|
$id = bin2hex(random_bytes(16));
|
||||||
$qID = $body['questionID'];
|
$qID = $body['questionID'];
|
||||||
$text = trim($body['defaultText']);
|
$text = trim($body['defaultText']);
|
||||||
|
qdb_require_non_empty_german($text);
|
||||||
$points = (int)($body['points'] ?? 0);
|
$points = (int)($body['points'] ?? 0);
|
||||||
$order = (int)($body['orderIndex'] ?? 0);
|
$order = (int)($body['orderIndex'] ?? 0);
|
||||||
$nextQ = trim($body['nextQuestionId'] ?? '');
|
$nextQ = trim($body['nextQuestionId'] ?? '');
|
||||||
@ -54,6 +55,7 @@ case 'POST':
|
|||||||
}
|
}
|
||||||
$pdo->prepare('INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId) VALUES (:id, :qid, :t, :p, :o, :nq)')
|
$pdo->prepare('INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId) VALUES (:id, :qid, :t, :p, :o, :nq)')
|
||||||
->execute([':id' => $id, ':qid' => $qID, ':t' => $text, ':p' => $points, ':o' => $order, ':nq' => $nextQ]);
|
->execute([':id' => $id, ':qid' => $qID, ':t' => $text, ':p' => $points, ':o' => $order, ':nq' => $nextQ]);
|
||||||
|
qdb_upsert_source_translation($pdo, 'answer_option', $id, $text);
|
||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
json_success(['answerOption' => [
|
json_success(['answerOption' => [
|
||||||
'answerOptionID' => $id,
|
'answerOptionID' => $id,
|
||||||
@ -89,12 +91,14 @@ case 'PUT':
|
|||||||
json_error('NOT_FOUND', 'Answer option not found', 404);
|
json_error('NOT_FOUND', 'Answer option not found', 404);
|
||||||
}
|
}
|
||||||
$text = trim($body['defaultText'] ?? $row['defaultText']);
|
$text = trim($body['defaultText'] ?? $row['defaultText']);
|
||||||
|
qdb_require_non_empty_german($text);
|
||||||
$points = (int)($body['points'] ?? $row['points']);
|
$points = (int)($body['points'] ?? $row['points']);
|
||||||
$order = (int)($body['orderIndex'] ?? $row['orderIndex']);
|
$order = (int)($body['orderIndex'] ?? $row['orderIndex']);
|
||||||
$nextQ = trim($body['nextQuestionId'] ?? $row['nextQuestionId']);
|
$nextQ = trim($body['nextQuestionId'] ?? $row['nextQuestionId']);
|
||||||
|
|
||||||
$pdo->prepare('UPDATE answer_option SET defaultText = :t, points = :p, orderIndex = :o, nextQuestionId = :nq WHERE answerOptionID = :id')
|
$pdo->prepare('UPDATE answer_option SET defaultText = :t, points = :p, orderIndex = :o, nextQuestionId = :nq WHERE answerOptionID = :id')
|
||||||
->execute([':t' => $text, ':p' => $points, ':o' => $order, ':nq' => $nextQ, ':id' => $id]);
|
->execute([':t' => $text, ':p' => $points, ':o' => $order, ':nq' => $nextQ, ':id' => $id]);
|
||||||
|
qdb_upsert_source_translation($pdo, 'answer_option', $id, $text);
|
||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
json_success(['answerOption' => [
|
json_success(['answerOption' => [
|
||||||
'answerOptionID' => $id,
|
'answerOptionID' => $id,
|
||||||
|
|||||||
@ -48,6 +48,7 @@ case 'POST':
|
|||||||
$id = bin2hex(random_bytes(16));
|
$id = bin2hex(random_bytes(16));
|
||||||
$qnID = $body['questionnaireID'];
|
$qnID = $body['questionnaireID'];
|
||||||
$text = trim($body['defaultText']);
|
$text = trim($body['defaultText']);
|
||||||
|
qdb_require_non_empty_german($text);
|
||||||
$type = trim($body['type'] ?? '');
|
$type = trim($body['type'] ?? '');
|
||||||
$order = (int)($body['orderIndex'] ?? 0);
|
$order = (int)($body['orderIndex'] ?? 0);
|
||||||
$req = (int)($body['isRequired'] ?? 0);
|
$req = (int)($body['isRequired'] ?? 0);
|
||||||
@ -69,6 +70,7 @@ case 'POST':
|
|||||||
VALUES (:id, :qid, :t, :ty, :o, :r, :cj)")
|
VALUES (:id, :qid, :t, :ty, :o, :r, :cj)")
|
||||||
->execute([':id' => $id, ':qid' => $qnID, ':t' => $text, ':ty' => $type,
|
->execute([':id' => $id, ':qid' => $qnID, ':t' => $text, ':ty' => $type,
|
||||||
':o' => $order, ':r' => $req, ':cj' => $config]);
|
':o' => $order, ':r' => $req, ':cj' => $config]);
|
||||||
|
qdb_upsert_source_translation($pdo, 'question', $id, $text);
|
||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
json_success(['question' => [
|
json_success(['question' => [
|
||||||
'questionID' => $id, 'questionnaireID' => $qnID, 'defaultText' => $text,
|
'questionID' => $id, 'questionnaireID' => $qnID, 'defaultText' => $text,
|
||||||
@ -99,12 +101,14 @@ case 'PUT':
|
|||||||
json_error('NOT_FOUND', 'Question not found', 404);
|
json_error('NOT_FOUND', 'Question not found', 404);
|
||||||
}
|
}
|
||||||
$text = trim($body['defaultText'] ?? $row['defaultText']);
|
$text = trim($body['defaultText'] ?? $row['defaultText']);
|
||||||
|
qdb_require_non_empty_german($text);
|
||||||
$type = trim($body['type'] ?? $row['type']);
|
$type = trim($body['type'] ?? $row['type']);
|
||||||
$order = (int)($body['orderIndex'] ?? $row['orderIndex']);
|
$order = (int)($body['orderIndex'] ?? $row['orderIndex']);
|
||||||
$req = (int)($body['isRequired'] ?? $row['isRequired']);
|
$req = (int)($body['isRequired'] ?? $row['isRequired']);
|
||||||
$config = $body['configJson'] ?? $row['configJson'];
|
$config = $body['configJson'] ?? $row['configJson'];
|
||||||
$pdo->prepare("UPDATE question SET defaultText = :t, type = :ty, orderIndex = :o, isRequired = :r, configJson = :cj WHERE questionID = :id")
|
$pdo->prepare("UPDATE question SET defaultText = :t, type = :ty, orderIndex = :o, isRequired = :r, configJson = :cj WHERE questionID = :id")
|
||||||
->execute([':t' => $text, ':ty' => $type, ':o' => $order, ':r' => $req, ':cj' => $config, ':id' => $id]);
|
->execute([':t' => $text, ':ty' => $type, ':o' => $order, ':r' => $req, ':cj' => $config, ':id' => $id]);
|
||||||
|
qdb_upsert_source_translation($pdo, 'question', $id, $text);
|
||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
json_success(['question' => [
|
json_success(['question' => [
|
||||||
'questionID' => $id, 'questionnaireID' => $row['questionnaireID'],
|
'questionID' => $id, 'questionnaireID' => $row['questionnaireID'],
|
||||||
|
|||||||
@ -14,83 +14,74 @@ case 'GET':
|
|||||||
json_success(["languages" => $rows]);
|
json_success(["languages" => $rows]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!empty($_GET['all'])) {
|
||||||
|
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||||
|
|
||||||
|
$languages = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
if (!array_filter($languages, fn($l) => $l['languageCode'] === QDB_SOURCE_LANGUAGE)) {
|
||||||
|
array_unshift($languages, ['languageCode' => QDB_SOURCE_LANGUAGE, 'name' => 'German']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$qnRows = $pdo->query("
|
||||||
|
SELECT questionnaireID, name, orderIndex FROM questionnaire ORDER BY orderIndex
|
||||||
|
")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$questionnaires = [];
|
||||||
|
$allEntries = [];
|
||||||
|
|
||||||
|
foreach ($qnRows as $qn) {
|
||||||
|
$lists = qdb_translation_entry_lists($pdo, $qn['questionnaireID']);
|
||||||
|
$entries = array_merge($lists['stringEntries'], $lists['contentEntries']);
|
||||||
|
if (!$entries) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$translations = qdb_load_translations_for_entries($pdo, $entries);
|
||||||
|
qdb_attach_translation_texts($entries, $translations);
|
||||||
|
$questionnaires[] = [
|
||||||
|
'questionnaireID' => $qn['questionnaireID'],
|
||||||
|
'name' => $qn['name'],
|
||||||
|
'orderIndex' => (int)$qn['orderIndex'],
|
||||||
|
'entries' => $entries,
|
||||||
|
];
|
||||||
|
foreach ($entries as $e) {
|
||||||
|
$allEntries[] = $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
json_success([
|
||||||
|
'languages' => $languages,
|
||||||
|
'questionnaires' => $questionnaires,
|
||||||
|
'entries' => $allEntries,
|
||||||
|
'sourceLanguage' => QDB_SOURCE_LANGUAGE,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$qnID = $_GET['questionnaireID'] ?? '';
|
$qnID = $_GET['questionnaireID'] ?? '';
|
||||||
if ($qnID) {
|
if ($qnID) {
|
||||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||||
|
|
||||||
$languages = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC);
|
$languages = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
if (!array_filter($languages, fn($l) => $l['languageCode'] === QDB_SOURCE_LANGUAGE)) {
|
||||||
$qStmt = $pdo->prepare("
|
array_unshift($languages, ['languageCode' => QDB_SOURCE_LANGUAGE, 'name' => 'German']);
|
||||||
SELECT q.questionID, q.defaultText, q.configJson
|
|
||||||
FROM question q WHERE q.questionnaireID = :id ORDER BY q.orderIndex
|
|
||||||
");
|
|
||||||
$qStmt->execute([':id' => $qnID]);
|
|
||||||
$dbQuestions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
|
|
||||||
|
|
||||||
$entries = [];
|
|
||||||
$stringKeys = [];
|
|
||||||
|
|
||||||
foreach ($dbQuestions as $dbQ) {
|
|
||||||
$entries[] = ['key' => $dbQ['defaultText'], 'type' => 'question', 'entityId' => $dbQ['questionID']];
|
|
||||||
|
|
||||||
$aoStmt = $pdo->prepare("SELECT answerOptionID, defaultText FROM answer_option WHERE questionID = :qid ORDER BY orderIndex");
|
|
||||||
$aoStmt->execute([':qid' => $dbQ['questionID']]);
|
|
||||||
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
|
||||||
$entries[] = ['key' => $ao['defaultText'], 'type' => 'answer_option', 'entityId' => $ao['answerOptionID']];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$config = json_decode($dbQ['configJson'] ?: '{}', true) ?: [];
|
$qnRow = $pdo->prepare("SELECT name FROM questionnaire WHERE questionnaireID = :id");
|
||||||
foreach (['textKey','textKey1','textKey2','hint','hint1','hint2'] as $field) {
|
$qnRow->execute([':id' => $qnID]);
|
||||||
if (!empty($config[$field])) $stringKeys[$config[$field]] = true;
|
$qnName = $qnRow->fetchColumn() ?: '';
|
||||||
}
|
|
||||||
if (!empty($config['symptoms']) && is_array($config['symptoms'])) {
|
|
||||||
foreach ($config['symptoms'] as $s) $stringKeys[$s] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (array_keys($stringKeys) as $sk) {
|
$lists = qdb_translation_entry_lists($pdo, $qnID);
|
||||||
$entries[] = ['key' => $sk, 'type' => 'string', 'entityId' => $sk];
|
$entries = array_merge($lists['stringEntries'], $lists['contentEntries']);
|
||||||
}
|
$translations = qdb_load_translations_for_entries($pdo, $entries);
|
||||||
|
qdb_attach_translation_texts($entries, $translations);
|
||||||
$translations = [];
|
|
||||||
|
|
||||||
$qIds = array_filter(array_map(fn($e) => $e['type'] === 'question' ? $e['entityId'] : null, $entries));
|
|
||||||
if ($qIds) {
|
|
||||||
$ph = implode(',', array_fill(0, count($qIds), '?'));
|
|
||||||
$stmt = $pdo->prepare("SELECT questionID AS entityId, languageCode, text FROM question_translation WHERE questionID IN ($ph)");
|
|
||||||
$stmt->execute(array_values($qIds));
|
|
||||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
|
||||||
$translations[$r['entityId']][$r['languageCode']] = $r['text'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$aoIds = array_filter(array_map(fn($e) => $e['type'] === 'answer_option' ? $e['entityId'] : null, $entries));
|
|
||||||
if ($aoIds) {
|
|
||||||
$ph = implode(',', array_fill(0, count($aoIds), '?'));
|
|
||||||
$stmt = $pdo->prepare("SELECT answerOptionID AS entityId, languageCode, text FROM answer_option_translation WHERE answerOptionID IN ($ph)");
|
|
||||||
$stmt->execute(array_values($aoIds));
|
|
||||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
|
||||||
$translations[$r['entityId']][$r['languageCode']] = $r['text'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$sKeys = array_filter(array_map(fn($e) => $e['type'] === 'string' ? $e['entityId'] : null, $entries));
|
|
||||||
if ($sKeys) {
|
|
||||||
$ph = implode(',', array_fill(0, count($sKeys), '?'));
|
|
||||||
$stmt = $pdo->prepare("SELECT stringKey AS entityId, languageCode, text FROM string_translation WHERE stringKey IN ($ph)");
|
|
||||||
$stmt->execute(array_values($sKeys));
|
|
||||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
|
||||||
$translations[$r['entityId']][$r['languageCode']] = $r['text'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($entries as &$e) {
|
|
||||||
$e['translations'] = $translations[$e['entityId']] ?? (object)[];
|
|
||||||
}
|
|
||||||
unset($e);
|
|
||||||
|
|
||||||
qdb_discard($tmpDb, $lockFp);
|
qdb_discard($tmpDb, $lockFp);
|
||||||
json_success(["languages" => $languages, "entries" => $entries]);
|
json_success([
|
||||||
|
'languages' => $languages,
|
||||||
|
'entries' => $entries,
|
||||||
|
'questionnaire' => ['questionnaireID' => $qnID, 'name' => $qnName],
|
||||||
|
'sourceLanguage' => QDB_SOURCE_LANGUAGE,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$type = $_GET['type'] ?? '';
|
$type = $_GET['type'] ?? '';
|
||||||
@ -132,9 +123,13 @@ case 'PUT':
|
|||||||
}
|
}
|
||||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||||
try {
|
try {
|
||||||
|
if ($lang === QDB_SOURCE_LANGUAGE) {
|
||||||
|
qdb_ensure_source_language($pdo);
|
||||||
|
} else {
|
||||||
$pdo->prepare("INSERT INTO language (languageCode, name) VALUES (:lc, :n)
|
$pdo->prepare("INSERT INTO language (languageCode, name) VALUES (:lc, :n)
|
||||||
ON CONFLICT(languageCode) DO UPDATE SET name = excluded.name")
|
ON CONFLICT(languageCode) DO UPDATE SET name = excluded.name")
|
||||||
->execute([':lc' => $lang, ':n' => $name]);
|
->execute([':lc' => $lang, ':n' => $name]);
|
||||||
|
}
|
||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
json_success([]);
|
json_success([]);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
@ -189,6 +184,9 @@ case 'DELETE':
|
|||||||
if (!$lang) {
|
if (!$lang) {
|
||||||
json_error('MISSING_FIELDS', 'languageCode required', 400);
|
json_error('MISSING_FIELDS', 'languageCode required', 400);
|
||||||
}
|
}
|
||||||
|
if ($lang === QDB_SOURCE_LANGUAGE) {
|
||||||
|
json_error('BAD_REQUEST', 'German (de) cannot be removed', 400);
|
||||||
|
}
|
||||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||||
try {
|
try {
|
||||||
$pdo->prepare("DELETE FROM language WHERE languageCode = :lc")->execute([':lc' => $lang]);
|
$pdo->prepare("DELETE FROM language WHERE languageCode = :lc")->execute([':lc' => $lang]);
|
||||||
|
|||||||
@ -15,6 +15,12 @@
|
|||||||
--shadow-lg: 0 4px 12px rgba(0,0,0,.1);
|
--shadow-lg: 0 4px 12px rgba(0,0,0,.1);
|
||||||
--sidebar-width: 240px;
|
--sidebar-width: 240px;
|
||||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
/* Incomplete translation highlight (neutral, not yellow) */
|
||||||
|
--trans-missing-bg: #f8fafc;
|
||||||
|
--trans-missing-bg-hover: #f1f5f9;
|
||||||
|
--trans-missing-border: #cbd5e1;
|
||||||
|
--trans-missing-accent: #64748b;
|
||||||
|
--trans-missing-placeholder: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
@ -740,6 +746,204 @@ th.sort-desc::after { content: ' \2193'; opacity: 1; }
|
|||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Translations page ── */
|
||||||
|
.trans-page { padding: 20px 24px; }
|
||||||
|
.trans-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.trans-control {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 360px;
|
||||||
|
font-size: .85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.trans-control span { font-weight: 500; }
|
||||||
|
.trans-select {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: .9rem;
|
||||||
|
font-family: var(--font);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.trans-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(37,99,235,.12);
|
||||||
|
}
|
||||||
|
.trans-type-select { width: auto; min-width: 140px; flex-shrink: 0; }
|
||||||
|
.trans-lang-panel {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.trans-lang-panel summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: .9rem;
|
||||||
|
color: var(--text);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.trans-lang-panel[open] summary { margin-bottom: 12px; }
|
||||||
|
.trans-lang-panel .lang-chips { margin-bottom: 10px; }
|
||||||
|
.trans-hint { padding: 24px 0; text-align: center; font-size: .9rem; }
|
||||||
|
.text-muted { color: var(--text-secondary); }
|
||||||
|
.required-mark { color: var(--danger); font-weight: 600; }
|
||||||
|
.trans-list-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(100px, 0.75fr) 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: .75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: #f1f5f9;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: var(--radius) var(--radius) 0 0;
|
||||||
|
}
|
||||||
|
.trans-qn-block {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.trans-qn-block:last-child { border-bottom: none; margin-bottom: 0; }
|
||||||
|
.trans-qn-hidden { display: none !important; }
|
||||||
|
.trans-qn-title {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0 0 14px;
|
||||||
|
}
|
||||||
|
.trans-group {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.trans-group-hidden { display: none !important; }
|
||||||
|
.trans-group-title {
|
||||||
|
font-size: .8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0 0 8px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
.trans-groups-wrap .trans-list-header { margin-top: 0; }
|
||||||
|
.trans-list-in-group {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#transGroupedRoot {
|
||||||
|
max-height: calc(100vh - 360px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.trans-list {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0 0 var(--radius) var(--radius);
|
||||||
|
max-height: calc(100vh - 360px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.trans-list-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(100px, 0.75fr) 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
.trans-list-item:last-child { border-bottom: none; }
|
||||||
|
.trans-list-item:hover { background: #f8fafc; }
|
||||||
|
.trans-list-item-missing {
|
||||||
|
background: var(--trans-missing-bg);
|
||||||
|
box-shadow: inset 3px 0 0 var(--trans-missing-accent);
|
||||||
|
}
|
||||||
|
.trans-list-item-missing:hover { background: var(--trans-missing-bg-hover); }
|
||||||
|
.trans-list-key {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.trans-key-text {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: .8rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.trans-list-source {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.trans-source-text {
|
||||||
|
display: block;
|
||||||
|
font-size: .9rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.lang-chip-fixed {
|
||||||
|
background: #dbeafe;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
}
|
||||||
|
.trans-list-item .trans-cell-input {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.trans-input-missing {
|
||||||
|
border-color: var(--trans-missing-border) !important;
|
||||||
|
background: var(--surface) !important;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(100, 116, 139, 0.12);
|
||||||
|
}
|
||||||
|
.trans-input-missing::placeholder {
|
||||||
|
color: var(--trans-missing-placeholder);
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.trans-list-header,
|
||||||
|
.trans-list-item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.trans-list-header { display: none; }
|
||||||
|
.trans-list-item .trans-list-key::before {
|
||||||
|
content: 'Key';
|
||||||
|
display: block;
|
||||||
|
font-size: .7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.trans-list-item .trans-list-source::before {
|
||||||
|
content: 'German';
|
||||||
|
display: block;
|
||||||
|
font-size: .7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.trans-list-item .trans-cell-input[data-field="translation"] {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Language manager ── */
|
/* ── Language manager ── */
|
||||||
.lang-manager {
|
.lang-manager {
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
@ -849,7 +1053,11 @@ th.sort-desc::after { content: ' \2193'; opacity: 1; }
|
|||||||
.trans-table td { padding: 2px 4px; }
|
.trans-table td { padding: 2px 4px; }
|
||||||
.trans-table tbody tr { border-bottom: 1px solid #f1f5f9; }
|
.trans-table tbody tr { border-bottom: 1px solid #f1f5f9; }
|
||||||
.trans-table tbody tr:hover { background: #f8fafc; }
|
.trans-table tbody tr:hover { background: #f8fafc; }
|
||||||
.trans-row-hidden { display: none; }
|
.trans-row-hidden { display: none !important; }
|
||||||
|
#tabContent-translations .trans-list,
|
||||||
|
#tabContent-translations .trans-list-header {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.col-key { min-width: 180px; }
|
.col-key { min-width: 180px; }
|
||||||
.col-type { width: 50px; text-align: center; }
|
.col-type { width: 50px; text-align: center; }
|
||||||
@ -876,11 +1084,11 @@ th.sort-desc::after { content: ' \2193'; opacity: 1; }
|
|||||||
letter-spacing: .3px;
|
letter-spacing: .3px;
|
||||||
}
|
}
|
||||||
.badge-q { background: #dbeafe; color: #1e40af; }
|
.badge-q { background: #dbeafe; color: #1e40af; }
|
||||||
.badge-opt { background: #fef3c7; color: #92400e; }
|
.badge-opt { background: #e2e8f0; color: #475569; }
|
||||||
.badge-str { background: #ede9fe; color: #5b21b6; }
|
.badge-str { background: #ede9fe; color: #5b21b6; }
|
||||||
|
|
||||||
.cell-trans { position: relative; }
|
.cell-trans { position: relative; }
|
||||||
.cell-missing { background: #fff7ed; }
|
.cell-missing { background: var(--trans-missing-bg); }
|
||||||
|
|
||||||
.trans-cell-input {
|
.trans-cell-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -903,16 +1111,22 @@ th.sort-desc::after { content: ' \2193'; opacity: 1; }
|
|||||||
box-shadow: 0 0 0 2px rgba(37,99,235,.1);
|
box-shadow: 0 0 0 2px rgba(37,99,235,.1);
|
||||||
}
|
}
|
||||||
.trans-cell-input::placeholder {
|
.trans-cell-input::placeholder {
|
||||||
color: #f59e0b;
|
color: var(--trans-missing-placeholder);
|
||||||
font-style: italic;
|
font-style: normal;
|
||||||
font-size: .8rem;
|
font-size: .8rem;
|
||||||
}
|
}
|
||||||
|
.trans-cell-input:not(.trans-input-missing)::placeholder {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
.trans-cell-input.save-flash {
|
.trans-cell-input.save-flash {
|
||||||
animation: saveFlash .4s ease;
|
animation: saveFlash .4s ease;
|
||||||
}
|
}
|
||||||
@keyframes saveFlash {
|
@keyframes saveFlash {
|
||||||
0% { background: #dcfce7; }
|
0% { background: #dbeafe; }
|
||||||
100% { background: transparent; }
|
100% { background: var(--surface); }
|
||||||
|
}
|
||||||
|
.trans-list-item .trans-cell-input.save-flash {
|
||||||
|
animation: saveFlash .4s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Stats bar ── */
|
/* ── Stats bar ── */
|
||||||
|
|||||||
@ -39,6 +39,12 @@
|
|||||||
Assign Clients
|
Assign Clients
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li data-nav-roles="admin supervisor">
|
||||||
|
<a href="#/translations">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg>
|
||||||
|
Translations
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#/export">
|
<a href="#/export">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { exportPage } from './pages/export.js';
|
|||||||
import { usersPage } from './pages/users.js';
|
import { usersPage } from './pages/users.js';
|
||||||
import { assignmentsPage } from './pages/assignments.js';
|
import { assignmentsPage } from './pages/assignments.js';
|
||||||
import { clientsPage } from './pages/clients.js';
|
import { clientsPage } from './pages/clients.js';
|
||||||
|
import { translationsPage } from './pages/translations.js';
|
||||||
|
|
||||||
// Auth state
|
// Auth state
|
||||||
export function isLoggedIn() {
|
export function isLoggedIn() {
|
||||||
@ -88,6 +89,7 @@ addRoute('/export', authGuard(exportPage));
|
|||||||
addRoute('/users', roleGuard(['admin', 'supervisor'], usersPage));
|
addRoute('/users', roleGuard(['admin', 'supervisor'], usersPage));
|
||||||
addRoute('/assignments', roleGuard(['admin', 'supervisor'], assignmentsPage));
|
addRoute('/assignments', roleGuard(['admin', 'supervisor'], assignmentsPage));
|
||||||
addRoute('/clients', roleGuard(['admin', 'supervisor'], clientsPage));
|
addRoute('/clients', roleGuard(['admin', 'supervisor'], clientsPage));
|
||||||
|
addRoute('/translations', roleGuard(['admin', 'supervisor'], translationsPage));
|
||||||
|
|
||||||
// Nav link handling & logout
|
// Nav link handling & logout
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|||||||
@ -1,6 +1,17 @@
|
|||||||
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from '../api.js';
|
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from '../api.js';
|
||||||
import { canEdit, showToast } from '../app.js';
|
import { canEdit, showToast } from '../app.js';
|
||||||
import { navigate } from '../router.js';
|
import { navigate } from '../router.js';
|
||||||
|
import {
|
||||||
|
SOURCE_LANG,
|
||||||
|
normalizeEntry,
|
||||||
|
translationFor,
|
||||||
|
targetLanguages,
|
||||||
|
buildTranslationGroups,
|
||||||
|
sourceLanguageLabel,
|
||||||
|
translationListHeaderHTML,
|
||||||
|
renderGroupedTranslationsHTML,
|
||||||
|
escHtml,
|
||||||
|
} from '../translations-helpers.js';
|
||||||
|
|
||||||
const LAYOUT_TYPES = [
|
const LAYOUT_TYPES = [
|
||||||
{ value: 'radio_question', label: 'Radio (Single Choice)' },
|
{ value: 'radio_question', label: 'Radio (Single Choice)' },
|
||||||
@ -427,8 +438,8 @@ function showAddQuestionForm() {
|
|||||||
<h4>New Question</h4>
|
<h4>New Question</h4>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group" style="flex:3">
|
<div class="form-group" style="flex:3">
|
||||||
<label>Question key</label>
|
<label>German text <span class="required-mark">*</span></label>
|
||||||
<input type="text" id="aq_text" placeholder="e.g. consent_instruction">
|
<input type="text" id="aq_text" placeholder="e.g. Haben Sie zugestimmt?" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="flex:1;min-width:200px">
|
<div class="form-group" style="flex:1;min-width:200px">
|
||||||
<label>Layout type</label>
|
<label>Layout type</label>
|
||||||
@ -449,7 +460,7 @@ function showAddQuestionForm() {
|
|||||||
<ul class="pending-options-list" id="aq_pending_options"></ul>
|
<ul class="pending-options-list" id="aq_pending_options"></ul>
|
||||||
<div style="display:flex;gap:8px;margin-top:6px;align-items:flex-end">
|
<div style="display:flex;gap:8px;margin-top:6px;align-items:flex-end">
|
||||||
<div class="form-group" style="flex:3;margin-bottom:0">
|
<div class="form-group" style="flex:3;margin-bottom:0">
|
||||||
<input type="text" id="aq_opt_text" placeholder="Option key...">
|
<input type="text" id="aq_opt_text" placeholder="German option text…" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="width:90px;margin-bottom:0">
|
<div class="form-group" style="width:90px;margin-bottom:0">
|
||||||
<input type="number" id="aq_opt_pts" value="0" min="0" placeholder="Pts">
|
<input type="number" id="aq_opt_pts" value="0" min="0" placeholder="Pts">
|
||||||
@ -505,7 +516,7 @@ function showAddQuestionForm() {
|
|||||||
const text = document.getElementById('aq_opt_text').value.trim();
|
const text = document.getElementById('aq_opt_text').value.trim();
|
||||||
const pts = parseInt(document.getElementById('aq_opt_pts').value || '0', 10);
|
const pts = parseInt(document.getElementById('aq_opt_pts').value || '0', 10);
|
||||||
const next = document.getElementById('aq_opt_next').value;
|
const next = document.getElementById('aq_opt_next').value;
|
||||||
if (!text) { showToast('Option key is required', 'error'); return; }
|
if (!text) { showToast('German text is required', 'error'); return; }
|
||||||
pendingOptions.push({ text, points: pts, nextQuestionId: next });
|
pendingOptions.push({ text, points: pts, nextQuestionId: next });
|
||||||
renderPendingOptions();
|
renderPendingOptions();
|
||||||
document.getElementById('aq_opt_text').value = '';
|
document.getElementById('aq_opt_text').value = '';
|
||||||
@ -529,7 +540,7 @@ function showAddQuestionForm() {
|
|||||||
const type = document.getElementById('aq_type').value;
|
const type = document.getElementById('aq_type').value;
|
||||||
const isRequired = document.getElementById('aq_required').checked ? 1 : 0;
|
const isRequired = document.getElementById('aq_required').checked ? 1 : 0;
|
||||||
const configJson = readConfigFromForm(type, 'aq_cfg');
|
const configJson = readConfigFromForm(type, 'aq_cfg');
|
||||||
if (!text) { showToast('Question key is required', 'error'); return; }
|
if (!text) { showToast('German text is required', 'error'); return; }
|
||||||
if (OPTION_TYPES.has(type) && pendingOptions.length === 0) {
|
if (OPTION_TYPES.has(type) && pendingOptions.length === 0) {
|
||||||
showToast('Add at least one answer option', 'error');
|
showToast('Add at least one answer option', 'error');
|
||||||
return;
|
return;
|
||||||
@ -644,8 +655,8 @@ function renderQuestionBody(q) {
|
|||||||
<div class="inline-form-card" style="margin-bottom:12px">
|
<div class="inline-form-card" style="margin-bottom:12px">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group" style="flex:3">
|
<div class="form-group" style="flex:3">
|
||||||
<label>Question key</label>
|
<label>German text <span class="required-mark">*</span></label>
|
||||||
<input type="text" id="eq_text_${q.questionID}" value="${esc(q.defaultText)}">
|
<input type="text" id="eq_text_${q.questionID}" value="${esc(q.defaultText)}" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="flex:1;min-width:200px">
|
<div class="form-group" style="flex:1;min-width:200px">
|
||||||
<label>Layout type</label>
|
<label>Layout type</label>
|
||||||
@ -666,7 +677,7 @@ function renderQuestionBody(q) {
|
|||||||
</div>
|
</div>
|
||||||
` : `
|
` : `
|
||||||
<div class="inline-form-card" style="margin-bottom:12px">
|
<div class="inline-form-card" style="margin-bottom:12px">
|
||||||
<p><strong>Key:</strong> ${esc(q.defaultText)}</p>
|
<p><strong>German:</strong> ${esc(q.defaultText)}</p>
|
||||||
<p><strong>Layout:</strong> ${esc(layoutLabel(layout))}</p>
|
<p><strong>Layout:</strong> ${esc(layoutLabel(layout))}</p>
|
||||||
${Object.keys(config).length ? `<p><strong>Config:</strong> <code>${esc(JSON.stringify(config))}</code></p>` : ''}
|
${Object.keys(config).length ? `<p><strong>Config:</strong> <code>${esc(JSON.stringify(config))}</code></p>` : ''}
|
||||||
</div>
|
</div>
|
||||||
@ -692,7 +703,7 @@ function renderQuestionBody(q) {
|
|||||||
const type = document.getElementById(`eq_type_${q.questionID}`).value;
|
const type = document.getElementById(`eq_type_${q.questionID}`).value;
|
||||||
const isReq = document.getElementById(`eq_req_${q.questionID}`).checked ? 1 : 0;
|
const isReq = document.getElementById(`eq_req_${q.questionID}`).checked ? 1 : 0;
|
||||||
const cj = readConfigFromForm(type, `eq_cfg_${q.questionID}`);
|
const cj = readConfigFromForm(type, `eq_cfg_${q.questionID}`);
|
||||||
if (!text) { showToast('Question key is required', 'error'); return; }
|
if (!text) { showToast('German text is required', 'error'); return; }
|
||||||
updateQuestion(q.questionID, { defaultText: text, type, isRequired: isReq, configJson: cj });
|
updateQuestion(q.questionID, { defaultText: text, type, isRequired: isReq, configJson: cj });
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -749,8 +760,8 @@ function showAddOptionForm(q) {
|
|||||||
<div class="inline-form-card" style="margin-top:8px">
|
<div class="inline-form-card" style="margin-top:8px">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group" style="flex:3">
|
<div class="form-group" style="flex:3">
|
||||||
<label>Option key</label>
|
<label>German text <span class="required-mark">*</span></label>
|
||||||
<input type="text" id="ao_text_${q.questionID}" placeholder="e.g. consent_signed">
|
<input type="text" id="ao_text_${q.questionID}" placeholder="e.g. Ja, ich stimme zu" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="flex:1;min-width:100px">
|
<div class="form-group" style="flex:1;min-width:100px">
|
||||||
<label>Points</label>
|
<label>Points</label>
|
||||||
@ -777,7 +788,7 @@ function showAddOptionForm(q) {
|
|||||||
const text = document.getElementById(`ao_text_${q.questionID}`).value.trim();
|
const text = document.getElementById(`ao_text_${q.questionID}`).value.trim();
|
||||||
const points = parseInt(document.getElementById(`ao_pts_${q.questionID}`).value || '0', 10);
|
const points = parseInt(document.getElementById(`ao_pts_${q.questionID}`).value || '0', 10);
|
||||||
const nextQ = document.getElementById(`ao_next_${q.questionID}`).value;
|
const nextQ = document.getElementById(`ao_next_${q.questionID}`).value;
|
||||||
if (!text) { showToast('Option key is required', 'error'); return; }
|
if (!text) { showToast('German text is required', 'error'); return; }
|
||||||
|
|
||||||
const btn = document.getElementById(`ao_submit_${q.questionID}`);
|
const btn = document.getElementById(`ao_submit_${q.questionID}`);
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
@ -824,7 +835,8 @@ function showEditOptionForm(q, opt) {
|
|||||||
<div class="inline-form-card" style="width:100%;padding:8px">
|
<div class="inline-form-card" style="width:100%;padding:8px">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group" style="flex:3;margin-bottom:8px">
|
<div class="form-group" style="flex:3;margin-bottom:8px">
|
||||||
<input type="text" id="eo_text_${opt.answerOptionID}" value="${esc(opt.defaultText)}">
|
<label style="font-size:.8rem">German text <span class="required-mark">*</span></label>
|
||||||
|
<input type="text" id="eo_text_${opt.answerOptionID}" value="${esc(opt.defaultText)}" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="flex:1;min-width:80px;margin-bottom:8px">
|
<div class="form-group" style="flex:1;min-width:80px;margin-bottom:8px">
|
||||||
<input type="number" id="eo_pts_${opt.answerOptionID}" value="${opt.points}" min="0">
|
<input type="number" id="eo_pts_${opt.answerOptionID}" value="${opt.points}" min="0">
|
||||||
@ -850,7 +862,7 @@ function showEditOptionForm(q, opt) {
|
|||||||
const text = document.getElementById(`eo_text_${opt.answerOptionID}`).value.trim();
|
const text = document.getElementById(`eo_text_${opt.answerOptionID}`).value.trim();
|
||||||
const points = parseInt(document.getElementById(`eo_pts_${opt.answerOptionID}`).value || '0', 10);
|
const points = parseInt(document.getElementById(`eo_pts_${opt.answerOptionID}`).value || '0', 10);
|
||||||
const nextQ = document.getElementById(`eo_next_${opt.answerOptionID}`).value;
|
const nextQ = document.getElementById(`eo_next_${opt.answerOptionID}`).value;
|
||||||
if (!text) { showToast('Option key is required', 'error'); return; }
|
if (!text) { showToast('German text is required', 'error'); return; }
|
||||||
await updateOption(q, opt.answerOptionID, { defaultText: text, points, nextQuestionId: nextQ });
|
await updateOption(q, opt.answerOptionID, { defaultText: text, points, nextQuestionId: nextQ });
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -923,6 +935,8 @@ async function deleteOption(q, answerOptionID) {
|
|||||||
// ── Translations tab ─────────────────────────────────────────────────────
|
// ── Translations tab ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
let transData = null;
|
let transData = null;
|
||||||
|
let editorTransLang = '';
|
||||||
|
let editorTransSaveTimers = {};
|
||||||
|
|
||||||
async function loadTranslationsTab() {
|
async function loadTranslationsTab() {
|
||||||
const container = document.getElementById('translationsContent');
|
const container = document.getElementById('translationsContent');
|
||||||
@ -930,8 +944,12 @@ async function loadTranslationsTab() {
|
|||||||
container.innerHTML = '<div class="spinner"></div>';
|
container.innerHTML = '<div class="spinner"></div>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
try {
|
||||||
|
await apiPut('translations.php', { type: 'language', languageCode: SOURCE_LANG, name: 'German' });
|
||||||
|
} catch (_) { /* already exists */ }
|
||||||
const data = await apiGet(`translations.php?questionnaireID=${encodeURIComponent(questionnaire.questionnaireID)}`);
|
const data = await apiGet(`translations.php?questionnaireID=${encodeURIComponent(questionnaire.questionnaireID)}`);
|
||||||
transData = data;
|
transData = data;
|
||||||
|
transData.entries = (transData.entries || []).map(normalizeEntry);
|
||||||
renderTranslationsTab();
|
renderTranslationsTab();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
container.innerHTML = `<p class="error-text">${esc(e.message)}</p>`;
|
container.innerHTML = `<p class="error-text">${esc(e.message)}</p>`;
|
||||||
@ -944,99 +962,193 @@ function renderTranslationsTab() {
|
|||||||
const editable = canEdit();
|
const editable = canEdit();
|
||||||
const languages = transData.languages || [];
|
const languages = transData.languages || [];
|
||||||
const entries = transData.entries || [];
|
const entries = transData.entries || [];
|
||||||
const langCodes = languages.map(l => l.languageCode);
|
const targets = targetLanguages(languages);
|
||||||
|
|
||||||
if (!entries.length) {
|
if (!entries.length) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
${editable ? languageManagerHTML(languages) : ''}
|
${editable ? languageManagerHTML(languages) : ''}
|
||||||
<p style="color:var(--text-secondary);margin-top:12px">No translatable keys found. Add questions first.</p>
|
<p style="color:var(--text-secondary);margin-top:12px">No translatable content yet. Add questions with German text first.</p>
|
||||||
`;
|
`;
|
||||||
if (editable) bindLanguageManager(languages);
|
if (editable) bindLanguageManager(languages);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterHtml = `
|
if (!editorTransLang || !targets.some(l => l.languageCode === editorTransLang)) {
|
||||||
<div class="trans-toolbar">
|
editorTransLang = targets[0]?.languageCode || '';
|
||||||
<div class="trans-search-wrap">
|
}
|
||||||
<input type="text" id="transFilter" placeholder="Filter keys..." class="trans-search-input">
|
|
||||||
</div>
|
|
||||||
<div class="trans-filter-types">
|
|
||||||
<label class="checkbox-label"><input type="checkbox" class="trans-type-filter" value="question" checked> Questions</label>
|
|
||||||
<label class="checkbox-label"><input type="checkbox" class="trans-type-filter" value="answer_option" checked> Options</label>
|
|
||||||
<label class="checkbox-label"><input type="checkbox" class="trans-type-filter" value="string" checked> Strings</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const typeBadge = (type) => {
|
const langSelectHtml = targets.length
|
||||||
const map = { question: 'Q', answer_option: 'Opt', string: 'Str' };
|
? `<label class="trans-control" style="max-width:280px;margin-bottom:14px">
|
||||||
const cls = { question: 'badge-q', answer_option: 'badge-opt', string: 'badge-str' };
|
<span>Translate into</span>
|
||||||
return `<span class="trans-type-badge ${cls[type] || ''}">${map[type] || type}</span>`;
|
<select id="editorTransLangSelect" class="trans-select">
|
||||||
};
|
${targets.map(l => {
|
||||||
|
const label = l.name ? `${l.name} (${l.languageCode})` : l.languageCode;
|
||||||
|
return `<option value="${escHtml(l.languageCode)}" ${l.languageCode === editorTransLang ? 'selected' : ''}>${escHtml(label)}</option>`;
|
||||||
|
}).join('')}
|
||||||
|
</select>
|
||||||
|
</label>`
|
||||||
|
: `<p class="text-muted" style="margin-bottom:14px">Add a language below (e.g. English) to translate into.</p>`;
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
${editable ? languageManagerHTML(languages) : ''}
|
${editable ? languageManagerHTML(languages) : ''}
|
||||||
${filterHtml}
|
${langSelectHtml}
|
||||||
<div class="table-wrapper trans-table-wrapper">
|
${targets.length && editorTransLang ? renderEditorTransList(entries, editable) : ''}
|
||||||
<table class="data-table trans-table" id="transTable">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="col-key">Key</th>
|
|
||||||
<th class="col-type">Type</th>
|
|
||||||
${langCodes.map(lc => `<th class="col-lang">${esc(lc.toUpperCase())}</th>`).join('')}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
${entries.map((entry, i) => `
|
|
||||||
<tr data-idx="${i}" data-type="${entry.type}" data-key="${esc(entry.key)}">
|
|
||||||
<td class="cell-key" title="${esc(entry.key)}">${esc(entry.key)}</td>
|
|
||||||
<td class="cell-type">${typeBadge(entry.type)}</td>
|
|
||||||
${langCodes.map(lc => {
|
|
||||||
const val = entry.translations[lc] || '';
|
|
||||||
const missing = !val;
|
|
||||||
return `<td class="cell-trans ${missing ? 'cell-missing' : ''}">
|
|
||||||
<input type="text"
|
|
||||||
class="trans-cell-input"
|
|
||||||
data-idx="${i}"
|
|
||||||
data-lang="${esc(lc)}"
|
|
||||||
value="${esc(val)}"
|
|
||||||
${editable ? '' : 'disabled'}
|
|
||||||
placeholder="${missing ? 'missing' : ''}">
|
|
||||||
</td>`;
|
|
||||||
}).join('')}
|
|
||||||
</tr>
|
|
||||||
`).join('')}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="trans-stats" id="transStats"></div>
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
updateTransStats(entries, langCodes);
|
if (editable) bindLanguageManager(languages);
|
||||||
|
|
||||||
if (editable) {
|
document.getElementById('editorTransLangSelect')?.addEventListener('change', (e) => {
|
||||||
bindLanguageManager(languages);
|
editorTransLang = e.target.value;
|
||||||
bindTransCellEditing(entries);
|
renderTranslationsTab();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (targets.length && editorTransLang) {
|
||||||
|
bindEditorTransEditing(entries, editable);
|
||||||
|
bindEditorTransFiltering(entries);
|
||||||
}
|
}
|
||||||
bindTransFiltering();
|
}
|
||||||
|
|
||||||
|
function renderEditorTransList(entries, editable) {
|
||||||
|
const targets = targetLanguages(transData.languages || []);
|
||||||
|
const langLabel = targets.find(l => l.languageCode === editorTransLang)?.name || editorTransLang.toUpperCase();
|
||||||
|
const sourceLabel = sourceLanguageLabel(transData.languages || []);
|
||||||
|
const groups = buildTranslationGroups(entries, editorTransLang, 0);
|
||||||
|
const missingCount = entries.filter(e => !translationFor(e, editorTransLang).trim()).length;
|
||||||
|
const pct = entries.length ? Math.round(((entries.length - missingCount) / entries.length) * 100) : 0;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="trans-toolbar">
|
||||||
|
<input type="search" id="transFilter" class="trans-search-input" placeholder="Search…">
|
||||||
|
<select id="transTypeFilter" class="trans-select trans-type-select">
|
||||||
|
<option value="">All types</option>
|
||||||
|
<option value="string">UI strings</option>
|
||||||
|
<option value="question">Questions</option>
|
||||||
|
<option value="answer_option">Answer options</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
${translationListHeaderHTML(langLabel, sourceLabel)}
|
||||||
|
<div id="transGroupedRoot">
|
||||||
|
${renderGroupedTranslationsHTML(groups, editorTransLang, sourceLabel, editable, { showColumnHeader: false })}
|
||||||
|
</div>
|
||||||
|
<div class="trans-stats">
|
||||||
|
<span id="transStats">${missingCount ? `${missingCount} missing · ` : ''}${entries.length} keys</span>
|
||||||
|
<span class="trans-stats-progress">
|
||||||
|
<span class="trans-progress-bar"><span class="trans-progress-fill" style="width:${pct}%"></span></span>
|
||||||
|
<span class="trans-progress-label">${pct}% in ${escHtml(editorTransLang.toUpperCase())}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEditorTransEditing(entries, editable) {
|
||||||
|
if (!editable) return;
|
||||||
|
editorTransSaveTimers = {};
|
||||||
|
document.querySelectorAll('#transGroupedRoot .trans-cell-input').forEach(inp => {
|
||||||
|
inp.addEventListener('input', () => {
|
||||||
|
const idx = parseInt(inp.dataset.idx, 10);
|
||||||
|
const entry = entries[idx];
|
||||||
|
if (!entry) return;
|
||||||
|
const isGerman = inp.dataset.field === 'german';
|
||||||
|
if (!entry.translations || typeof entry.translations !== 'object') entry.translations = {};
|
||||||
|
if (isGerman) {
|
||||||
|
entry.germanText = inp.value;
|
||||||
|
entry.translations[SOURCE_LANG] = inp.value;
|
||||||
|
} else {
|
||||||
|
entry.translations[editorTransLang] = inp.value;
|
||||||
|
const missing = !inp.value.trim();
|
||||||
|
inp.classList.toggle('trans-input-missing', missing);
|
||||||
|
const row = inp.closest('.trans-list-item');
|
||||||
|
if (row) {
|
||||||
|
row.classList.toggle('trans-list-item-missing', missing);
|
||||||
|
row.dataset.missing = missing ? '1' : '0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const timerKey = isGerman ? `${idx}_de` : `${idx}_${editorTransLang}`;
|
||||||
|
clearTimeout(editorTransSaveTimers[timerKey]);
|
||||||
|
editorTransSaveTimers[timerKey] = setTimeout(() => saveEditorTranslation(entry, inp, isGerman), 500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEditorTranslation(entry, inp, isGerman) {
|
||||||
|
try {
|
||||||
|
await apiPut('translations.php', {
|
||||||
|
type: entry.type,
|
||||||
|
id: entry.entityId,
|
||||||
|
languageCode: isGerman ? SOURCE_LANG : editorTransLang,
|
||||||
|
text: inp.value,
|
||||||
|
});
|
||||||
|
inp.classList.add('save-flash');
|
||||||
|
setTimeout(() => inp.classList.remove('save-flash'), 400);
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEditorTransFiltering(entries) {
|
||||||
|
const filterInput = document.getElementById('transFilter');
|
||||||
|
const typeFilter = document.getElementById('transTypeFilter');
|
||||||
|
const root = document.getElementById('transGroupedRoot');
|
||||||
|
if (!filterInput || !root) return;
|
||||||
|
|
||||||
|
function applyFilter() {
|
||||||
|
const text = filterInput.value.toLowerCase();
|
||||||
|
const type = typeFilter?.value || '';
|
||||||
|
let visible = 0;
|
||||||
|
let visibleMissing = 0;
|
||||||
|
root.querySelectorAll('.trans-list-item').forEach(row => {
|
||||||
|
const key = row.dataset.key?.toLowerCase() || '';
|
||||||
|
const rowType = row.dataset.type || '';
|
||||||
|
const german = row.querySelector('.trans-source-text')?.textContent?.toLowerCase()
|
||||||
|
|| row.querySelector('[data-field="german"]')?.value?.toLowerCase() || '';
|
||||||
|
const val = row.querySelector('[data-field="translation"]')?.value?.toLowerCase() || '';
|
||||||
|
const show = (!type || rowType === type) &&
|
||||||
|
(!text || key.includes(text) || german.includes(text) || val.includes(text));
|
||||||
|
row.classList.toggle('trans-row-hidden', !show);
|
||||||
|
if (show) {
|
||||||
|
visible++;
|
||||||
|
if (row.dataset.missing === '1') visibleMissing++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
root.querySelectorAll('.trans-group').forEach(group => {
|
||||||
|
const anyVisible = [...group.querySelectorAll('.trans-list-item')].some(r => !r.classList.contains('trans-row-hidden'));
|
||||||
|
group.classList.toggle('trans-group-hidden', !anyVisible);
|
||||||
|
});
|
||||||
|
const stats = document.getElementById('transStats');
|
||||||
|
if (stats) {
|
||||||
|
stats.textContent = text || type
|
||||||
|
? `Showing ${visible} (${visibleMissing} missing)`
|
||||||
|
: `${[...root.querySelectorAll('.trans-list-item')].filter(r => r.dataset.missing === '1').length} missing · ${entries.length} keys`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filterInput.addEventListener('input', applyFilter);
|
||||||
|
typeFilter?.addEventListener('change', applyFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
function languageManagerHTML(languages) {
|
function languageManagerHTML(languages) {
|
||||||
|
const others = targetLanguages(languages);
|
||||||
return `
|
return `
|
||||||
<div class="lang-manager">
|
<div class="lang-manager">
|
||||||
|
<p style="font-size:.85rem;color:var(--text-secondary);margin:0 0 10px">
|
||||||
|
German source text is edited under Questions. Add other languages here.
|
||||||
|
</p>
|
||||||
<div class="lang-chips">
|
<div class="lang-chips">
|
||||||
${languages.map(l => `
|
<span class="lang-chip lang-chip-fixed">
|
||||||
|
<strong>DE</strong>
|
||||||
|
<span class="lang-chip-name">German (source)</span>
|
||||||
|
</span>
|
||||||
|
${others.map(l => `
|
||||||
<span class="lang-chip">
|
<span class="lang-chip">
|
||||||
<strong>${esc(l.languageCode.toUpperCase())}</strong>
|
<strong>${esc(l.languageCode.toUpperCase())}</strong>
|
||||||
${l.name ? `<span class="lang-chip-name">${esc(l.name)}</span>` : ''}
|
${l.name ? `<span class="lang-chip-name">${esc(l.name)}</span>` : ''}
|
||||||
<button class="lang-chip-remove" data-lc="${esc(l.languageCode)}" title="Remove language">×</button>
|
<button class="lang-chip-remove" data-lc="${esc(l.languageCode)}" title="Remove language">×</button>
|
||||||
</span>
|
</span>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
${!languages.length ? '<span style="color:var(--text-secondary);font-size:.85rem">No languages defined yet.</span>' : ''}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="lang-add-row">
|
<div class="lang-add-row">
|
||||||
<input type="text" id="newLangCode" placeholder="Code (e.g. de)" class="lang-add-code">
|
<input type="text" id="newLangCode" placeholder="Code (en)" class="lang-add-code">
|
||||||
<input type="text" id="newLangName" placeholder="Name (e.g. German)" class="lang-add-name">
|
<input type="text" id="newLangName" placeholder="Name (English)" class="lang-add-name">
|
||||||
<button class="btn btn-sm btn-primary" id="addLangBtn">+ Add Language</button>
|
<button class="btn btn-sm btn-primary" id="addLangBtn">+ Add Language</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1048,6 +1160,10 @@ function bindLanguageManager(languages) {
|
|||||||
const code = document.getElementById('newLangCode').value.trim().toLowerCase();
|
const code = document.getElementById('newLangCode').value.trim().toLowerCase();
|
||||||
const name = document.getElementById('newLangName').value.trim();
|
const name = document.getElementById('newLangName').value.trim();
|
||||||
if (!code) { showToast('Language code is required', 'error'); return; }
|
if (!code) { showToast('Language code is required', 'error'); return; }
|
||||||
|
if (code === SOURCE_LANG) {
|
||||||
|
showToast('German (de) is always available as the source language', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (languages.some(l => l.languageCode === code)) {
|
if (languages.some(l => l.languageCode === code)) {
|
||||||
showToast('Language already exists', 'error');
|
showToast('Language already exists', 'error');
|
||||||
return;
|
return;
|
||||||
@ -1055,6 +1171,7 @@ function bindLanguageManager(languages) {
|
|||||||
try {
|
try {
|
||||||
await apiPut('translations.php', { type: 'language', languageCode: code, name });
|
await apiPut('translations.php', { type: 'language', languageCode: code, name });
|
||||||
showToast(`Language "${code}" added`, 'success');
|
showToast(`Language "${code}" added`, 'success');
|
||||||
|
editorTransLang = code;
|
||||||
loadTranslationsTab();
|
loadTranslationsTab();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(e.message, 'error');
|
showToast(e.message, 'error');
|
||||||
@ -1076,99 +1193,6 @@ function bindLanguageManager(languages) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindTransCellEditing(entries) {
|
|
||||||
const debounceTimers = {};
|
|
||||||
document.querySelectorAll('.trans-cell-input').forEach(inp => {
|
|
||||||
inp.addEventListener('input', () => {
|
|
||||||
const idx = parseInt(inp.dataset.idx, 10);
|
|
||||||
const lang = inp.dataset.lang;
|
|
||||||
const entry = entries[idx];
|
|
||||||
if (!entry) return;
|
|
||||||
|
|
||||||
entry.translations[lang] = inp.value;
|
|
||||||
|
|
||||||
const cell = inp.closest('td');
|
|
||||||
cell.classList.toggle('cell-missing', !inp.value);
|
|
||||||
|
|
||||||
const timerKey = `${idx}_${lang}`;
|
|
||||||
clearTimeout(debounceTimers[timerKey]);
|
|
||||||
debounceTimers[timerKey] = setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
await apiPut('translations.php', {
|
|
||||||
type: entry.type,
|
|
||||||
id: entry.entityId,
|
|
||||||
languageCode: lang,
|
|
||||||
text: inp.value,
|
|
||||||
});
|
|
||||||
inp.classList.add('save-flash');
|
|
||||||
setTimeout(() => inp.classList.remove('save-flash'), 400);
|
|
||||||
} catch (e) {
|
|
||||||
showToast(e.message, 'error');
|
|
||||||
}
|
|
||||||
}, 600);
|
|
||||||
});
|
|
||||||
|
|
||||||
inp.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Tab' || e.key === 'Enter') return;
|
|
||||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
||||||
e.preventDefault();
|
|
||||||
const row = inp.closest('tr');
|
|
||||||
const cellIdx = [...row.children].indexOf(inp.closest('td'));
|
|
||||||
const targetRow = e.key === 'ArrowDown' ? row.nextElementSibling : row.previousElementSibling;
|
|
||||||
if (targetRow && !targetRow.classList.contains('trans-row-hidden')) {
|
|
||||||
const targetInput = targetRow.children[cellIdx]?.querySelector('input');
|
|
||||||
if (targetInput) targetInput.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindTransFiltering() {
|
|
||||||
const filterInput = document.getElementById('transFilter');
|
|
||||||
const typeChecks = document.querySelectorAll('.trans-type-filter');
|
|
||||||
const tbody = document.querySelector('#transTable tbody');
|
|
||||||
if (!filterInput || !tbody) return;
|
|
||||||
|
|
||||||
function applyFilter() {
|
|
||||||
const text = filterInput.value.toLowerCase();
|
|
||||||
const activeTypes = new Set([...typeChecks].filter(c => c.checked).map(c => c.value));
|
|
||||||
let visible = 0;
|
|
||||||
tbody.querySelectorAll('tr').forEach(row => {
|
|
||||||
const key = row.dataset.key?.toLowerCase() || '';
|
|
||||||
const type = row.dataset.type || '';
|
|
||||||
const show = activeTypes.has(type) && (!text || key.includes(text));
|
|
||||||
row.classList.toggle('trans-row-hidden', !show);
|
|
||||||
if (show) visible++;
|
|
||||||
});
|
|
||||||
const stats = document.getElementById('transStats');
|
|
||||||
if (stats) stats.textContent = `Showing ${visible} of ${tbody.children.length} keys`;
|
|
||||||
}
|
|
||||||
|
|
||||||
filterInput.addEventListener('input', applyFilter);
|
|
||||||
typeChecks.forEach(c => c.addEventListener('change', applyFilter));
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateTransStats(entries, langCodes) {
|
|
||||||
const stats = document.getElementById('transStats');
|
|
||||||
if (!stats) return;
|
|
||||||
const total = entries.length * langCodes.length;
|
|
||||||
let filled = 0;
|
|
||||||
for (const e of entries) {
|
|
||||||
for (const lc of langCodes) {
|
|
||||||
if (e.translations[lc]) filled++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const pct = total ? Math.round((filled / total) * 100) : 0;
|
|
||||||
stats.innerHTML = `
|
|
||||||
<span>${entries.length} keys × ${langCodes.length} languages = ${total} cells</span>
|
|
||||||
<span class="trans-stats-progress">
|
|
||||||
<span class="trans-progress-bar"><span class="trans-progress-fill" style="width:${pct}%"></span></span>
|
|
||||||
<strong>${filled}/${total}</strong> (${pct}%) translated
|
|
||||||
</span>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Drag & drop (questions) ──────────────────────────────────────────────
|
// ── Drag & drop (questions) ──────────────────────────────────────────────
|
||||||
|
|
||||||
function initDragReorder() {
|
function initDragReorder() {
|
||||||
|
|||||||
384
website/js/pages/translations.js
Normal file
384
website/js/pages/translations.js
Normal file
@ -0,0 +1,384 @@
|
|||||||
|
import { apiGet, apiPut, apiDelete } from '../api.js';
|
||||||
|
import { showToast } from '../app.js';
|
||||||
|
import {
|
||||||
|
SOURCE_LANG,
|
||||||
|
normalizeEntry,
|
||||||
|
translationFor,
|
||||||
|
targetLanguages,
|
||||||
|
sourceLanguageLabel,
|
||||||
|
translationListHeaderHTML,
|
||||||
|
buildTranslationGroups,
|
||||||
|
renderGroupedTranslationsHTML,
|
||||||
|
flattenAllQuestionnaires,
|
||||||
|
escHtml as esc,
|
||||||
|
} from '../translations-helpers.js';
|
||||||
|
|
||||||
|
let transData = null;
|
||||||
|
let allEntries = [];
|
||||||
|
let selectedLang = '';
|
||||||
|
let saveTimers = {};
|
||||||
|
|
||||||
|
export async function translationsPage() {
|
||||||
|
const app = document.getElementById('app');
|
||||||
|
app.innerHTML = `
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Translations</h1>
|
||||||
|
</div>
|
||||||
|
<div id="transPageRoot"><div class="spinner"></div></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
renderShell();
|
||||||
|
await loadTranslations();
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('transPageRoot').innerHTML =
|
||||||
|
`<p class="error-text" style="padding:16px">${esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderShell() {
|
||||||
|
document.getElementById('transPageRoot').innerHTML = `
|
||||||
|
<div class="card trans-page">
|
||||||
|
<div class="trans-controls">
|
||||||
|
<label class="trans-control">
|
||||||
|
<span>Translate into</span>
|
||||||
|
<select id="transLangSelect" class="trans-select" disabled>
|
||||||
|
<option value="">—</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="trans-lang-panel">
|
||||||
|
<summary>More languages</summary>
|
||||||
|
<div id="transLangPanel"><span class="text-muted">Loading…</span></div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div class="trans-toolbar">
|
||||||
|
<input type="search" id="transFilter" class="trans-search-input" placeholder="Search…" disabled>
|
||||||
|
<select id="transTypeFilter" class="trans-select trans-type-select" disabled>
|
||||||
|
<option value="">All types</option>
|
||||||
|
<option value="string">UI strings</option>
|
||||||
|
<option value="question">Questions</option>
|
||||||
|
<option value="answer_option">Answer options</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="transListArea"><div class="spinner"></div></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickDefaultTargetLang(languages) {
|
||||||
|
const targets = targetLanguages(languages);
|
||||||
|
if (!targets.length) return '';
|
||||||
|
if (selectedLang && targets.some(l => l.languageCode === selectedLang)) {
|
||||||
|
return selectedLang;
|
||||||
|
}
|
||||||
|
return targets[0].languageCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureGermanLanguage() {
|
||||||
|
try {
|
||||||
|
await apiPut('translations.php', { type: 'language', languageCode: SOURCE_LANG, name: 'German' });
|
||||||
|
} catch (_) { /* already exists */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTranslations() {
|
||||||
|
const listArea = document.getElementById('transListArea');
|
||||||
|
const langSelect = document.getElementById('transLangSelect');
|
||||||
|
const filter = document.getElementById('transFilter');
|
||||||
|
const typeFilter = document.getElementById('transTypeFilter');
|
||||||
|
|
||||||
|
if (listArea) listArea.innerHTML = '<div class="spinner"></div>';
|
||||||
|
saveTimers = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureGermanLanguage();
|
||||||
|
const data = await apiGet('translations.php?all=1');
|
||||||
|
transData = data;
|
||||||
|
const languages = transData.languages || [];
|
||||||
|
const questionnaires = transData.questionnaires || [];
|
||||||
|
|
||||||
|
const flat = flattenAllQuestionnaires(questionnaires);
|
||||||
|
allEntries = flat.allEntries;
|
||||||
|
transData.sections = flat.sections;
|
||||||
|
|
||||||
|
selectedLang = pickDefaultTargetLang(languages);
|
||||||
|
|
||||||
|
if (langSelect) {
|
||||||
|
const targets = targetLanguages(languages);
|
||||||
|
langSelect.disabled = !targets.length;
|
||||||
|
langSelect.innerHTML = targets.length
|
||||||
|
? targets.map(l => {
|
||||||
|
const label = l.name ? `${l.name} (${l.languageCode})` : l.languageCode;
|
||||||
|
return `<option value="${esc(l.languageCode)}" ${l.languageCode === selectedLang ? 'selected' : ''}>${esc(label)}</option>`;
|
||||||
|
}).join('')
|
||||||
|
: '<option value="">Add a language below</option>';
|
||||||
|
langSelect.onchange = () => {
|
||||||
|
selectedLang = langSelect.value;
|
||||||
|
renderEntryList();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter) filter.disabled = false;
|
||||||
|
if (typeFilter) typeFilter.disabled = false;
|
||||||
|
|
||||||
|
renderLanguagePanel(languages);
|
||||||
|
renderEntryList();
|
||||||
|
bindFilters();
|
||||||
|
} catch (e) {
|
||||||
|
if (listArea) listArea.innerHTML = `<p class="error-text">${esc(e.message)}</p>`;
|
||||||
|
showToast(e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLanguagePanel(languages) {
|
||||||
|
const panel = document.getElementById('transLangPanel');
|
||||||
|
if (!panel) return;
|
||||||
|
|
||||||
|
const others = targetLanguages(languages);
|
||||||
|
|
||||||
|
panel.innerHTML = `
|
||||||
|
<p class="text-muted" style="font-size:.85rem;margin:0 0 10px">
|
||||||
|
German source text is edited in the questionnaire editor. Add other languages here.
|
||||||
|
</p>
|
||||||
|
<div class="lang-chips">
|
||||||
|
<span class="lang-chip lang-chip-fixed">
|
||||||
|
<strong>DE</strong>
|
||||||
|
<span class="lang-chip-name">German (source)</span>
|
||||||
|
</span>
|
||||||
|
${others.map(l => `
|
||||||
|
<span class="lang-chip">
|
||||||
|
<strong>${esc(l.languageCode.toUpperCase())}</strong>
|
||||||
|
${l.name ? `<span class="lang-chip-name">${esc(l.name)}</span>` : ''}
|
||||||
|
<button type="button" class="lang-chip-remove" data-lc="${esc(l.languageCode)}" title="Remove language">×</button>
|
||||||
|
</span>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
<div class="lang-add-row">
|
||||||
|
<input type="text" id="newLangCode" placeholder="Code (en)" class="lang-add-code" maxlength="8">
|
||||||
|
<input type="text" id="newLangName" placeholder="Name (English)" class="lang-add-name">
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" id="addLangBtn">Add</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('addLangBtn')?.addEventListener('click', async () => {
|
||||||
|
const code = document.getElementById('newLangCode').value.trim().toLowerCase();
|
||||||
|
const name = document.getElementById('newLangName').value.trim();
|
||||||
|
if (!code) { showToast('Language code is required', 'error'); return; }
|
||||||
|
if (code === SOURCE_LANG) {
|
||||||
|
showToast('German (de) is always available as the source language', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (languages.some(l => l.languageCode === code)) {
|
||||||
|
showToast('Language already exists', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiPut('translations.php', { type: 'language', languageCode: code, name });
|
||||||
|
showToast(`Language "${code}" added`, 'success');
|
||||||
|
selectedLang = code;
|
||||||
|
await loadTranslations();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
panel.querySelectorAll('.lang-chip-remove').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const lc = btn.dataset.lc;
|
||||||
|
if (!confirm(`Remove "${lc}" and all its translations?`)) return;
|
||||||
|
try {
|
||||||
|
await apiDelete('translations.php', { type: 'language', languageCode: lc });
|
||||||
|
showToast(`Language "${lc}" removed`, 'success');
|
||||||
|
if (selectedLang === lc) selectedLang = '';
|
||||||
|
await loadTranslations();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEntryList() {
|
||||||
|
const listArea = document.getElementById('transListArea');
|
||||||
|
if (!listArea || !transData) return;
|
||||||
|
|
||||||
|
const languages = transData.languages || [];
|
||||||
|
const targets = targetLanguages(languages);
|
||||||
|
const sections = transData.sections || [];
|
||||||
|
|
||||||
|
if (!targets.length) {
|
||||||
|
listArea.innerHTML = `
|
||||||
|
<p class="text-muted trans-hint">Add at least one language under <strong>More languages</strong> to translate into.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedLang) {
|
||||||
|
listArea.innerHTML = `<p class="text-muted trans-hint">Choose a language above.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sections.length) {
|
||||||
|
listArea.innerHTML = `
|
||||||
|
<p class="text-muted trans-hint">No translatable content yet. Add questionnaires and questions in the editor first.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceLabel = sourceLanguageLabel(languages);
|
||||||
|
const langLabel = targets.find(l => l.languageCode === selectedLang)?.name || selectedLang.toUpperCase();
|
||||||
|
|
||||||
|
const missingCount = allEntries.filter(e => !translationFor(e, selectedLang).trim()).length;
|
||||||
|
const pct = allEntries.length ? Math.round(((allEntries.length - missingCount) / allEntries.length) * 100) : 0;
|
||||||
|
|
||||||
|
const headerOnce = translationListHeaderHTML(langLabel, sourceLabel);
|
||||||
|
|
||||||
|
const sectionsHtml = sections.map(sec => {
|
||||||
|
const entries = allEntries.slice(sec.startIdx, sec.startIdx + sec.entryCount);
|
||||||
|
const groups = buildTranslationGroups(entries, selectedLang, sec.startIdx);
|
||||||
|
return `
|
||||||
|
<div class="trans-qn-block" data-qn="${esc(sec.questionnaireID)}">
|
||||||
|
<h2 class="trans-qn-title">${esc(sec.name)}</h2>
|
||||||
|
${renderGroupedTranslationsHTML(groups, selectedLang, sourceLabel, true, {
|
||||||
|
showColumnHeader: false,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
listArea.innerHTML = `
|
||||||
|
${headerOnce}
|
||||||
|
<div id="transGroupedRoot">${sectionsHtml}</div>
|
||||||
|
<div class="trans-stats">
|
||||||
|
<span id="transVisibleCount">${missingCount ? `${missingCount} missing · ` : ''}${allEntries.length} keys</span>
|
||||||
|
<span class="trans-stats-progress">
|
||||||
|
<span class="trans-progress-bar"><span class="trans-progress-fill" style="width:${pct}%"></span></span>
|
||||||
|
<span class="trans-progress-label">${pct}% in ${esc(selectedLang.toUpperCase())}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
bindEntryEditing(allEntries);
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEntryEditing(entries) {
|
||||||
|
document.querySelectorAll('#transGroupedRoot .trans-cell-input').forEach(inp => {
|
||||||
|
inp.addEventListener('input', () => {
|
||||||
|
const idx = parseInt(inp.dataset.idx, 10);
|
||||||
|
const entry = entries[idx];
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
const isGerman = inp.dataset.field === 'german';
|
||||||
|
if (isGerman) {
|
||||||
|
entry.germanText = inp.value;
|
||||||
|
if (!entry.translations || typeof entry.translations !== 'object') entry.translations = {};
|
||||||
|
entry.translations[SOURCE_LANG] = inp.value;
|
||||||
|
} else {
|
||||||
|
if (!entry.translations || typeof entry.translations !== 'object') entry.translations = {};
|
||||||
|
entry.translations[selectedLang] = inp.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isGerman) {
|
||||||
|
const missing = !inp.value.trim();
|
||||||
|
inp.classList.toggle('trans-input-missing', missing);
|
||||||
|
const row = inp.closest('.trans-list-item');
|
||||||
|
if (row) {
|
||||||
|
row.classList.toggle('trans-list-item-missing', missing);
|
||||||
|
row.dataset.missing = missing ? '1' : '0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const timerKey = isGerman ? `${idx}_de` : `${idx}_${selectedLang}`;
|
||||||
|
clearTimeout(saveTimers[timerKey]);
|
||||||
|
saveTimers[timerKey] = setTimeout(() => saveTranslation(entry, inp, isGerman), 500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTranslation(entry, inp, isGerman = false) {
|
||||||
|
try {
|
||||||
|
await apiPut('translations.php', {
|
||||||
|
type: entry.type,
|
||||||
|
id: entry.entityId,
|
||||||
|
languageCode: isGerman ? SOURCE_LANG : selectedLang,
|
||||||
|
text: inp.value,
|
||||||
|
});
|
||||||
|
inp.classList.add('save-flash');
|
||||||
|
setTimeout(() => inp.classList.remove('save-flash'), 400);
|
||||||
|
updateProgress();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProgress() {
|
||||||
|
const missing = allEntries.filter(e => !translationFor(e, selectedLang).trim()).length;
|
||||||
|
const pct = allEntries.length ? Math.round(((allEntries.length - missing) / allEntries.length) * 100) : 0;
|
||||||
|
const fill = document.querySelector('.trans-progress-fill');
|
||||||
|
if (fill) fill.style.width = `${pct}%`;
|
||||||
|
const label = document.querySelector('.trans-progress-label');
|
||||||
|
if (label) label.textContent = `${pct}% in ${selectedLang.toUpperCase()}`;
|
||||||
|
const countEl = document.getElementById('transVisibleCount');
|
||||||
|
if (countEl && !document.getElementById('transFilter')?.value && !document.getElementById('transTypeFilter')?.value) {
|
||||||
|
countEl.textContent = `${missing ? `${missing} missing · ` : ''}${allEntries.length} keys`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let filtersBound = false;
|
||||||
|
|
||||||
|
function bindFilters() {
|
||||||
|
if (filtersBound) {
|
||||||
|
applyFilters();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
filtersBound = true;
|
||||||
|
|
||||||
|
document.getElementById('transFilter')?.addEventListener('input', applyFilters);
|
||||||
|
document.getElementById('transTypeFilter')?.addEventListener('change', applyFilters);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
const text = (document.getElementById('transFilter')?.value || '').toLowerCase();
|
||||||
|
const type = document.getElementById('transTypeFilter')?.value || '';
|
||||||
|
const items = document.querySelectorAll('#transGroupedRoot .trans-list-item');
|
||||||
|
let visible = 0;
|
||||||
|
let visibleMissing = 0;
|
||||||
|
|
||||||
|
items.forEach(row => {
|
||||||
|
const key = row.dataset.key?.toLowerCase()
|
||||||
|
|| row.querySelector('.trans-key-text')?.textContent?.toLowerCase() || '';
|
||||||
|
const rowType = row.dataset.type || '';
|
||||||
|
const german = row.querySelector('.trans-source-text')?.textContent?.toLowerCase()
|
||||||
|
|| row.querySelector('[data-field="german"]')?.value?.toLowerCase() || '';
|
||||||
|
const val = row.querySelector('[data-field="translation"]')?.value?.toLowerCase() || '';
|
||||||
|
const show = (!type || rowType === type) &&
|
||||||
|
(!text || key.includes(text) || german.includes(text) || val.includes(text));
|
||||||
|
row.classList.toggle('trans-row-hidden', !show);
|
||||||
|
if (show) {
|
||||||
|
visible++;
|
||||||
|
if (row.dataset.missing === '1') visibleMissing++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('#transGroupedRoot .trans-group').forEach(group => {
|
||||||
|
const rows = group.querySelectorAll('.trans-list-item');
|
||||||
|
const anyVisible = [...rows].some(r => !r.classList.contains('trans-row-hidden'));
|
||||||
|
group.classList.toggle('trans-group-hidden', !anyVisible);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('#transGroupedRoot .trans-qn-block').forEach(block => {
|
||||||
|
const anyVisible = [...block.querySelectorAll('.trans-list-item')].some(r => !r.classList.contains('trans-row-hidden'));
|
||||||
|
block.classList.toggle('trans-qn-hidden', !anyVisible);
|
||||||
|
});
|
||||||
|
|
||||||
|
const countEl = document.getElementById('transVisibleCount');
|
||||||
|
if (countEl) {
|
||||||
|
if (text || type) {
|
||||||
|
countEl.textContent = `Showing ${visible} (${visibleMissing} missing)`;
|
||||||
|
} else {
|
||||||
|
const totalMissing = [...items].filter(r => r.dataset.missing === '1').length;
|
||||||
|
countEl.textContent = `${totalMissing ? `${totalMissing} missing · ` : ''}${items.length} keys`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
185
website/js/translations-helpers.js
Normal file
185
website/js/translations-helpers.js
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
/** German (de) is the canonical source language for questionnaire content. */
|
||||||
|
export const SOURCE_LANG = 'de';
|
||||||
|
|
||||||
|
export function normalizeTranslations(raw) {
|
||||||
|
if (!raw) return {};
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
const map = {};
|
||||||
|
raw.forEach(row => {
|
||||||
|
if (row && row.languageCode) map[row.languageCode] = row.text ?? '';
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
return typeof raw === 'object' ? { ...raw } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeEntry(entry) {
|
||||||
|
const translations = normalizeTranslations(entry.translations);
|
||||||
|
const germanText = (entry.germanText || translations[SOURCE_LANG] || entry.key || '').trim()
|
||||||
|
|| entry.key
|
||||||
|
|| '';
|
||||||
|
return { ...entry, translations, germanText };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function germanFor(entry) {
|
||||||
|
return entry.germanText || normalizeTranslations(entry.translations)[SOURCE_LANG] || entry.key || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translationFor(entry, lang) {
|
||||||
|
return normalizeTranslations(entry.translations)[lang] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function targetLanguages(languages) {
|
||||||
|
return (languages || []).filter(l => l.languageCode !== SOURCE_LANG);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sort rows within a group: missing translations first, then questionnaire order */
|
||||||
|
export function sortGroupItems(items, targetLang) {
|
||||||
|
return [...items].sort((a, b) => {
|
||||||
|
const aMissing = !translationFor(a.entry, targetLang).trim();
|
||||||
|
const bMissing = !translationFor(b.entry, targetLang).trim();
|
||||||
|
if (aMissing !== bMissing) return aMissing ? -1 : 1;
|
||||||
|
|
||||||
|
const orderA = a.entry.sortOrder ?? 0;
|
||||||
|
const orderB = b.entry.sortOrder ?? 0;
|
||||||
|
if (orderA !== orderB) return orderA - orderB;
|
||||||
|
|
||||||
|
const typeCmp = (a.entry.type === 'question' ? 0 : 1) - (b.entry.type === 'question' ? 0 : 1);
|
||||||
|
if (typeCmp !== 0) return typeCmp;
|
||||||
|
|
||||||
|
return germanFor(a.entry).localeCompare(germanFor(b.entry), 'de');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups for one questionnaire: UI strings, then questions + options (interleaved).
|
||||||
|
* @param {number} indexOffset - global index for save handlers
|
||||||
|
*/
|
||||||
|
export function buildTranslationGroups(entries, targetLang, indexOffset = 0) {
|
||||||
|
const items = entries.map((entry, i) => ({ entry, idx: indexOffset + i }));
|
||||||
|
const strings = items.filter(({ entry }) => entry.type === 'string');
|
||||||
|
const content = items.filter(({ entry }) => entry.type !== 'string');
|
||||||
|
const groups = [];
|
||||||
|
|
||||||
|
if (strings.length) {
|
||||||
|
groups.push({
|
||||||
|
id: 'strings',
|
||||||
|
title: 'UI strings',
|
||||||
|
items: sortGroupItems(strings, targetLang),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (content.length) {
|
||||||
|
groups.push({
|
||||||
|
id: 'content',
|
||||||
|
title: 'Questions',
|
||||||
|
items: sortGroupItems(content, targetLang),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render grouped sections (optional questionnaire wrapper title). */
|
||||||
|
export function renderGroupedTranslationsHTML(groups, targetLang, sourceLabel, editable, options = {}) {
|
||||||
|
const { showColumnHeader = true, questionnaireTitle = null } = options;
|
||||||
|
if (!groups.length) return '';
|
||||||
|
|
||||||
|
const header = showColumnHeader ? translationListHeaderHTML(
|
||||||
|
targetLang,
|
||||||
|
sourceLabel
|
||||||
|
) : '';
|
||||||
|
|
||||||
|
const qnHeader = questionnaireTitle
|
||||||
|
? `<h2 class="trans-qn-title">${escHtml(questionnaireTitle)}</h2>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const body = groups.map(g => `
|
||||||
|
<section class="trans-group" data-group="${escHtml(g.id)}">
|
||||||
|
<h3 class="trans-group-title">${escHtml(g.title)}</h3>
|
||||||
|
<div class="trans-list trans-list-in-group">
|
||||||
|
${g.items.map(({ entry, idx }) =>
|
||||||
|
translationRowHTML(entry, idx, { targetLang, editable })
|
||||||
|
).join('')}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
const wrapClass = questionnaireTitle ? 'trans-qn-block' : 'trans-groups-wrap';
|
||||||
|
return `<div class="${wrapClass}">${qnHeader}${header}${body}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flatten questionnaires from ?all=1 into one entries array + section metadata */
|
||||||
|
export function flattenAllQuestionnaires(questionnaires) {
|
||||||
|
const allEntries = [];
|
||||||
|
const sections = [];
|
||||||
|
|
||||||
|
for (const qn of questionnaires) {
|
||||||
|
const normalized = (qn.entries || []).map(normalizeEntry);
|
||||||
|
if (!normalized.length) continue;
|
||||||
|
const startIdx = allEntries.length;
|
||||||
|
normalized.forEach(e => allEntries.push(e));
|
||||||
|
sections.push({
|
||||||
|
questionnaireID: qn.questionnaireID,
|
||||||
|
name: qn.name || 'Questionnaire',
|
||||||
|
startIdx,
|
||||||
|
entryCount: normalized.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allEntries, sections };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function typeBadge(type) {
|
||||||
|
const map = { question: 'Q', answer_option: 'Opt', string: 'Str' };
|
||||||
|
const cls = { question: 'badge-q', answer_option: 'badge-opt', string: 'badge-str' };
|
||||||
|
return `<span class="trans-type-badge ${cls[type] || ''}">${map[type] || type}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escHtml(s) {
|
||||||
|
if (s == null) return '';
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sourceLanguageLabel(languages) {
|
||||||
|
const row = (languages || []).find(l => l.languageCode === SOURCE_LANG);
|
||||||
|
return row?.name ? `${row.name} (${SOURCE_LANG})` : 'German';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Column headers: Key | main language (German) | target language */
|
||||||
|
export function translationListHeaderHTML(targetLangLabel, sourceLabel = 'German') {
|
||||||
|
return `
|
||||||
|
<div class="trans-list-header">
|
||||||
|
<span class="trans-col-key">Key</span>
|
||||||
|
<span class="trans-col-source">${escHtml(sourceLabel)}</span>
|
||||||
|
<span class="trans-col-target">${escHtml(targetLangLabel)}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translationRowHTML(entry, idx, { targetLang, editable = true }) {
|
||||||
|
const key = entry.key || '';
|
||||||
|
const german = germanFor(entry);
|
||||||
|
const val = translationFor(entry, targetLang);
|
||||||
|
const missing = !val.trim();
|
||||||
|
const disabled = editable ? '' : 'disabled';
|
||||||
|
|
||||||
|
const sourceCell = entry.type === 'string' && editable
|
||||||
|
? `<input type="text" class="trans-cell-input" data-idx="${idx}" data-field="german"
|
||||||
|
value="${escHtml(german)}" placeholder="German text…" autocomplete="off">`
|
||||||
|
: `<span class="trans-source-text" title="${escHtml(german)}">${escHtml(german)}</span>`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="trans-list-item ${missing ? 'trans-list-item-missing' : ''}"
|
||||||
|
data-idx="${idx}" data-type="${entry.type}" data-key="${escHtml(key)}" data-missing="${missing ? '1' : '0'}">
|
||||||
|
<div class="trans-list-key">
|
||||||
|
${typeBadge(entry.type)}
|
||||||
|
<span class="trans-key-text" title="${escHtml(key)}">${escHtml(key)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="trans-list-source">${sourceCell}</div>
|
||||||
|
<input type="text" class="trans-cell-input ${missing ? 'trans-input-missing' : ''}"
|
||||||
|
data-idx="${idx}" data-field="translation" value="${escHtml(val)}"
|
||||||
|
placeholder="Translation…" ${disabled} autocomplete="off">
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user