WebOrbiton
v1.0.0.8

Publisium

411 lines · 19.8 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. $db = Database::site();
  6. ​
  7. $articleId = (int) ($_GET['id'] ?? 0) ?: null;
  8. $article = null;
  9. ​
  10. if ($articleId !== null) {
  11. $statement = $db->prepare('SELECT * FROM articles WHERE id = :id LIMIT 1');
  12. $statement->execute(['id' => $articleId]);
  13. $article = $statement->fetch();
  14. ​
  15. if (!$article) {
  16. echo '<p>We couldn’t find this article. It may have been deleted.</p>';
  17. return;
  18. }
  19. ​
  20. $isOwner = (int) $article['author_id'] === (int) $currentUser['id'];
  21. $canEditAny = Auth::hasRoleAtLeast(Auth::ROLE_EDITOR_IN_CHIEF);
  22. $canProofread = Auth::role() === Auth::ROLE_PROOFREADER;
  23. ​
  24. if (!$isOwner && !$canEditAny && !$canProofread) {
  25. echo '<p>Only the author and editors can open this article.</p>';
  26. return;
  27. }
  28. ​
  29. if ($article['deleted_at'] !== null) {
  30. echo '<div class="dash-flash dash-flash-error">This article is in the trash. Restore it from the Trash to keep editing.</div>';
  31. if ($isOwner || $canEditAny) {
  32. echo '<form method="post">' . Csrf::field()
  33. . '<input type="hidden" name="action" value="restore_article">'
  34. . '<input type="hidden" name="article_id" value="' . (int) $article['id'] . '">'
  35. . '<button type="submit" class="dash-btn dash-btn-primary">Restore article</button></form>';
  36. }
  37. return;
  38. }
  39. }
  40. ​
  41. $categories = $db->query('SELECT id, name FROM categories ORDER BY name ASC')->fetchAll();
  42. $blocksJson = $article['content_blocks'] ?? '{"blocks":[]}';
  43. $readOnlyMeta = $article !== null && Auth::role() === Auth::ROLE_PROOFREADER && (int) $article['author_id'] !== (int) $currentUser['id'];
  44. ​
  45. ?>
  46. <div class="dash-header-row">
  47. <h1 class="dash-title"><?= Icons::icon('edit', 'icon icon-lg') ?><?= $article ? 'Edit article' : 'New article' ?></h1>
  48. <?php if ($article): ?>
  49. <?php if ($article['status'] === 'published' && !empty($article['slug'])): ?>
  50. <a href="<?= htmlspecialchars(SiteFront::articleUrl($article['slug'])) ?>" class="dash-btn-small" target="_blank" rel="noopener noreferrer"><?= Icons::icon('eye', 'icon icon-sm') ?>View</a>
  51. <?php else: ?>
  52. <a href="article.php?preview=<?= (int) $article['id'] ?>" class="dash-btn-small" target="_blank" rel="noopener noreferrer"><?= Icons::icon('eye', 'icon icon-sm') ?>Preview</a>
  53. <?php endif; ?>
  54. <?php endif; ?>
  55. </div>
  56. ​
  57. <form method="post" id="article-form" class="article-form">
  58. <?= Csrf::field() ?>
  59. <input type="hidden" name="action" value="save_article">
  60. <?php if ($article): ?>
  61. <input type="hidden" name="article_id" value="<?= (int) $article['id'] ?>">
  62. <?php endif; ?>
  63. <input type="hidden" name="blocks_json" id="blocks_json_input">
  64. ​
  65. <div class="article-form-grid">
  66. <div class="article-form-main">
  67. ​
  68. <label><?= Icons::icon('heading', 'icon icon-sm') ?>Title</label>
  69. <input type="text" name="title" value="<?= htmlspecialchars($article['title'] ?? '') ?>" <?= $readOnlyMeta ? 'readonly' : '' ?> required>
  70. ​
  71. <label><?= Icons::icon('quote', 'icon icon-sm') ?>Short summary</label>
  72. <textarea name="excerpt" rows="2" <?= $readOnlyMeta ? 'readonly' : '' ?>><?= htmlspecialchars($article['excerpt'] ?? '') ?></textarea>
  73. ​
  74. <label><?= Icons::icon('articles', 'icon icon-sm') ?>Content</label>
  75. <div id="block-editor-root" data-initial-blocks='<?= htmlspecialchars($blocksJson, ENT_QUOTES) ?>'></div>
  76. ​
  77. </div>
  78. ​
  79. <div class="article-form-sidebar">
  80. ​
  81. <div class="sidebar-box">
  82. <label>Category</label>
  83. <select name="category_id" <?= $readOnlyMeta ? 'disabled' : '' ?>>
  84. <option value="">No category</option>
  85. <?php foreach ($categories as $category): ?>
  86. <option value="<?= (int) $category['id'] ?>" <?= (int) ($article['category_id'] ?? 0) === (int) $category['id'] ? 'selected' : '' ?>>
  87. <?= htmlspecialchars($category['name']) ?>
  88. </option>
  89. <?php endforeach; ?>
  90. </select>
  91. ​
  92. <label>Access</label>
  93. <select name="access_type" id="access_type_select" <?= $readOnlyMeta ? 'disabled' : '' ?>>
  94. <option value="free" <?= ($article['access_type'] ?? 'free') === 'free' ? 'selected' : '' ?>>Free</option>
  95. <option value="paid" <?= ($article['access_type'] ?? '') === 'paid' ? 'selected' : '' ?>>Subscribers only</option>
  96. </select>
  97. ​
  98. <p id="access_note" style="font-size:12px;color:var(--muted);margin:6px 0 0;<?= ($article['access_type'] ?? '') === 'paid' ? '' : 'display:none;' ?>">Readers need an active subscription to read this article. Set the plan and price in Settings.</p>
  99. </div>
  100. ​
  101. <?php if (ArticleTags::isEnabled()): ?>
  102. <div class="sidebar-box">
  103. <label>Tags (separate with commas, up to <?= ArticleTags::MAX_TAGS ?>)</label>
  104. <input type="text" name="tags" maxlength="700" value="<?= htmlspecialchars($article ? ArticleTags::csvFor((int) $article['id']) : '') ?>" placeholder="politics, economy" <?= $readOnlyMeta ? 'readonly' : '' ?>>
  105. </div>
  106. <?php endif; ?>
  107. ​
  108. <?php if (ReadAloud::isEnabled() && !$readOnlyMeta): ?>
  109. <?php
  110. $readAloudOn = (int) ($article['read_aloud'] ?? 0) === 1;
  111. $readAloudSource = ($article['read_aloud_source'] ?? 'auto') === 'upload' ? 'upload' : 'auto';
  112. $readAloudAudio = (string) ($article['read_aloud_audio'] ?? '');
  113. $readAloudStatus = (string) ($article['read_aloud_status'] ?? '');
  114. $readAloudError = (string) ($article['read_aloud_error'] ?? '');
  115. $readAloudProviderReady = ReadAloud::providerReady();
  116. $readAloudAudioValid = $readAloudAudio !== '' && ReadAloud::isValidPath($readAloudAudio);
  117. ?>
  118. <div class="sidebar-box" id="read-aloud-box" data-csrf="<?= htmlspecialchars(Csrf::token(), ENT_QUOTES) ?>">
  119. <input type="hidden" name="read_aloud_present" value="1">
  120. <label style="margin-top:0;"><input type="checkbox" name="read_aloud" id="read-aloud-toggle" <?= $readAloudOn ? 'checked' : '' ?>> Read aloud (audio version)</label>
  121. ​
  122. <div id="read-aloud-options" style="<?= $readAloudOn ? '' : 'display:none;' ?>">
  123. <label style="font-weight:500;"><input type="radio" name="read_aloud_source" value="auto" <?= $readAloudSource === 'auto' ? 'checked' : '' ?>> Generate it automatically when published<?= $readAloudProviderReady ? '' : ' (no text-to-speech provider is set up in AI)' ?></label>
  124. <label style="font-weight:500;"><input type="radio" name="read_aloud_source" value="upload" <?= $readAloudSource === 'upload' ? 'checked' : '' ?>> Use my own MP3 / WAV file</label>
  125. ​
  126. <div id="read-aloud-upload" style="<?= $readAloudSource === 'upload' ? '' : 'display:none;' ?>">
  127. <input type="hidden" name="read_aloud_audio_path" id="read-aloud-path" value="<?= $readAloudSource === 'upload' && $readAloudAudioValid ? htmlspecialchars($readAloudAudio) : '' ?>">
  128. <input type="file" id="read-aloud-file" accept=".mp3,.wav,audio/mpeg,audio/wav" style="margin-top:8px;">
  129. <div id="read-aloud-upload-status" style="font-size:12px;color:var(--muted);margin-top:6px;"></div>
  130. </div>
  131. ​
  132. <div id="read-aloud-current" style="margin-top:10px;">
  133. <?php if ($readAloudAudioValid): ?>
  134. <audio controls preload="none" style="width:100%;" src="<?= htmlspecialchars(ReadAloud::publicUrl($readAloudAudio)) ?>"></audio>
  135. <label><input type="checkbox" name="read_aloud_remove_audio"> Remove the current audio</label>
  136. <?php elseif ($readAloudStatus === 'generating'): ?>
  137. <p style="font-size:12.5px;color:var(--muted);margin:0;">The audio is being generated. Reload this page in a moment.</p>
  138. <?php elseif ($readAloudStatus === 'error'): ?>
  139. <p style="font-size:12.5px;color:var(--danger);margin:0;"><?= htmlspecialchars($readAloudError !== '' ? $readAloudError : 'We couldn’t create the audio.') ?> Save the article again to retry.</p>
  140. <?php else: ?>
  141. <p style="font-size:12.5px;color:var(--muted);margin:0;">No audio yet. It is generated when the article is published, or use your own file.</p>
  142. <?php endif; ?>
  143. </div>
  144. </div>
  145. </div>
  146. <script>
  147. (function () {
  148. var box = document.getElementById('read-aloud-box');
  149. if (!box) { return; }
  150. ​
  151. var toggle = document.getElementById('read-aloud-toggle');
  152. var options = document.getElementById('read-aloud-options');
  153. var upload = document.getElementById('read-aloud-upload');
  154. var file = document.getElementById('read-aloud-file');
  155. var path = document.getElementById('read-aloud-path');
  156. var status = document.getElementById('read-aloud-upload-status');
  157. ​
  158. toggle.addEventListener('change', function () {
  159. options.style.display = toggle.checked ? '' : 'none';
  160. });
  161. ​
  162. box.querySelectorAll('input[name="read_aloud_source"]').forEach(function (radio) {
  163. radio.addEventListener('change', function () {
  164. upload.style.display = radio.value === 'upload' && radio.checked ? '' : 'none';
  165. });
  166. });
  167. ​
  168. file.addEventListener('change', function () {
  169. if (!file.files.length) { return; }
  170. ​
  171. var data = new FormData();
  172. data.append('file', file.files[0]);
  173. data.append('kind', 'article');
  174. data.append('csrf_token', box.getAttribute('data-csrf'));
  175. status.textContent = 'Uploading…';
  176. ​
  177. fetch('audio-upload.php', { method: 'POST', body: data, credentials: 'same-origin' })
  178. .then(function (response) { return response.json(); })
  179. .then(function (result) {
  180. if (!result.success) { throw new Error(result.error || 'Upload failed.'); }
  181. path.value = result.path;
  182. status.textContent = 'Uploaded. Save the article to apply it.';
  183. })
  184. .catch(function (error) {
  185. status.textContent = error.message;
  186. file.value = '';
  187. });
  188. });
  189. })();
  190. </script>
  191. <?php endif; ?>
  192. ​
  193. <?php if ($article && !$readOnlyMeta && Languages::enabled() && !empty(Languages::all())): ?>
  194. <?php $translatedArticleLanguages = []; foreach (Languages::all() as $translationLanguage) { $found = Languages::translation('article', (int) $article['id'], $translationLanguage['code']); $translatedArticleLanguages[$translationLanguage['code']] = $found !== null && trim((string) $found['title']) !== ''; } ?>
  195. <div class="sidebar-box">
  196. <label>Translations</label>
  197. <ul style="list-style:none;padding:0;margin:0;display:grid;gap:8px;font-size:13px;">
  198. <?php foreach (Languages::all() as $translationLanguage): ?>
  199. <li style="display:flex;justify-content:space-between;align-items:center;gap:8px;">
  200. <span><?= htmlspecialchars($translationLanguage['name']) ?> <span style="color:var(--muted);"><?= $translatedArticleLanguages[$translationLanguage['code']] ? 'translated' : 'not translated' ?></span></span>
  201. <a href="translate.php?type=article&id=<?= (int) $article['id'] ?>&lang=<?= urlencode($translationLanguage['code']) ?>" class="dash-btn-small"><?= Icons::icon('translate', 'icon icon-sm') ?><?= $translatedArticleLanguages[$translationLanguage['code']] ? 'Edit' : 'Translate' ?></a>
  202. </li>
  203. <?php endforeach; ?>
  204. </ul>
  205. </div>
  206. <?php endif; ?>
  207. ​
  208. <div class="sidebar-box">
  209. <label>Cover image link</label>
  210. <input type="text" name="cover_image_path" data-image-upload value="<?= htmlspecialchars($article['cover_image_path'] ?? '') ?>" <?= $readOnlyMeta ? 'readonly' : '' ?>>
  211. ​
  212. <label>Title for search engines</label>
  213. <input type="text" name="seo_title" value="<?= htmlspecialchars($article['seo_title'] ?? '') ?>" <?= $readOnlyMeta ? 'readonly' : '' ?>>
  214. ​
  215. <label>Description for search engines</label>
  216. <textarea name="seo_description" rows="2" <?= $readOnlyMeta ? 'readonly' : '' ?>><?= htmlspecialchars($article['seo_description'] ?? '') ?></textarea>
  217. </div>
  218. ​
  219. <?php
  220. $previewSettings = SiteFront::settings();
  221. $previewEnabled = ($previewSettings['seo_preview_enabled'] ?? '0') === '1';
  222. $previewUrl = rtrim((string) Config::get('APP_URL', ''), '/') . SiteFront::articleUrl((string) ($article['slug'] ?? 'your-article-slug'));
  223. $previewImage = ($previewSettings['seo_og_article_image_enabled'] ?? '0') === '1' ? 'cover' : 'logo';
  224. ?>
  225. <?php if ($previewEnabled): ?>
  226. <div class="sidebar-box seo-preview" id="seo-preview"
  227. data-site-name="<?= htmlspecialchars((string) $previewSettings['site_name'], ENT_QUOTES) ?>"
  228. data-url="<?= htmlspecialchars($previewUrl, ENT_QUOTES) ?>"
  229. data-logo="<?= htmlspecialchars((string) $previewSettings['site_logo_url'], ENT_QUOTES) ?>"
  230. data-image-source="<?= $previewImage ?>">
  231. <?= Asset::css('assets/mevasearch.css') ?>
  232. <label>MevaSearch preview</label>
  233. <div class="mevasearch-preview">
  234. <div class="result-card">
  235. <div class="result-breadcrumb">
  236. <img class="result-favicon" id="seo-preview-favicon" alt="" style="display:none;">
  237. <span id="seo-preview-url"></span>
  238. </div>
  239. <div class="result-title"><a href="#" onclick="return false;" id="seo-preview-title"></a></div>
  240. <div class="result-desc" id="seo-preview-desc"></div>
  241. </div>
  242. </div>
  243. ​
  244. <label>Social media preview</label>
  245. <div class="seo-preview-social">
  246. <div class="seo-preview-social-image" id="seo-preview-image"></div>
  247. <div class="seo-preview-social-body">
  248. <div class="seo-preview-social-domain" id="seo-preview-domain"></div>
  249. <div class="seo-preview-social-title" id="seo-preview-social-title"></div>
  250. <div class="seo-preview-social-desc" id="seo-preview-social-desc"></div>
  251. </div>
  252. </div>
  253. </div>
  254. ​
  255. <style>
  256. .seo-preview-social { background: #fff; border: 1px solid #dfe1e5; border-radius: 8px; overflow: hidden; margin-bottom: 14px; font-family: Arial, sans-serif; }
  257. .seo-preview-social-image { height: 150px; background: #e9ecef center / cover no-repeat; }
  258. .seo-preview-social-body { padding: 10px 12px; background: #f0f2f5; }
  259. .seo-preview-social-domain { color: #65676b; font-size: 11px; text-transform: uppercase; letter-spacing: .3px; }
  260. .seo-preview-social-title { color: #050505; font-size: 15px; font-weight: 600; line-height: 1.3; margin: 2px 0; }
  261. .seo-preview-social-desc { color: #65676b; font-size: 13px; line-height: 1.4; }
  262. </style>
  263. ​
  264. <script>
  265. (function () {
  266. var root = document.getElementById('seo-preview');
  267. var form = document.getElementById('article-form');
  268. if (!root || !form) { return; }
  269. ​
  270. var siteName = root.getAttribute('data-site-name');
  271. var url = root.getAttribute('data-url');
  272. var logo = root.getAttribute('data-logo');
  273. var imageSource = root.getAttribute('data-image-source');
  274. ​
  275. function field(name) {
  276. var el = form.elements[name];
  277. return el ? String(el.value || '').trim() : '';
  278. }
  279. ​
  280. function clip(text, max) {
  281. return text.length > max ? text.slice(0, max - 1).trimEnd() + '…' : text;
  282. }
  283. ​
  284. function set(id, text) {
  285. document.getElementById(id).textContent = text;
  286. }
  287. ​
  288. function render() {
  289. var title = (field('seo_title') || field('title') || 'Article title') + ' - ' + siteName;
  290. var description = field('seo_description') || field('excerpt');
  291. var cover = field('cover_image_path');
  292. var image = imageSource === 'cover' && cover !== '' ? cover : logo;
  293. var host = url;
  294. ​
  295. try { host = new URL(url, document.baseURI).host; } catch (e) {}
  296. ​
  297. set('seo-preview-url', host);
  298. set('seo-preview-title', clip(title, 70));
  299. set('seo-preview-desc', clip(description || 'No description. Search engines will pick text from the page.', 160));
  300. ​
  301. var favicon = document.getElementById('seo-preview-favicon');
  302. if (logo) {
  303. favicon.src = logo;
  304. favicon.style.display = '';
  305. } else {
  306. favicon.style.display = 'none';
  307. }
  308. set('seo-preview-domain', host);
  309. set('seo-preview-social-title', clip(title, 70));
  310. set('seo-preview-social-desc', clip(description, 110));
  311. ​
  312. var imageEl = document.getElementById('seo-preview-image');
  313. if (image) {
  314. var src = image;
  315. try { src = new URL(image, document.baseURI).href; } catch (e) {}
  316. imageEl.style.backgroundImage = 'url("' + src.replace(/"/g, '%22') + '")';
  317. imageEl.style.display = '';
  318. } else {
  319. imageEl.style.backgroundImage = '';
  320. imageEl.style.display = 'none';
  321. }
  322. }
  323. ​
  324. ['title', 'excerpt', 'seo_title', 'seo_description', 'cover_image_path'].forEach(function (name) {
  325. var el = form.elements[name];
  326. if (el) { el.addEventListener('input', render); }
  327. });
  328. render();
  329. })();
  330. </script>
  331. <?php endif; ?>
  332. ​
  333. <?php if (!$readOnlyMeta): ?>
  334. <div class="sidebar-box">
  335. <label>Schedule for</label>
  336. <input type="datetime-local" name="scheduled_at" value="<?= htmlspecialchars(!empty($article['scheduled_at']) ? date('Y-m-d\TH:i', strtotime((string) $article['scheduled_at'])) : '') ?>">
  337. ​
  338. <div class="publish-actions">
  339. <?php $articleIsLive = $article !== null && in_array($article['status'], ['published', 'scheduled'], true) && Auth::canPublishDirectly(); ?>
  340. <?php if ($articleIsLive): ?>
  341. <button type="submit" name="publish_action" value="update" class="dash-btn dash-btn-primary"><?= Icons::icon('check', 'icon icon-sm') ?>Update</button>
  342. <?php if ($article['status'] === 'scheduled'): ?>
  343. <button type="submit" name="publish_action" value="publish_now" class="dash-btn"><?= Icons::icon('check', 'icon icon-sm') ?>Publish now</button>
  344. <?php endif; ?>
  345. <button type="submit" name="publish_action" value="save_draft" class="dash-btn" onclick="return confirm('Move this article to drafts? It disappears from the site until you publish it again.');"><?= Icons::icon('pages', 'icon icon-sm') ?>Move to drafts</button>
  346. <?php else: ?>
  347. <button type="submit" name="publish_action" value="save_draft" class="dash-btn"><?= Icons::icon('pages', 'icon icon-sm') ?>Save draft</button>
  348. <?php endif; ?>
  349. <?php if ($articleIsLive): ?>
  350. <?php elseif (!Auth::canPublishDirectly()): ?>
  351. <button type="submit" name="publish_action" value="submit_for_review" class="dash-btn dash-btn-primary"><?= Icons::icon('check', 'icon icon-sm') ?>Send for review</button>
  352. <?php else: ?>
  353. <button type="submit" name="publish_action" value="publish_now" class="dash-btn dash-btn-primary"><?= Icons::icon('check', 'icon icon-sm') ?>Publish now</button>
  354. <button type="submit" name="publish_action" value="schedule" class="dash-btn"><?= Icons::icon('stats', 'icon icon-sm') ?>Schedule</button>
  355. <?php endif; ?>
  356. </div>
  357. </div>
  358. <?php else: ?>
  359. <button type="submit" class="dash-btn dash-btn-primary"><?= Icons::icon('check', 'icon icon-sm') ?>Save corrections</button>
  360. <?php endif; ?>
  361. ​
  362. <?php $articleVersions = ($article && !$readOnlyMeta) ? ArticleVersions::recent((int) $article['id']) : []; ?>
  363. <?php if (!empty($articleVersions)): ?>
  364. <div class="sidebar-box">
  365. <label>Versions</label>
  366. <ul style="list-style:none;padding:0;margin:0;display:grid;gap:10px;font-size:13px;">
  367. <?php foreach ($articleVersions as $version): ?>
  368. <li style="display:flex;justify-content:space-between;align-items:center;gap:8px;">
  369. <span><?= htmlspecialchars($version['created_at']) ?><br><span style="color:var(--muted);"><?= htmlspecialchars((string) $version['saved_by_name']) ?> &middot; <?= htmlspecialchars($version['note']) ?></span></span>
  370. <button type="submit" form="version-restore-form" name="version_id" value="<?= (int) $version['id'] ?>" class="dash-btn-small" onclick="return confirm('Go back to this version? Your current text is saved as a version first, just in case.');"><?= Icons::icon('refresh', 'icon icon-sm') ?>Restore</button>
  371. </li>
  372. <?php endforeach; ?>
  373. </ul>
  374. </div>
  375. <?php endif; ?>
  376. ​
  377. <?php if ($article && $article['status'] === 'rejected' && !empty($article['rejection_reason'])): ?>
  378. <div class="dash-flash dash-flash-error">Sent back with a note: <?= htmlspecialchars($article['rejection_reason']) ?></div>
  379. <?php endif; ?>
  380. ​
  381. </div>
  382. </div>
  383. </form>
  384. <script>
  385. (function () {
  386. // Enter in a one-line field would submit with the first button ("Save draft"), so it does nothing here.
  387. var form = document.getElementById('article-form');
  388. if (!form) { return; }
  389. form.addEventListener('keydown', function (event) {
  390. var field = event.target;
  391. if (event.key === 'Enter' && field.tagName === 'INPUT' && ['text', 'search', 'url', 'number', 'datetime-local', 'email'].indexOf(field.type) !== -1) {
  392. event.preventDefault();
  393. }
  394. });
  395. })();
  396. </script>
  397. ​
  398. <?php if (!empty($articleVersions)): ?>
  399. <form method="post" id="version-restore-form">
  400. <?= Csrf::field() ?>
  401. <input type="hidden" name="action" value="restore_version">
  402. <input type="hidden" name="article_id" value="<?= (int) $article['id'] ?>">
  403. </form>
  404. <?php endif; ?>
  405. ​
  406. <script>
  407. document.getElementById('access_type_select').addEventListener('change', function () {
  408. document.getElementById('access_note').style.display = this.value === 'paid' ? '' : 'none';
  409. });
  410. </script>
  411. ​