WebOrbiton
v1.0.0.9

Publisium

295 lines · 14.9 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. require_once __DIR__ . '/includes/config.php';
  6. require_once __DIR__ . '/includes/database.php';
  7. require_once __DIR__ . '/includes/auth.php';
  8. require_once __DIR__ . '/includes/csrf.php';
  9. require_once __DIR__ . '/includes/activity-log.php';
  10. require_once __DIR__ . '/includes/site-front.php';
  11. require_once __DIR__ . '/includes/language.php';
  12. require_once __DIR__ . '/includes/block-editor.php';
  13. ​
  14. Auth::boot();
  15. Auth::requireLogin();
  16. ​
  17. $currentUser = Auth::user();
  18. $db = Database::site();
  19. ​
  20. $type = (string) ($_GET['type'] ?? '');
  21. $entityId = (int) ($_GET['id'] ?? 0);
  22. ​
  23. $sources = [
  24. 'article' => ['table' => 'articles', 'label' => 'article', 'fields' => ['title', 'excerpt', 'blocks', 'seo_title', 'seo_description'], 'back' => 'dashboard.php?view=article-edit&id='],
  25. 'page' => ['table' => 'pages', 'label' => 'page', 'fields' => ['title', 'blocks'], 'back' => 'dashboard.php?view=pages&page_id='],
  26. 'category' => ['table' => 'categories', 'label' => 'category', 'fields' => ['title', 'excerpt'], 'back' => 'dashboard.php?view=categories&ref='],
  27. 'home' => ['table' => null, 'label' => 'home page', 'fields' => ['blocks'], 'back' => 'dashboard.php?view=pages&home='],
  28. ];
  29. ​
  30. if (!isset($sources[$type]) || $entityId <= 0 || ($type === 'home' && $entityId !== 1)) {
  31. http_response_code(404);
  32. exit('There’s nothing to translate here.');
  33. }
  34. ​
  35. $source = $sources[$type];
  36. $isHome = $type === 'home';
  37. if ($isHome) {
  38. $original = [
  39. 'title' => 'Home page',
  40. 'content_blocks' => BlockEditor::sanitizeHomepage((string) (SiteFront::settings()['homepage_blocks'] ?? '')),
  41. ];
  42. } else {
  43. $statement = $db->prepare('SELECT * FROM ' . $source['table'] . ' WHERE id = :id LIMIT 1');
  44. $statement->execute(['id' => $entityId]);
  45. $original = $statement->fetch();
  46. }
  47. ​
  48. if (!$original || ($type === 'article' && $original['deleted_at'] !== null)) {
  49. http_response_code(404);
  50. exit('There’s nothing to translate here.');
  51. }
  52. ​
  53. $isEditor = Auth::hasRoleAtLeast(Auth::ROLE_EDITOR_IN_CHIEF);
  54. $canTranslate = $type === 'article'
  55. ? ($isEditor || (int) $original['author_id'] === (int) $currentUser['id'])
  56. : $isEditor;
  57. ​
  58. if (!$canTranslate) {
  59. http_response_code(403);
  60. exit('Only editors in chief can translate this.');
  61. }
  62. ​
  63. $originalTitle = (string) ($type === 'category' ? $original['name'] : $original['title']);
  64. $originalExcerpt = (string) ($type === 'category' ? ($original['description'] ?? '') : ($original['excerpt'] ?? ''));
  65. $originalBlocks = (string) ($original['content_blocks'] ?? '');
  66. $originalSeoTitle = (string) ($original['seo_title'] ?? '');
  67. $originalSeoDescription = (string) ($original['seo_description'] ?? '');
  68. ​
  69. $languages = Languages::all();
  70. if (empty($languages)) {
  71. $noLanguages = true;
  72. $lang = '';
  73. } else {
  74. $noLanguages = false;
  75. $lang = strtolower(trim((string) ($_REQUEST['lang'] ?? '')));
  76. if (Languages::find($lang) === null) {
  77. $lang = $languages[0]['code'];
  78. }
  79. }
  80. ​
  81. $blocksAreEmpty = static function (string $json): bool {
  82. $decoded = json_decode($json, true);
  83. ​
  84. return !is_array($decoded) || empty($decoded['blocks']);
  85. };
  86. ​
  87. $selfUrl = static fn(string $code, string $extra = ''): string => 'translate.php?type=' . urlencode($type) . '&id=' . $entityId . '&lang=' . urlencode($code) . $extra;
  88. ​
  89. $flashMessage = null;
  90. $flashType = 'success';
  91. ​
  92. if ($noLanguages === false && $_SERVER['REQUEST_METHOD'] === 'POST') {
  93. if (!Csrf::verify($_POST['csrf_token'] ?? null)) {
  94. $flashMessage = 'Your session expired. Please try again.';
  95. $flashType = 'error';
  96. } else {
  97. $action = (string) ($_POST['action'] ?? '');
  98. $existing = Languages::translation($type, $entityId, $lang) ?? [];
  99. ​
  100. if ($action === 'delete') {
  101. Languages::deleteTranslation($type, $entityId, $lang);
  102. ActivityLog::record('translation.delete', $type, $entityId, $lang);
  103. header('Location: ' . $selfUrl($lang, '&done=deleted'));
  104. exit;
  105. }
  106. ​
  107. if ($action === 'copy') {
  108. $merged = [
  109. 'title' => trim((string) ($existing['title'] ?? '')) !== '' ? (string) $existing['title'] : $originalTitle,
  110. 'excerpt' => trim((string) ($existing['excerpt'] ?? '')) !== '' ? (string) $existing['excerpt'] : $originalExcerpt,
  111. 'content_blocks' => $blocksAreEmpty((string) ($existing['content_blocks'] ?? '')) ? $originalBlocks : (string) $existing['content_blocks'],
  112. 'seo_title' => trim((string) ($existing['seo_title'] ?? '')) !== '' ? (string) $existing['seo_title'] : $originalSeoTitle,
  113. 'seo_description' => trim((string) ($existing['seo_description'] ?? '')) !== '' ? (string) $existing['seo_description'] : $originalSeoDescription,
  114. ];
  115. if (!in_array('blocks', $source['fields'], true)) {
  116. $merged['content_blocks'] = '';
  117. }
  118. Languages::saveTranslation($type, $entityId, $lang, $merged);
  119. ActivityLog::record('translation.copy', $type, $entityId, $lang);
  120. header('Location: ' . $selfUrl($lang, '&done=copied'));
  121. exit;
  122. }
  123. ​
  124. if ($action === 'save') {
  125. $title = $isHome ? $originalTitle : mb_substr(trim((string) ($_POST['title'] ?? '')), 0, 255);
  126. $fields = [
  127. 'title' => $title,
  128. 'excerpt' => in_array('excerpt', $source['fields'], true) ? trim((string) ($_POST['excerpt'] ?? '')) : '',
  129. 'content_blocks' => $isHome
  130. ? BlockEditor::sanitizeHomepage((string) ($_POST['blocks_json'] ?? ''))
  131. : (in_array('blocks', $source['fields'], true) ? BlockEditor::sanitize((string) ($_POST['blocks_json'] ?? '{"blocks":[]}'), $type === 'page') : ''),
  132. 'seo_title' => in_array('seo_title', $source['fields'], true) ? mb_substr(trim((string) ($_POST['seo_title'] ?? '')), 0, 255) : '',
  133. 'seo_description' => in_array('seo_description', $source['fields'], true) ? mb_substr(trim((string) ($_POST['seo_description'] ?? '')), 0, 500) : '',
  134. ];
  135. ​
  136. if ($title === '') {
  137. $flashMessage = 'Add a translated title. If you want to drop this translation, use "Remove translation" instead.';
  138. $flashType = 'error';
  139. } else {
  140. Languages::saveTranslation($type, $entityId, $lang, $fields);
  141. ActivityLog::record('translation.save', $type, $entityId, $lang);
  142. header('Location: ' . $selfUrl($lang, '&done=saved'));
  143. exit;
  144. }
  145. }
  146. }
  147. }
  148. ​
  149. $done = (string) ($_GET['done'] ?? '');
  150. if ($flashMessage === null && $done !== '') {
  151. $flashMessage = ['saved' => 'Translation saved.', 'copied' => 'Empty fields filled in from the original. Nothing you already translated was changed.', 'deleted' => 'Translation removed.'][$done] ?? null;
  152. }
  153. ​
  154. $translation = $noLanguages ? null : Languages::translation($type, $entityId, $lang);
  155. $currentLanguage = $noLanguages ? null : Languages::find($lang);
  156. $translatedCodes = [];
  157. foreach ($languages as $language) {
  158. $found = Languages::translation($type, $entityId, $language['code']);
  159. if ($found !== null && trim((string) $found['title']) !== '') {
  160. $translatedCodes[$language['code']] = true;
  161. }
  162. }
  163. ​
  164. $formTitle = (string) ($translation['title'] ?? '');
  165. $formExcerpt = (string) ($translation['excerpt'] ?? '');
  166. $formSeoTitle = (string) ($translation['seo_title'] ?? '');
  167. $formSeoDescription = (string) ($translation['seo_description'] ?? '');
  168. $formBlocks = $translation !== null && !$blocksAreEmpty((string) ($translation['content_blocks'] ?? '')) ? (string) $translation['content_blocks'] : '{"blocks":[]}';
  169. $hasBlocks = in_array('blocks', $source['fields'], true);
  170. ​
  171. $dashActivePage = $type === 'category' ? 'categories' : '';
  172. $dashView = $type === 'article' ? 'article-edit' : (($type === 'page' || $isHome) ? 'pages' : 'categories');
  173. $dashPageTitle = 'Translate';
  174. $dashLoadBlockEditor = $hasBlocks && !$noLanguages;
  175. ​
  176. require __DIR__ . '/includes/dash-header.php';
  177. ​
  178. $hint = static function (string $text): string {
  179. $text = trim($text);
  180. if ($text === '') {
  181. return '';
  182. }
  183. if (mb_strlen($text) > 220) {
  184. $text = mb_substr($text, 0, 219) . '…';
  185. }
  186. ​
  187. return '<p style="font-size:12px;color:var(--muted-dim);margin:4px 0 0;">Original: ' . htmlspecialchars($text) . '</p>';
  188. };
  189. ​
  190. ?>
  191. ​
  192. <div class="dash-header-row">
  193. <h1 class="dash-title"><?= Icons::icon('translate', 'icon icon-lg') ?>Translate <?= htmlspecialchars($source['label']) ?></h1>
  194. <a href="<?= htmlspecialchars($source['back'] . $entityId, ENT_QUOTES) ?>" class="dash-btn"><?= Icons::icon('arrow-up', 'icon icon-sm') ?>Back to the original</a>
  195. </div>
  196. ​
  197. <?php if ($flashMessage !== null): ?>
  198. <div class="dash-flash dash-flash-<?= htmlspecialchars($flashType) ?>"><?= Icons::icon($flashType === 'error' ? 'x' : 'check', 'icon icon-sm') ?><?= htmlspecialchars($flashMessage) ?></div>
  199. <?php endif; ?>
  200. ​
  201. <?php if ($noLanguages): ?>
  202. <div class="sidebar-box" style="max-width:640px;">
  203. <p style="margin:0 0 12px;">You haven’t added any languages yet.</p>
  204. <?php if ($isEditor): ?>
  205. <a href="languages.php" class="dash-btn dash-btn-primary"><?= Icons::icon('translate', 'icon icon-sm') ?>Add a language</a>
  206. <?php else: ?>
  207. <p style="margin:0;color:var(--muted);">Ask an editor in chief to add one in Languages.</p>
  208. <?php endif; ?>
  209. </div>
  210. <?php else: ?>
  211. ​
  212. <div class="settings-tabs">
  213. <?php foreach ($languages as $language): ?>
  214. <a href="<?= htmlspecialchars($selfUrl($language['code']), ENT_QUOTES) ?>" class="<?= $language['code'] === $lang ? 'active' : '' ?>">
  215. <?= Icons::icon(isset($translatedCodes[$language['code']]) ? 'check' : 'translate', 'icon icon-sm') ?><?= htmlspecialchars($language['name']) ?>
  216. </a>
  217. <?php endforeach; ?>
  218. </div>
  219. ​
  220. <p style="color:var(--muted);margin:0 0 18px;">Original (<?= htmlspecialchars(Languages::defaultName()) ?>): <strong><?= htmlspecialchars($originalTitle) ?></strong>
  221. &middot; Translating to <strong><?= htmlspecialchars((string) $currentLanguage['name']) ?></strong>
  222. <?php if ((int) $currentLanguage['is_active'] !== 1): ?>&middot; <span class="status-pill status-draft">language switched off</span><?php endif; ?></p>
  223. ​
  224. <form method="post" id="article-form" class="article-form">
  225. <?= Csrf::field() ?>
  226. <input type="hidden" name="action" value="save">
  227. <input type="hidden" name="lang" value="<?= htmlspecialchars($lang, ENT_QUOTES) ?>">
  228. <input type="hidden" name="article_id" value="translation-<?= htmlspecialchars($type . '-' . $entityId . '-' . $lang, ENT_QUOTES) ?>">
  229. <input type="hidden" name="blocks_json" id="blocks_json_input">
  230. ​
  231. <div class="article-form-main" style="max-width:900px;">
  232. <?php if ($isHome): ?>
  233. <p style="color:var(--muted);font-size:13px;margin:0 0 14px;">This is how the home page looks in this language. Featured articles and Latest articles are always there. Move them, translate your blocks or add new ones. Until you save a translation, visitors see the original layout.</p>
  234. <?php else: ?>
  235. <label><?= Icons::icon('heading', 'icon icon-sm') ?>Title</label>
  236. <input type="text" name="title" value="<?= htmlspecialchars($formTitle) ?>" maxlength="255" required>
  237. <?= $hint($originalTitle) ?>
  238. <?php endif; ?>
  239. ​
  240. <?php if (in_array('excerpt', $source['fields'], true)): ?>
  241. <label><?= Icons::icon('quote', 'icon icon-sm') ?><?= $type === 'category' ? 'Description' : 'Excerpt' ?></label>
  242. <textarea name="excerpt" rows="<?= $type === 'category' ? 3 : 2 ?>"><?= htmlspecialchars($formExcerpt) ?></textarea>
  243. <?= $hint($originalExcerpt) ?>
  244. <?php endif; ?>
  245. ​
  246. <?php if ($hasBlocks): ?>
  247. <label><?= Icons::icon('articles', 'icon icon-sm') ?>Content</label>
  248. <?php if ($type === 'page') { require_once __DIR__ . '/includes/contact.php'; } ?>
  249. <?php if ($isHome) { require_once __DIR__ . '/includes/contact.php'; } ?>
  250. <div id="block-editor-root"<?= ($type === 'page' || $isHome) ? ' data-contact-form="' . (ContactForm::isEnabled() ? 'enabled' : 'disabled') . '"' : '' ?><?= $isHome ? ' data-system-blocks="home" data-featured-enabled="' . ((SiteFront::settings()['featured_banner_enabled'] ?? '0') === '1' ? '1' : '0') . '"' : '' ?> data-initial-blocks='<?= htmlspecialchars($formBlocks, ENT_QUOTES) ?>'></div>
  251. <?php if (!$blocksAreEmpty($originalBlocks)): ?>
  252. <details class="sidebar-box" style="margin-top:14px;">
  253. <summary style="cursor:pointer;font-weight:600;">Show original content</summary>
  254. <div class="article-content" style="margin-top:12px;"><?= BlockEditor::render($originalBlocks) ?></div>
  255. </details>
  256. <?php endif; ?>
  257. <?php endif; ?>
  258. ​
  259. <?php if (in_array('seo_title', $source['fields'], true)): ?>
  260. <label>Title for search engines</label>
  261. <input type="text" name="seo_title" value="<?= htmlspecialchars($formSeoTitle) ?>" maxlength="255">
  262. <?= $hint($originalSeoTitle) ?>
  263. <label>Description for search engines</label>
  264. <textarea name="seo_description" rows="2" maxlength="500"><?= htmlspecialchars($formSeoDescription) ?></textarea>
  265. <?= $hint($originalSeoDescription) ?>
  266. <?php endif; ?>
  267. ​
  268. <div class="publish-actions">
  269. <button type="submit" class="dash-btn dash-btn-primary"><?= Icons::icon('check', 'icon icon-sm') ?>Save translation</button>
  270. </div>
  271. </div>
  272. </form>
  273. ​
  274. <div class="publish-actions" style="margin-top:14px;">
  275. <form method="post">
  276. <?= Csrf::field() ?>
  277. <input type="hidden" name="action" value="copy">
  278. <input type="hidden" name="lang" value="<?= htmlspecialchars($lang, ENT_QUOTES) ?>">
  279. <button type="submit" class="dash-btn"><?= Icons::icon('copy', 'icon icon-sm') ?>Fill in empty fields from the original</button>
  280. </form>
  281. <?php if ($translation !== null): ?>
  282. <form method="post" onsubmit="return confirm('Remove this translation? The original stays as it is.');">
  283. <?= Csrf::field() ?>
  284. <input type="hidden" name="action" value="delete">
  285. <input type="hidden" name="lang" value="<?= htmlspecialchars($lang, ENT_QUOTES) ?>">
  286. <button type="submit" class="dash-btn dash-btn-danger"><?= Icons::icon('trash', 'icon icon-sm') ?>Remove translation</button>
  287. </form>
  288. <?php endif; ?>
  289. </div>
  290. <p style="font-size:12.5px;color:var(--muted);margin:10px 0 0;">This only fills in empty fields, your translations stay as they are. Visitors see the original text wherever a field is empty.</p>
  291. ​
  292. <?php endif; ?>
  293. ​
  294. <?php require __DIR__ . '/includes/dash-footer.php'; ?>
  295. ​