From 0291b8be7aad82f35f5dca141461fbb2e7975114 Mon Sep 17 00:00:00 2001 From: Tom Hempel Date: Fri, 22 May 2026 16:20:00 +0200 Subject: [PATCH] added translation tab --- common.php | 160 ++++++++++++ handlers/answer_options.php | 4 + handlers/questions.php | 4 + handlers/translations.php | 138 +++++------ website/css/style.css | 228 ++++++++++++++++- website/index.html | 6 + website/js/app.js | 2 + website/js/pages/editor.js | 362 ++++++++++++++------------- website/js/pages/translations.js | 384 +++++++++++++++++++++++++++++ website/js/translations-helpers.js | 185 ++++++++++++++ 10 files changed, 1227 insertions(+), 246 deletions(-) create mode 100644 website/js/pages/translations.js create mode 100644 website/js/translations-helpers.js diff --git a/common.php b/common.php index d0a243d..6782c1e 100644 --- a/common.php +++ b/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 --- function aes256_cbc_encrypt_bytes(string $plain, string $key): string { $key = str_pad(substr($key, 0, 32), 32, "\0"); diff --git a/handlers/answer_options.php b/handlers/answer_options.php index f211e08..3442573 100644 --- a/handlers/answer_options.php +++ b/handlers/answer_options.php @@ -35,6 +35,7 @@ case 'POST': $id = bin2hex(random_bytes(16)); $qID = $body['questionID']; $text = trim($body['defaultText']); + qdb_require_non_empty_german($text); $points = (int)($body['points'] ?? 0); $order = (int)($body['orderIndex'] ?? 0); $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)') ->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); json_success(['answerOption' => [ 'answerOptionID' => $id, @@ -89,12 +91,14 @@ case 'PUT': json_error('NOT_FOUND', 'Answer option not found', 404); } $text = trim($body['defaultText'] ?? $row['defaultText']); + qdb_require_non_empty_german($text); $points = (int)($body['points'] ?? $row['points']); $order = (int)($body['orderIndex'] ?? $row['orderIndex']); $nextQ = trim($body['nextQuestionId'] ?? $row['nextQuestionId']); $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]); + qdb_upsert_source_translation($pdo, 'answer_option', $id, $text); qdb_save($tmpDb, $lockFp); json_success(['answerOption' => [ 'answerOptionID' => $id, diff --git a/handlers/questions.php b/handlers/questions.php index 8d36491..73cf5dd 100644 --- a/handlers/questions.php +++ b/handlers/questions.php @@ -48,6 +48,7 @@ case 'POST': $id = bin2hex(random_bytes(16)); $qnID = $body['questionnaireID']; $text = trim($body['defaultText']); + qdb_require_non_empty_german($text); $type = trim($body['type'] ?? ''); $order = (int)($body['orderIndex'] ?? 0); $req = (int)($body['isRequired'] ?? 0); @@ -69,6 +70,7 @@ case 'POST': VALUES (:id, :qid, :t, :ty, :o, :r, :cj)") ->execute([':id' => $id, ':qid' => $qnID, ':t' => $text, ':ty' => $type, ':o' => $order, ':r' => $req, ':cj' => $config]); + qdb_upsert_source_translation($pdo, 'question', $id, $text); qdb_save($tmpDb, $lockFp); json_success(['question' => [ 'questionID' => $id, 'questionnaireID' => $qnID, 'defaultText' => $text, @@ -99,12 +101,14 @@ case 'PUT': json_error('NOT_FOUND', 'Question not found', 404); } $text = trim($body['defaultText'] ?? $row['defaultText']); + qdb_require_non_empty_german($text); $type = trim($body['type'] ?? $row['type']); $order = (int)($body['orderIndex'] ?? $row['orderIndex']); $req = (int)($body['isRequired'] ?? $row['isRequired']); $config = $body['configJson'] ?? $row['configJson']; $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]); + qdb_upsert_source_translation($pdo, 'question', $id, $text); qdb_save($tmpDb, $lockFp); json_success(['question' => [ 'questionID' => $id, 'questionnaireID' => $row['questionnaireID'], diff --git a/handlers/translations.php b/handlers/translations.php index 2228d04..928a79a 100644 --- a/handlers/translations.php +++ b/handlers/translations.php @@ -14,83 +14,74 @@ case 'GET': 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'] ?? ''; if ($qnID) { [$pdo, $tmpDb, $lockFp] = qdb_open(false); $languages = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC); - - $qStmt = $pdo->prepare(" - 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) ?: []; - 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; - } + if (!array_filter($languages, fn($l) => $l['languageCode'] === QDB_SOURCE_LANGUAGE)) { + array_unshift($languages, ['languageCode' => QDB_SOURCE_LANGUAGE, 'name' => 'German']); } - foreach (array_keys($stringKeys) as $sk) { - $entries[] = ['key' => $sk, 'type' => 'string', 'entityId' => $sk]; - } + $qnRow = $pdo->prepare("SELECT name FROM questionnaire WHERE questionnaireID = :id"); + $qnRow->execute([':id' => $qnID]); + $qnName = $qnRow->fetchColumn() ?: ''; - $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); + $lists = qdb_translation_entry_lists($pdo, $qnID); + $entries = array_merge($lists['stringEntries'], $lists['contentEntries']); + $translations = qdb_load_translations_for_entries($pdo, $entries); + qdb_attach_translation_texts($entries, $translations); 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'] ?? ''; @@ -132,9 +123,13 @@ case 'PUT': } [$pdo, $tmpDb, $lockFp] = qdb_open(true); try { - $pdo->prepare("INSERT INTO language (languageCode, name) VALUES (:lc, :n) - ON CONFLICT(languageCode) DO UPDATE SET name = excluded.name") - ->execute([':lc' => $lang, ':n' => $name]); + if ($lang === QDB_SOURCE_LANGUAGE) { + qdb_ensure_source_language($pdo); + } else { + $pdo->prepare("INSERT INTO language (languageCode, name) VALUES (:lc, :n) + ON CONFLICT(languageCode) DO UPDATE SET name = excluded.name") + ->execute([':lc' => $lang, ':n' => $name]); + } qdb_save($tmpDb, $lockFp); json_success([]); } catch (Throwable $e) { @@ -189,6 +184,9 @@ case 'DELETE': if (!$lang) { 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); try { $pdo->prepare("DELETE FROM language WHERE languageCode = :lc")->execute([':lc' => $lang]); diff --git a/website/css/style.css b/website/css/style.css index 3f6942e..21d4e0c 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -15,6 +15,12 @@ --shadow-lg: 0 4px 12px rgba(0,0,0,.1); --sidebar-width: 240px; --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; } @@ -740,6 +746,204 @@ th.sort-desc::after { content: ' \2193'; opacity: 1; } 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 ── */ .lang-manager { background: #f8fafc; @@ -849,7 +1053,11 @@ th.sort-desc::after { content: ' \2193'; opacity: 1; } .trans-table td { padding: 2px 4px; } .trans-table tbody tr { border-bottom: 1px solid #f1f5f9; } .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-type { width: 50px; text-align: center; } @@ -876,11 +1084,11 @@ th.sort-desc::after { content: ' \2193'; opacity: 1; } letter-spacing: .3px; } .badge-q { background: #dbeafe; color: #1e40af; } -.badge-opt { background: #fef3c7; color: #92400e; } +.badge-opt { background: #e2e8f0; color: #475569; } .badge-str { background: #ede9fe; color: #5b21b6; } .cell-trans { position: relative; } -.cell-missing { background: #fff7ed; } +.cell-missing { background: var(--trans-missing-bg); } .trans-cell-input { 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); } .trans-cell-input::placeholder { - color: #f59e0b; - font-style: italic; + color: var(--trans-missing-placeholder); + font-style: normal; font-size: .8rem; } +.trans-cell-input:not(.trans-input-missing)::placeholder { + color: var(--text-secondary); +} .trans-cell-input.save-flash { animation: saveFlash .4s ease; } @keyframes saveFlash { - 0% { background: #dcfce7; } - 100% { background: transparent; } + 0% { background: #dbeafe; } + 100% { background: var(--surface); } +} +.trans-list-item .trans-cell-input.save-flash { + animation: saveFlash .4s ease; } /* ── Stats bar ── */ diff --git a/website/index.html b/website/index.html index 1ce969a..65f49b4 100644 --- a/website/index.html +++ b/website/index.html @@ -39,6 +39,12 @@ Assign Clients +
  • + + + Translations + +
  • diff --git a/website/js/app.js b/website/js/app.js index 107dc92..de12b32 100644 --- a/website/js/app.js +++ b/website/js/app.js @@ -7,6 +7,7 @@ import { exportPage } from './pages/export.js'; import { usersPage } from './pages/users.js'; import { assignmentsPage } from './pages/assignments.js'; import { clientsPage } from './pages/clients.js'; +import { translationsPage } from './pages/translations.js'; // Auth state export function isLoggedIn() { @@ -88,6 +89,7 @@ addRoute('/export', authGuard(exportPage)); addRoute('/users', roleGuard(['admin', 'supervisor'], usersPage)); addRoute('/assignments', roleGuard(['admin', 'supervisor'], assignmentsPage)); addRoute('/clients', roleGuard(['admin', 'supervisor'], clientsPage)); +addRoute('/translations', roleGuard(['admin', 'supervisor'], translationsPage)); // Nav link handling & logout document.addEventListener('DOMContentLoaded', () => { diff --git a/website/js/pages/editor.js b/website/js/pages/editor.js index 539261c..58c5c3a 100644 --- a/website/js/pages/editor.js +++ b/website/js/pages/editor.js @@ -1,6 +1,17 @@ import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from '../api.js'; import { canEdit, showToast } from '../app.js'; import { navigate } from '../router.js'; +import { + SOURCE_LANG, + normalizeEntry, + translationFor, + targetLanguages, + buildTranslationGroups, + sourceLanguageLabel, + translationListHeaderHTML, + renderGroupedTranslationsHTML, + escHtml, +} from '../translations-helpers.js'; const LAYOUT_TYPES = [ { value: 'radio_question', label: 'Radio (Single Choice)' }, @@ -427,8 +438,8 @@ function showAddQuestionForm() {

    New Question

    - - + +
    @@ -449,7 +460,7 @@ function showAddQuestionForm() {
      - +
      @@ -505,7 +516,7 @@ function showAddQuestionForm() { const text = document.getElementById('aq_opt_text').value.trim(); const pts = parseInt(document.getElementById('aq_opt_pts').value || '0', 10); 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 }); renderPendingOptions(); document.getElementById('aq_opt_text').value = ''; @@ -529,7 +540,7 @@ function showAddQuestionForm() { const type = document.getElementById('aq_type').value; const isRequired = document.getElementById('aq_required').checked ? 1 : 0; 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) { showToast('Add at least one answer option', 'error'); return; @@ -644,8 +655,8 @@ function renderQuestionBody(q) {
      - - + +
      @@ -666,7 +677,7 @@ function renderQuestionBody(q) {
      ` : `
      -

      Key: ${esc(q.defaultText)}

      +

      German: ${esc(q.defaultText)}

      Layout: ${esc(layoutLabel(layout))}

      ${Object.keys(config).length ? `

      Config: ${esc(JSON.stringify(config))}

      ` : ''}
      @@ -692,7 +703,7 @@ function renderQuestionBody(q) { const type = document.getElementById(`eq_type_${q.questionID}`).value; const isReq = document.getElementById(`eq_req_${q.questionID}`).checked ? 1 : 0; 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 }); }); @@ -749,8 +760,8 @@ function showAddOptionForm(q) {
      - - + +
      @@ -777,7 +788,7 @@ function showAddOptionForm(q) { const text = document.getElementById(`ao_text_${q.questionID}`).value.trim(); const points = parseInt(document.getElementById(`ao_pts_${q.questionID}`).value || '0', 10); 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}`); btn.disabled = true; @@ -824,7 +835,8 @@ function showEditOptionForm(q, opt) {
      - + +
      @@ -850,7 +862,7 @@ function showEditOptionForm(q, opt) { const text = document.getElementById(`eo_text_${opt.answerOptionID}`).value.trim(); const points = parseInt(document.getElementById(`eo_pts_${opt.answerOptionID}`).value || '0', 10); 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 }); }); @@ -923,6 +935,8 @@ async function deleteOption(q, answerOptionID) { // ── Translations tab ───────────────────────────────────────────────────── let transData = null; +let editorTransLang = ''; +let editorTransSaveTimers = {}; async function loadTranslationsTab() { const container = document.getElementById('translationsContent'); @@ -930,8 +944,12 @@ async function loadTranslationsTab() { container.innerHTML = '
      '; 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)}`); transData = data; + transData.entries = (transData.entries || []).map(normalizeEntry); renderTranslationsTab(); } catch (e) { container.innerHTML = `

      ${esc(e.message)}

      `; @@ -944,99 +962,193 @@ function renderTranslationsTab() { const editable = canEdit(); const languages = transData.languages || []; const entries = transData.entries || []; - const langCodes = languages.map(l => l.languageCode); + const targets = targetLanguages(languages); if (!entries.length) { container.innerHTML = ` ${editable ? languageManagerHTML(languages) : ''} -

      No translatable keys found. Add questions first.

      +

      No translatable content yet. Add questions with German text first.

      `; if (editable) bindLanguageManager(languages); return; } - const filterHtml = ` -
      -
      - -
      -
      - - - -
      -
      - `; + if (!editorTransLang || !targets.some(l => l.languageCode === editorTransLang)) { + editorTransLang = targets[0]?.languageCode || ''; + } - const typeBadge = (type) => { - const map = { question: 'Q', answer_option: 'Opt', string: 'Str' }; - const cls = { question: 'badge-q', answer_option: 'badge-opt', string: 'badge-str' }; - return `${map[type] || type}`; - }; + const langSelectHtml = targets.length + ? `` + : `

      Add a language below (e.g. English) to translate into.

      `; container.innerHTML = ` ${editable ? languageManagerHTML(languages) : ''} - ${filterHtml} -
      - - - - - - ${langCodes.map(lc => ``).join('')} - - - - ${entries.map((entry, i) => ` - - - - ${langCodes.map(lc => { - const val = entry.translations[lc] || ''; - const missing = !val; - return ``; - }).join('')} - - `).join('')} - -
      KeyType${esc(lc.toUpperCase())}
      ${esc(entry.key)}${typeBadge(entry.type)} - -
      -
      -
      + ${langSelectHtml} + ${targets.length && editorTransLang ? renderEditorTransList(entries, editable) : ''} `; - updateTransStats(entries, langCodes); + if (editable) bindLanguageManager(languages); - if (editable) { - bindLanguageManager(languages); - bindTransCellEditing(entries); + document.getElementById('editorTransLangSelect')?.addEventListener('change', (e) => { + editorTransLang = e.target.value; + 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 ` +
      + + +
      + ${translationListHeaderHTML(langLabel, sourceLabel)} +
      + ${renderGroupedTranslationsHTML(groups, editorTransLang, sourceLabel, editable, { showColumnHeader: false })} +
      +
      + ${missingCount ? `${missingCount} missing · ` : ''}${entries.length} keys + + + ${pct}% in ${escHtml(editorTransLang.toUpperCase())} + +
      + `; +} + +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) { + const others = targetLanguages(languages); return `
      +

      + German source text is edited under Questions. Add other languages here. +

      - ${languages.map(l => ` + + DE + German (source) + + ${others.map(l => ` ${esc(l.languageCode.toUpperCase())} ${l.name ? `${esc(l.name)}` : ''} `).join('')} - ${!languages.length ? 'No languages defined yet.' : ''}
      - - + +
      @@ -1048,6 +1160,10 @@ function bindLanguageManager(languages) { 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; @@ -1055,6 +1171,7 @@ function bindLanguageManager(languages) { try { await apiPut('translations.php', { type: 'language', languageCode: code, name }); showToast(`Language "${code}" added`, 'success'); + editorTransLang = code; loadTranslationsTab(); } catch (e) { 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 = ` - ${entries.length} keys × ${langCodes.length} languages = ${total} cells - - - ${filled}/${total} (${pct}%) translated - - `; -} - // ── Drag & drop (questions) ────────────────────────────────────────────── function initDragReorder() { diff --git a/website/js/pages/translations.js b/website/js/pages/translations.js new file mode 100644 index 0000000..09e3f86 --- /dev/null +++ b/website/js/pages/translations.js @@ -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 = ` + +
      + `; + + try { + renderShell(); + await loadTranslations(); + } catch (e) { + document.getElementById('transPageRoot').innerHTML = + `

      ${esc(e.message)}

      `; + } +} + +function renderShell() { + document.getElementById('transPageRoot').innerHTML = ` +
      +
      + +
      + +
      + More languages +
      Loading…
      +
      + +
      + + +
      + +
      +
      + `; +} + +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 = '
      '; + 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 ``; + }).join('') + : ''; + 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 = `

      ${esc(e.message)}

      `; + showToast(e.message, 'error'); + } +} + +function renderLanguagePanel(languages) { + const panel = document.getElementById('transLangPanel'); + if (!panel) return; + + const others = targetLanguages(languages); + + panel.innerHTML = ` +

      + German source text is edited in the questionnaire editor. Add other languages here. +

      +
      + + DE + German (source) + + ${others.map(l => ` + + ${esc(l.languageCode.toUpperCase())} + ${l.name ? `${esc(l.name)}` : ''} + + + `).join('')} +
      +
      + + + +
      + `; + + 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 = ` +

      Add at least one language under More languages to translate into.

      `; + return; + } + + if (!selectedLang) { + listArea.innerHTML = `

      Choose a language above.

      `; + return; + } + + if (!sections.length) { + listArea.innerHTML = ` +

      No translatable content yet. Add questionnaires and questions in the editor first.

      `; + 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 ` +
      +

      ${esc(sec.name)}

      + ${renderGroupedTranslationsHTML(groups, selectedLang, sourceLabel, true, { + showColumnHeader: false, + })} +
      + `; + }).join(''); + + listArea.innerHTML = ` + ${headerOnce} +
      ${sectionsHtml}
      +
      + ${missingCount ? `${missingCount} missing · ` : ''}${allEntries.length} keys + + + ${pct}% in ${esc(selectedLang.toUpperCase())} + +
      + `; + + 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`; + } + } +} diff --git a/website/js/translations-helpers.js b/website/js/translations-helpers.js new file mode 100644 index 0000000..d9cecd0 --- /dev/null +++ b/website/js/translations-helpers.js @@ -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 + ? `

      ${escHtml(questionnaireTitle)}

      ` + : ''; + + const body = groups.map(g => ` +
      +

      ${escHtml(g.title)}

      +
      + ${g.items.map(({ entry, idx }) => + translationRowHTML(entry, idx, { targetLang, editable }) + ).join('')} +
      +
      + `).join(''); + + const wrapClass = questionnaireTitle ? 'trans-qn-block' : 'trans-groups-wrap'; + return `
      ${qnHeader}${header}${body}
      `; +} + +/** 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 `${map[type] || type}`; +} + +export function escHtml(s) { + if (s == null) return ''; + return String(s) + .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 ` +
      + Key + ${escHtml(sourceLabel)} + ${escHtml(targetLangLabel)} +
      `; +} + +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 + ? `` + : `${escHtml(german)}`; + + return ` +
      +
      + ${typeBadge(entry.type)} + ${escHtml(key)} +
      +
      ${sourceCell}
      + +
      `; +}