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 +
Key: ${esc(q.defaultText)}
+German: ${esc(q.defaultText)}
Layout: ${esc(layoutLabel(layout))}
${Object.keys(config).length ? `Config: ${esc(JSON.stringify(config))}
${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} -| Key | -Type | - ${langCodes.map(lc => `${esc(lc.toUpperCase())} | `).join('')} -
|---|---|---|
| ${esc(entry.key)} | -${typeBadge(entry.type)} | - ${langCodes.map(lc => { - const val = entry.translations[lc] || ''; - const missing = !val; - return `- - | `; - }).join('')} -
+ German source text is edited under Questions. Add other languages here. +
${esc(e.message)}
`; + } +} + +function renderShell() { + document.getElementById('transPageRoot').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. +
+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 ` +