WebOrbiton
v1.0.0.0

StocketBase

278 lines · 13.4 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. 'product' => ['table' => 'products', 'label' => 'product', 'fields' => ['title', 'excerpt', 'blocks', 'seo_title', 'seo_description'], 'back' => 'dashboard.php?view=product-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. ];
  28. ​
  29. if (!isset($sources[$type]) || $entityId <= 0) {
  30. http_response_code(404);
  31. exit('Nothing to translate.');
  32. }
  33. ​
  34. $source = $sources[$type];
  35. $statement = $db->prepare('SELECT * FROM ' . $source['table'] . ' WHERE id = :id LIMIT 1');
  36. $statement->execute(['id' => $entityId]);
  37. $original = $statement->fetch();
  38. ​
  39. if (!$original || ($type === 'product' && $original['deleted_at'] !== null)) {
  40. http_response_code(404);
  41. exit('Nothing to translate.');
  42. }
  43. ​
  44. $isEditor = Auth::hasRoleAtLeast(Auth::ROLE_STORE_OWNER);
  45. $canTranslate = $type === 'product'
  46. ? ($isEditor || (int) $original['created_by'] === (int) $currentUser['id'])
  47. : $isEditor;
  48. ​
  49. if (!$canTranslate) {
  50. http_response_code(403);
  51. exit('You do not have permission to translate this item.');
  52. }
  53. ​
  54. $originalTitle = (string) ($type === 'category' ? $original['name'] : $original['title']);
  55. $originalExcerpt = (string) ($type === 'category' ? ($original['description'] ?? '') : ($original['excerpt'] ?? ''));
  56. $originalBlocks = (string) ($original['content_blocks'] ?? '');
  57. $originalSeoTitle = (string) ($original['seo_title'] ?? '');
  58. $originalSeoDescription = (string) ($original['seo_description'] ?? '');
  59. ​
  60. $languages = Languages::all();
  61. if (empty($languages)) {
  62. $noLanguages = true;
  63. $lang = '';
  64. } else {
  65. $noLanguages = false;
  66. $lang = strtolower(trim((string) ($_REQUEST['lang'] ?? '')));
  67. if (Languages::find($lang) === null) {
  68. $lang = $languages[0]['code'];
  69. }
  70. }
  71. ​
  72. $blocksAreEmpty = static function (string $json): bool {
  73. $decoded = json_decode($json, true);
  74. ​
  75. return !is_array($decoded) || empty($decoded['blocks']);
  76. };
  77. ​
  78. $selfUrl = static fn(string $code, string $extra = ''): string => 'translate.php?type=' . urlencode($type) . '&id=' . $entityId . '&lang=' . urlencode($code) . $extra;
  79. ​
  80. $flashMessage = null;
  81. $flashType = 'success';
  82. ​
  83. if ($noLanguages === false && $_SERVER['REQUEST_METHOD'] === 'POST') {
  84. if (!Csrf::verify($_POST['csrf_token'] ?? null)) {
  85. $flashMessage = 'Security check failed, please try again.';
  86. $flashType = 'error';
  87. } else {
  88. $action = (string) ($_POST['action'] ?? '');
  89. $existing = Languages::translation($type, $entityId, $lang) ?? [];
  90. ​
  91. if ($action === 'delete') {
  92. Languages::deleteTranslation($type, $entityId, $lang);
  93. ActivityLog::record('translation.delete', $type, $entityId, $lang);
  94. header('Location: ' . $selfUrl($lang, '&done=deleted'));
  95. exit;
  96. }
  97. ​
  98. if ($action === 'copy') {
  99. $merged = [
  100. 'title' => trim((string) ($existing['title'] ?? '')) !== '' ? (string) $existing['title'] : $originalTitle,
  101. 'excerpt' => trim((string) ($existing['excerpt'] ?? '')) !== '' ? (string) $existing['excerpt'] : $originalExcerpt,
  102. 'content_blocks' => $blocksAreEmpty((string) ($existing['content_blocks'] ?? '')) ? $originalBlocks : (string) $existing['content_blocks'],
  103. 'seo_title' => trim((string) ($existing['seo_title'] ?? '')) !== '' ? (string) $existing['seo_title'] : $originalSeoTitle,
  104. 'seo_description' => trim((string) ($existing['seo_description'] ?? '')) !== '' ? (string) $existing['seo_description'] : $originalSeoDescription,
  105. ];
  106. if (!in_array('blocks', $source['fields'], true)) {
  107. $merged['content_blocks'] = '';
  108. }
  109. Languages::saveTranslation($type, $entityId, $lang, $merged);
  110. ActivityLog::record('translation.copy', $type, $entityId, $lang);
  111. header('Location: ' . $selfUrl($lang, '&done=copied'));
  112. exit;
  113. }
  114. ​
  115. if ($action === 'save') {
  116. $title = mb_substr(trim((string) ($_POST['title'] ?? '')), 0, 255);
  117. $fields = [
  118. 'title' => $title,
  119. 'excerpt' => in_array('excerpt', $source['fields'], true) ? trim((string) ($_POST['excerpt'] ?? '')) : '',
  120. 'content_blocks' => in_array('blocks', $source['fields'], true) ? BlockEditor::sanitize((string) ($_POST['blocks_json'] ?? '{"blocks":[]}'), $type === 'page') : '',
  121. 'seo_title' => in_array('seo_title', $source['fields'], true) ? mb_substr(trim((string) ($_POST['seo_title'] ?? '')), 0, 255) : '',
  122. 'seo_description' => in_array('seo_description', $source['fields'], true) ? mb_substr(trim((string) ($_POST['seo_description'] ?? '')), 0, 500) : '',
  123. ];
  124. ​
  125. if ($title === '') {
  126. $flashMessage = 'The translated title is required. To remove the translation use "Remove translation".';
  127. $flashType = 'error';
  128. } else {
  129. Languages::saveTranslation($type, $entityId, $lang, $fields);
  130. ActivityLog::record('translation.save', $type, $entityId, $lang);
  131. header('Location: ' . $selfUrl($lang, '&done=saved'));
  132. exit;
  133. }
  134. }
  135. }
  136. }
  137. ​
  138. $done = (string) ($_GET['done'] ?? '');
  139. if ($flashMessage === null && $done !== '') {
  140. $flashMessage = ['saved' => 'Translation saved.', 'copied' => 'Missing fields copied from the original. Existing translations were not changed.', 'deleted' => 'Translation removed.'][$done] ?? null;
  141. }
  142. ​
  143. $translation = $noLanguages ? null : Languages::translation($type, $entityId, $lang);
  144. $currentLanguage = $noLanguages ? null : Languages::find($lang);
  145. $translatedCodes = [];
  146. foreach ($languages as $language) {
  147. $found = Languages::translation($type, $entityId, $language['code']);
  148. if ($found !== null && trim((string) $found['title']) !== '') {
  149. $translatedCodes[$language['code']] = true;
  150. }
  151. }
  152. ​
  153. $formTitle = (string) ($translation['title'] ?? '');
  154. $formExcerpt = (string) ($translation['excerpt'] ?? '');
  155. $formSeoTitle = (string) ($translation['seo_title'] ?? '');
  156. $formSeoDescription = (string) ($translation['seo_description'] ?? '');
  157. $formBlocks = $translation !== null && !$blocksAreEmpty((string) ($translation['content_blocks'] ?? '')) ? (string) $translation['content_blocks'] : '{"blocks":[]}';
  158. $hasBlocks = in_array('blocks', $source['fields'], true);
  159. ​
  160. $dashActivePage = $type === 'category' ? 'categories' : '';
  161. $dashView = $type === 'product' ? 'product-edit' : ($type === 'page' ? 'pages' : 'categories');
  162. $dashPageTitle = 'Translate';
  163. $dashLoadBlockEditor = $hasBlocks && !$noLanguages;
  164. ​
  165. require __DIR__ . '/includes/dash-header.php';
  166. ​
  167. $hint = static function (string $text): string {
  168. $text = trim($text);
  169. if ($text === '') {
  170. return '';
  171. }
  172. if (mb_strlen($text) > 220) {
  173. $text = mb_substr($text, 0, 219) . '…';
  174. }
  175. ​
  176. return '<p style="font-size:12px;color:var(--muted-dim);margin:4px 0 0;">Original: ' . htmlspecialchars($text) . '</p>';
  177. };
  178. ​
  179. ?>
  180. ​
  181. <div class="dash-header-row">
  182. <h1 class="dash-title"><?= Icons::icon('translate', 'icon icon-lg') ?>Translate <?= htmlspecialchars($source['label']) ?></h1>
  183. <a href="<?= htmlspecialchars($source['back'] . $entityId, ENT_QUOTES) ?>" class="dash-btn"><?= Icons::icon('arrow-up', 'icon icon-sm') ?>Back to original</a>
  184. </div>
  185. ​
  186. <?php if ($flashMessage !== null): ?>
  187. <div class="dash-flash dash-flash-<?= htmlspecialchars($flashType) ?>"><?= Icons::icon($flashType === 'error' ? 'x' : 'check', 'icon icon-sm') ?><?= htmlspecialchars($flashMessage) ?></div>
  188. <?php endif; ?>
  189. ​
  190. <?php if ($noLanguages): ?>
  191. <div class="sidebar-box" style="max-width:640px;">
  192. <p style="margin:0 0 12px;">No languages have been added yet.</p>
  193. <?php if ($isEditor): ?>
  194. <a href="languages.php" class="dash-btn dash-btn-primary"><?= Icons::icon('translate', 'icon icon-sm') ?>Add a language</a>
  195. <?php else: ?>
  196. <p style="margin:0;color:var(--muted);">Ask an editor in chief to add one in Languages.</p>
  197. <?php endif; ?>
  198. </div>
  199. <?php else: ?>
  200. ​
  201. <div class="settings-tabs">
  202. <?php foreach ($languages as $language): ?>
  203. <a href="<?= htmlspecialchars($selfUrl($language['code']), ENT_QUOTES) ?>" class="<?= $language['code'] === $lang ? 'active' : '' ?>">
  204. <?= Icons::icon(isset($translatedCodes[$language['code']]) ? 'check' : 'translate', 'icon icon-sm') ?><?= htmlspecialchars($language['name']) ?>
  205. </a>
  206. <?php endforeach; ?>
  207. </div>
  208. ​
  209. <p style="color:var(--muted);margin:0 0 18px;">Original (<?= htmlspecialchars(Languages::defaultName()) ?>): <strong><?= htmlspecialchars($originalTitle) ?></strong>
  210. &middot; Translating to <strong><?= htmlspecialchars((string) $currentLanguage['name']) ?></strong>
  211. <?php if ((int) $currentLanguage['is_active'] !== 1): ?>&middot; <span class="status-pill status-draft">language inactive</span><?php endif; ?></p>
  212. ​
  213. <form method="post" id="article-form" class="article-form">
  214. <?= Csrf::field() ?>
  215. <input type="hidden" name="action" value="save">
  216. <input type="hidden" name="lang" value="<?= htmlspecialchars($lang, ENT_QUOTES) ?>">
  217. <input type="hidden" name="article_id" value="translation-<?= htmlspecialchars($type . '-' . $entityId . '-' . $lang, ENT_QUOTES) ?>">
  218. <input type="hidden" name="blocks_json" id="blocks_json_input">
  219. ​
  220. <div class="article-form-main" style="max-width:900px;">
  221. <label><?= Icons::icon('heading', 'icon icon-sm') ?>Title</label>
  222. <input type="text" name="title" value="<?= htmlspecialchars($formTitle) ?>" maxlength="255" required>
  223. <?= $hint($originalTitle) ?>
  224. ​
  225. <?php if (in_array('excerpt', $source['fields'], true)): ?>
  226. <label><?= Icons::icon('quote', 'icon icon-sm') ?><?= $type === 'category' ? 'Description' : 'Excerpt' ?></label>
  227. <textarea name="excerpt" rows="<?= $type === 'category' ? 3 : 2 ?>"><?= htmlspecialchars($formExcerpt) ?></textarea>
  228. <?= $hint($originalExcerpt) ?>
  229. <?php endif; ?>
  230. ​
  231. <?php if ($hasBlocks): ?>
  232. <label><?= Icons::icon('grid', 'icon icon-sm') ?>Content</label>
  233. <div id="block-editor-root" data-initial-blocks='<?= htmlspecialchars($formBlocks, ENT_QUOTES) ?>'></div>
  234. <?php if (!$blocksAreEmpty($originalBlocks)): ?>
  235. <details class="sidebar-box" style="margin-top:14px;">
  236. <summary style="cursor:pointer;font-weight:600;">Show original content</summary>
  237. <div class="article-content" style="margin-top:12px;"><?= BlockEditor::render($originalBlocks, false) ?></div>
  238. </details>
  239. <?php endif; ?>
  240. <?php endif; ?>
  241. ​
  242. <?php if (in_array('seo_title', $source['fields'], true)): ?>
  243. <label>SEO title</label>
  244. <input type="text" name="seo_title" value="<?= htmlspecialchars($formSeoTitle) ?>" maxlength="255">
  245. <?= $hint($originalSeoTitle) ?>
  246. <label>SEO description</label>
  247. <textarea name="seo_description" rows="2" maxlength="500"><?= htmlspecialchars($formSeoDescription) ?></textarea>
  248. <?= $hint($originalSeoDescription) ?>
  249. <?php endif; ?>
  250. ​
  251. <div class="publish-actions">
  252. <button type="submit" class="dash-btn dash-btn-primary"><?= Icons::icon('check', 'icon icon-sm') ?>Save translation</button>
  253. </div>
  254. </div>
  255. </form>
  256. ​
  257. <div class="publish-actions" style="margin-top:14px;">
  258. <form method="post">
  259. <?= Csrf::field() ?>
  260. <input type="hidden" name="action" value="copy">
  261. <input type="hidden" name="lang" value="<?= htmlspecialchars($lang, ENT_QUOTES) ?>">
  262. <button type="submit" class="dash-btn"><?= Icons::icon('copy', 'icon icon-sm') ?>Copy missing fields from the original</button>
  263. </form>
  264. <?php if ($translation !== null): ?>
  265. <form method="post" onsubmit="return confirm('Remove this translation? The original is not affected.');">
  266. <?= Csrf::field() ?>
  267. <input type="hidden" name="action" value="delete">
  268. <input type="hidden" name="lang" value="<?= htmlspecialchars($lang, ENT_QUOTES) ?>">
  269. <button type="submit" class="dash-btn dash-btn-danger"><?= Icons::icon('trash', 'icon icon-sm') ?>Remove translation</button>
  270. </form>
  271. <?php endif; ?>
  272. </div>
  273. <p style="font-size:12.5px;color:var(--muted);margin:10px 0 0;">Copying only fills fields that are still empty. Anything already translated stays as it is. Fields left empty show the original text to visitors.</p>
  274. ​
  275. <?php endif; ?>
  276. ​
  277. <?php require __DIR__ . '/includes/dash-footer.php'; ?>
  278. ​