WebOrbiton
v2.0.0.1

Publisium

413 lines · 20.0 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. ​
  218. <label><input type="checkbox" name="noindex" <?= !empty($article['noindex']) ? 'checked' : '' ?> <?= $readOnlyMeta ? 'disabled' : '' ?>> Hide from search engines (noindex)</label>
  219. </div>
  220. ​
  221. <?php
  222. $previewSettings = SiteFront::settings();
  223. $previewEnabled = ($previewSettings['seo_preview_enabled'] ?? '0') === '1';
  224. $previewUrl = rtrim((string) Config::get('APP_URL', ''), '/') . SiteFront::articleUrl((string) ($article['slug'] ?? 'your-article-slug'));
  225. $previewImage = ($previewSettings['seo_og_article_image_enabled'] ?? '0') === '1' ? 'cover' : 'logo';
  226. ?>
  227. <?php if ($previewEnabled): ?>
  228. <div class="sidebar-box seo-preview" id="seo-preview"
  229. data-site-name="<?= htmlspecialchars((string) $previewSettings['site_name'], ENT_QUOTES) ?>"
  230. data-url="<?= htmlspecialchars($previewUrl, ENT_QUOTES) ?>"
  231. data-logo="<?= htmlspecialchars((string) $previewSettings['site_logo_url'], ENT_QUOTES) ?>"
  232. data-image-source="<?= $previewImage ?>">
  233. <?= Asset::css('assets/mevasearch.css') ?>
  234. <label>MevaSearch preview</label>
  235. <div class="mevasearch-preview">
  236. <div class="result-card">
  237. <div class="result-breadcrumb">
  238. <img class="result-favicon" id="seo-preview-favicon" alt="" style="display:none;">
  239. <span id="seo-preview-url"></span>
  240. </div>
  241. <div class="result-title"><a href="#" onclick="return false;" id="seo-preview-title"></a></div>
  242. <div class="result-desc" id="seo-preview-desc"></div>
  243. </div>
  244. </div>
  245. ​
  246. <label>Social media preview</label>
  247. <div class="seo-preview-social">
  248. <div class="seo-preview-social-image" id="seo-preview-image"></div>
  249. <div class="seo-preview-social-body">
  250. <div class="seo-preview-social-domain" id="seo-preview-domain"></div>
  251. <div class="seo-preview-social-title" id="seo-preview-social-title"></div>
  252. <div class="seo-preview-social-desc" id="seo-preview-social-desc"></div>
  253. </div>
  254. </div>
  255. </div>
  256. ​
  257. <style>
  258. .seo-preview-social { background: #fff; border: 1px solid #dfe1e5; border-radius: 8px; overflow: hidden; margin-bottom: 14px; font-family: Arial, sans-serif; }
  259. .seo-preview-social-image { height: 150px; background: #e9ecef center / cover no-repeat; }
  260. .seo-preview-social-body { padding: 10px 12px; background: #f0f2f5; }
  261. .seo-preview-social-domain { color: #65676b; font-size: 11px; text-transform: uppercase; letter-spacing: .3px; }
  262. .seo-preview-social-title { color: #050505; font-size: 15px; font-weight: 600; line-height: 1.3; margin: 2px 0; }
  263. .seo-preview-social-desc { color: #65676b; font-size: 13px; line-height: 1.4; }
  264. </style>
  265. ​
  266. <script>
  267. (function () {
  268. var root = document.getElementById('seo-preview');
  269. var form = document.getElementById('article-form');
  270. if (!root || !form) { return; }
  271. ​
  272. var siteName = root.getAttribute('data-site-name');
  273. var url = root.getAttribute('data-url');
  274. var logo = root.getAttribute('data-logo');
  275. var imageSource = root.getAttribute('data-image-source');
  276. ​
  277. function field(name) {
  278. var el = form.elements[name];
  279. return el ? String(el.value || '').trim() : '';
  280. }
  281. ​
  282. function clip(text, max) {
  283. return text.length > max ? text.slice(0, max - 1).trimEnd() + '…' : text;
  284. }
  285. ​
  286. function set(id, text) {
  287. document.getElementById(id).textContent = text;
  288. }
  289. ​
  290. function render() {
  291. var title = (field('seo_title') || field('title') || 'Article title') + ' - ' + siteName;
  292. var description = field('seo_description') || field('excerpt');
  293. var cover = field('cover_image_path');
  294. var image = imageSource === 'cover' && cover !== '' ? cover : logo;
  295. var host = url;
  296. ​
  297. try { host = new URL(url, document.baseURI).host; } catch (e) {}
  298. ​
  299. set('seo-preview-url', host);
  300. set('seo-preview-title', clip(title, 70));
  301. set('seo-preview-desc', clip(description || 'No description. Search engines will pick text from the page.', 160));
  302. ​
  303. var favicon = document.getElementById('seo-preview-favicon');
  304. if (logo) {
  305. favicon.src = logo;
  306. favicon.style.display = '';
  307. } else {
  308. favicon.style.display = 'none';
  309. }
  310. set('seo-preview-domain', host);
  311. set('seo-preview-social-title', clip(title, 70));
  312. set('seo-preview-social-desc', clip(description, 110));
  313. ​
  314. var imageEl = document.getElementById('seo-preview-image');
  315. if (image) {
  316. var src = image;
  317. try { src = new URL(image, document.baseURI).href; } catch (e) {}
  318. imageEl.style.backgroundImage = 'url("' + src.replace(/"/g, '%22') + '")';
  319. imageEl.style.display = '';
  320. } else {
  321. imageEl.style.backgroundImage = '';
  322. imageEl.style.display = 'none';
  323. }
  324. }
  325. ​
  326. ['title', 'excerpt', 'seo_title', 'seo_description', 'cover_image_path'].forEach(function (name) {
  327. var el = form.elements[name];
  328. if (el) { el.addEventListener('input', render); }
  329. });
  330. render();
  331. })();
  332. </script>
  333. <?php endif; ?>
  334. ​
  335. <?php if (!$readOnlyMeta): ?>
  336. <div class="sidebar-box">
  337. <label>Schedule for</label>
  338. <input type="datetime-local" name="scheduled_at" value="<?= htmlspecialchars(!empty($article['scheduled_at']) ? date('Y-m-d\TH:i', strtotime((string) $article['scheduled_at'])) : '') ?>">
  339. ​
  340. <div class="publish-actions">
  341. <?php $articleIsLive = $article !== null && in_array($article['status'], ['published', 'scheduled'], true) && Auth::canPublishDirectly(); ?>
  342. <?php if ($articleIsLive): ?>
  343. <button type="submit" name="publish_action" value="update" class="dash-btn dash-btn-primary"><?= Icons::icon('check', 'icon icon-sm') ?>Update</button>
  344. <?php if ($article['status'] === 'scheduled'): ?>
  345. <button type="submit" name="publish_action" value="publish_now" class="dash-btn"><?= Icons::icon('check', 'icon icon-sm') ?>Publish now</button>
  346. <?php endif; ?>
  347. <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>
  348. <?php else: ?>
  349. <button type="submit" name="publish_action" value="save_draft" class="dash-btn"><?= Icons::icon('pages', 'icon icon-sm') ?>Save draft</button>
  350. <?php endif; ?>
  351. <?php if ($articleIsLive): ?>
  352. <?php elseif (!Auth::canPublishDirectly()): ?>
  353. <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>
  354. <?php else: ?>
  355. <button type="submit" name="publish_action" value="publish_now" class="dash-btn dash-btn-primary"><?= Icons::icon('check', 'icon icon-sm') ?>Publish now</button>
  356. <button type="submit" name="publish_action" value="schedule" class="dash-btn"><?= Icons::icon('stats', 'icon icon-sm') ?>Schedule</button>
  357. <?php endif; ?>
  358. </div>
  359. </div>
  360. <?php else: ?>
  361. <button type="submit" class="dash-btn dash-btn-primary"><?= Icons::icon('check', 'icon icon-sm') ?>Save corrections</button>
  362. <?php endif; ?>
  363. ​
  364. <?php $articleVersions = ($article && !$readOnlyMeta) ? ArticleVersions::recent((int) $article['id']) : []; ?>
  365. <?php if (!empty($articleVersions)): ?>
  366. <div class="sidebar-box">
  367. <label>Versions</label>
  368. <ul style="list-style:none;padding:0;margin:0;display:grid;gap:10px;font-size:13px;">
  369. <?php foreach ($articleVersions as $version): ?>
  370. <li style="display:flex;justify-content:space-between;align-items:center;gap:8px;">
  371. <span><?= htmlspecialchars($version['created_at']) ?><br><span style="color:var(--muted);"><?= htmlspecialchars((string) $version['saved_by_name']) ?> &middot; <?= htmlspecialchars($version['note']) ?></span></span>
  372. <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>
  373. </li>
  374. <?php endforeach; ?>
  375. </ul>
  376. </div>
  377. <?php endif; ?>
  378. ​
  379. <?php if ($article && $article['status'] === 'rejected' && !empty($article['rejection_reason'])): ?>
  380. <div class="dash-flash dash-flash-error">Sent back with a note: <?= htmlspecialchars($article['rejection_reason']) ?></div>
  381. <?php endif; ?>
  382. ​
  383. </div>
  384. </div>
  385. </form>
  386. <script>
  387. (function () {
  388. // Enter in a one-line field would submit with the first button ("Save draft"), so it does nothing here.
  389. var form = document.getElementById('article-form');
  390. if (!form) { return; }
  391. form.addEventListener('keydown', function (event) {
  392. var field = event.target;
  393. if (event.key === 'Enter' && field.tagName === 'INPUT' && ['text', 'search', 'url', 'number', 'datetime-local', 'email'].indexOf(field.type) !== -1) {
  394. event.preventDefault();
  395. }
  396. });
  397. })();
  398. </script>
  399. ​
  400. <?php if (!empty($articleVersions)): ?>
  401. <form method="post" id="version-restore-form">
  402. <?= Csrf::field() ?>
  403. <input type="hidden" name="action" value="restore_version">
  404. <input type="hidden" name="article_id" value="<?= (int) $article['id'] ?>">
  405. </form>
  406. <?php endif; ?>
  407. ​
  408. <script>
  409. document.getElementById('access_type_select').addEventListener('change', function () {
  410. document.getElementById('access_note').style.display = this.value === 'paid' ? '' : 'none';
  411. });
  412. </script>
  413. ​