WebOrbiton
v1.0.0.1

StocketBase

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