v1.0.0.9
Publisium
- <?php
-
- declare(strict_types=1);
-
- $db = Database::site();
-
- $articleId = (int) ($_GET['id'] ?? 0) ?: null;
- $article = null;
-
- if ($articleId !== null) {
- $statement = $db->prepare('SELECT * FROM articles WHERE id = :id LIMIT 1');
- $statement->execute(['id' => $articleId]);
- $article = $statement->fetch();
-
- if (!$article) {
- echo '<p>We couldn’t find this article. It may have been deleted.</p>';
- return;
- }
-
- $isOwner = (int) $article['author_id'] === (int) $currentUser['id'];
- $canEditAny = Auth::hasRoleAtLeast(Auth::ROLE_EDITOR_IN_CHIEF);
- $canProofread = Auth::role() === Auth::ROLE_PROOFREADER;
-
- if (!$isOwner && !$canEditAny && !$canProofread) {
- echo '<p>Only the author and editors can open this article.</p>';
- return;
- }
-
- if ($article['deleted_at'] !== null) {
- echo '<div class="dash-flash dash-flash-error">This article is in the trash. Restore it from the Trash to keep editing.</div>';
- if ($isOwner || $canEditAny) {
- echo '<form method="post">' . Csrf::field()
- . '<input type="hidden" name="action" value="restore_article">'
- . '<input type="hidden" name="article_id" value="' . (int) $article['id'] . '">'
- . '<button type="submit" class="dash-btn dash-btn-primary">Restore article</button></form>';
- }
- return;
- }
- }
-
- $categories = $db->query('SELECT id, name FROM categories ORDER BY name ASC')->fetchAll();
- $blocksJson = $article['content_blocks'] ?? '{"blocks":[]}';
- $readOnlyMeta = $article !== null && Auth::role() === Auth::ROLE_PROOFREADER && (int) $article['author_id'] !== (int) $currentUser['id'];
-
- ?>
- <div class="dash-header-row">
- <h1 class="dash-title"><?= Icons::icon('edit', 'icon icon-lg') ?><?= $article ? 'Edit article' : 'New article' ?></h1>
- <?php if ($article): ?>
- <?php if ($article['status'] === 'published' && !empty($article['slug'])): ?>
- <a href="<?= htmlspecialchars(SiteFront::articleUrl($article['slug'])) ?>" class="dash-btn-small" target="_blank" rel="noopener noreferrer"><?= Icons::icon('eye', 'icon icon-sm') ?>View</a>
- <?php else: ?>
- <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>
- <?php endif; ?>
- <?php endif; ?>
- </div>
-
- <form method="post" id="article-form" class="article-form">
- <?= Csrf::field() ?>
- <input type="hidden" name="action" value="save_article">
- <?php if ($article): ?>
- <input type="hidden" name="article_id" value="<?= (int) $article['id'] ?>">
- <?php endif; ?>
- <input type="hidden" name="blocks_json" id="blocks_json_input">
-
- <div class="article-form-grid">
- <div class="article-form-main">
-
- <label><?= Icons::icon('heading', 'icon icon-sm') ?>Title</label>
- <input type="text" name="title" value="<?= htmlspecialchars($article['title'] ?? '') ?>" <?= $readOnlyMeta ? 'readonly' : '' ?> required>
-
- <label><?= Icons::icon('quote', 'icon icon-sm') ?>Short summary</label>
- <textarea name="excerpt" rows="2" <?= $readOnlyMeta ? 'readonly' : '' ?>><?= htmlspecialchars($article['excerpt'] ?? '') ?></textarea>
-
- <label><?= Icons::icon('articles', 'icon icon-sm') ?>Content</label>
- <div id="block-editor-root" data-initial-blocks='<?= htmlspecialchars($blocksJson, ENT_QUOTES) ?>'></div>
-
- </div>
-
- <div class="article-form-sidebar">
-
- <div class="sidebar-box">
- <label>Category</label>
- <select name="category_id" <?= $readOnlyMeta ? 'disabled' : '' ?>>
- <option value="">No category</option>
- <?php foreach ($categories as $category): ?>
- <option value="<?= (int) $category['id'] ?>" <?= (int) ($article['category_id'] ?? 0) === (int) $category['id'] ? 'selected' : '' ?>>
- <?= htmlspecialchars($category['name']) ?>
- </option>
- <?php endforeach; ?>
- </select>
-
- <label>Access</label>
- <select name="access_type" id="access_type_select" <?= $readOnlyMeta ? 'disabled' : '' ?>>
- <option value="free" <?= ($article['access_type'] ?? 'free') === 'free' ? 'selected' : '' ?>>Free</option>
- <option value="paid" <?= ($article['access_type'] ?? '') === 'paid' ? 'selected' : '' ?>>Subscribers only</option>
- </select>
-
- <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>
- </div>
-
- <?php if (ArticleTags::isEnabled()): ?>
- <div class="sidebar-box">
- <label>Tags (separate with commas, up to <?= ArticleTags::MAX_TAGS ?>)</label>
- <input type="text" name="tags" maxlength="700" value="<?= htmlspecialchars($article ? ArticleTags::csvFor((int) $article['id']) : '') ?>" placeholder="politics, economy" <?= $readOnlyMeta ? 'readonly' : '' ?>>
- </div>
- <?php endif; ?>
-
- <?php if (ReadAloud::isEnabled() && !$readOnlyMeta): ?>
- <?php
- $readAloudOn = (int) ($article['read_aloud'] ?? 0) === 1;
- $readAloudSource = ($article['read_aloud_source'] ?? 'auto') === 'upload' ? 'upload' : 'auto';
- $readAloudAudio = (string) ($article['read_aloud_audio'] ?? '');
- $readAloudStatus = (string) ($article['read_aloud_status'] ?? '');
- $readAloudError = (string) ($article['read_aloud_error'] ?? '');
- $readAloudProviderReady = ReadAloud::providerReady();
- $readAloudAudioValid = $readAloudAudio !== '' && ReadAloud::isValidPath($readAloudAudio);
- ?>
- <div class="sidebar-box" id="read-aloud-box" data-csrf="<?= htmlspecialchars(Csrf::token(), ENT_QUOTES) ?>">
- <input type="hidden" name="read_aloud_present" value="1">
- <label style="margin-top:0;"><input type="checkbox" name="read_aloud" id="read-aloud-toggle" <?= $readAloudOn ? 'checked' : '' ?>> Read aloud (audio version)</label>
-
- <div id="read-aloud-options" style="<?= $readAloudOn ? '' : 'display:none;' ?>">
- <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>
- <label style="font-weight:500;"><input type="radio" name="read_aloud_source" value="upload" <?= $readAloudSource === 'upload' ? 'checked' : '' ?>> Use my own MP3 / WAV file</label>
-
- <div id="read-aloud-upload" style="<?= $readAloudSource === 'upload' ? '' : 'display:none;' ?>">
- <input type="hidden" name="read_aloud_audio_path" id="read-aloud-path" value="<?= $readAloudSource === 'upload' && $readAloudAudioValid ? htmlspecialchars($readAloudAudio) : '' ?>">
- <input type="file" id="read-aloud-file" accept=".mp3,.wav,audio/mpeg,audio/wav" style="margin-top:8px;">
- <div id="read-aloud-upload-status" style="font-size:12px;color:var(--muted);margin-top:6px;"></div>
- </div>
-
- <div id="read-aloud-current" style="margin-top:10px;">
- <?php if ($readAloudAudioValid): ?>
- <audio controls preload="none" style="width:100%;" src="<?= htmlspecialchars(ReadAloud::publicUrl($readAloudAudio)) ?>"></audio>
- <label><input type="checkbox" name="read_aloud_remove_audio"> Remove the current audio</label>
- <?php elseif ($readAloudStatus === 'generating'): ?>
- <p style="font-size:12.5px;color:var(--muted);margin:0;">The audio is being generated. Reload this page in a moment.</p>
- <?php elseif ($readAloudStatus === 'error'): ?>
- <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>
- <?php else: ?>
- <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>
- <?php endif; ?>
- </div>
- </div>
- </div>
- <script>
- (function () {
- var box = document.getElementById('read-aloud-box');
- if (!box) { return; }
-
- var toggle = document.getElementById('read-aloud-toggle');
- var options = document.getElementById('read-aloud-options');
- var upload = document.getElementById('read-aloud-upload');
- var file = document.getElementById('read-aloud-file');
- var path = document.getElementById('read-aloud-path');
- var status = document.getElementById('read-aloud-upload-status');
-
- toggle.addEventListener('change', function () {
- options.style.display = toggle.checked ? '' : 'none';
- });
-
- box.querySelectorAll('input[name="read_aloud_source"]').forEach(function (radio) {
- radio.addEventListener('change', function () {
- upload.style.display = radio.value === 'upload' && radio.checked ? '' : 'none';
- });
- });
-
- file.addEventListener('change', function () {
- if (!file.files.length) { return; }
-
- var data = new FormData();
- data.append('file', file.files[0]);
- data.append('kind', 'article');
- data.append('csrf_token', box.getAttribute('data-csrf'));
- status.textContent = 'Uploading…';
-
- fetch('audio-upload.php', { method: 'POST', body: data, credentials: 'same-origin' })
- .then(function (response) { return response.json(); })
- .then(function (result) {
- if (!result.success) { throw new Error(result.error || 'Upload failed.'); }
- path.value = result.path;
- status.textContent = 'Uploaded. Save the article to apply it.';
- })
- .catch(function (error) {
- status.textContent = error.message;
- file.value = '';
- });
- });
- })();
- </script>
- <?php endif; ?>
-
- <?php if ($article && !$readOnlyMeta && Languages::enabled() && !empty(Languages::all())): ?>
- <?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']) !== ''; } ?>
- <div class="sidebar-box">
- <label>Translations</label>
- <ul style="list-style:none;padding:0;margin:0;display:grid;gap:8px;font-size:13px;">
- <?php foreach (Languages::all() as $translationLanguage): ?>
- <li style="display:flex;justify-content:space-between;align-items:center;gap:8px;">
- <span><?= htmlspecialchars($translationLanguage['name']) ?> <span style="color:var(--muted);"><?= $translatedArticleLanguages[$translationLanguage['code']] ? 'translated' : 'not translated' ?></span></span>
- <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>
- </li>
- <?php endforeach; ?>
- </ul>
- </div>
- <?php endif; ?>
-
- <div class="sidebar-box">
- <label>Cover image link</label>
- <input type="text" name="cover_image_path" data-image-upload value="<?= htmlspecialchars($article['cover_image_path'] ?? '') ?>" <?= $readOnlyMeta ? 'readonly' : '' ?>>
-
- <label>Title for search engines</label>
- <input type="text" name="seo_title" value="<?= htmlspecialchars($article['seo_title'] ?? '') ?>" <?= $readOnlyMeta ? 'readonly' : '' ?>>
-
- <label>Description for search engines</label>
- <textarea name="seo_description" rows="2" <?= $readOnlyMeta ? 'readonly' : '' ?>><?= htmlspecialchars($article['seo_description'] ?? '') ?></textarea>
- </div>
-
- <?php
- $previewSettings = SiteFront::settings();
- $previewEnabled = ($previewSettings['seo_preview_enabled'] ?? '0') === '1';
- $previewUrl = rtrim((string) Config::get('APP_URL', ''), '/') . SiteFront::articleUrl((string) ($article['slug'] ?? 'your-article-slug'));
- $previewImage = ($previewSettings['seo_og_article_image_enabled'] ?? '0') === '1' ? 'cover' : 'logo';
- ?>
- <?php if ($previewEnabled): ?>
- <div class="sidebar-box seo-preview" id="seo-preview"
- data-site-name="<?= htmlspecialchars((string) $previewSettings['site_name'], ENT_QUOTES) ?>"
- data-url="<?= htmlspecialchars($previewUrl, ENT_QUOTES) ?>"
- data-logo="<?= htmlspecialchars((string) $previewSettings['site_logo_url'], ENT_QUOTES) ?>"
- data-image-source="<?= $previewImage ?>">
- <?= Asset::css('assets/mevasearch.css') ?>
- <label>MevaSearch preview</label>
- <div class="mevasearch-preview">
- <div class="result-card">
- <div class="result-breadcrumb">
- <img class="result-favicon" id="seo-preview-favicon" alt="" style="display:none;">
- <span id="seo-preview-url"></span>
- </div>
- <div class="result-title"><a href="#" onclick="return false;" id="seo-preview-title"></a></div>
- <div class="result-desc" id="seo-preview-desc"></div>
- </div>
- </div>
-
- <label>Social media preview</label>
- <div class="seo-preview-social">
- <div class="seo-preview-social-image" id="seo-preview-image"></div>
- <div class="seo-preview-social-body">
- <div class="seo-preview-social-domain" id="seo-preview-domain"></div>
- <div class="seo-preview-social-title" id="seo-preview-social-title"></div>
- <div class="seo-preview-social-desc" id="seo-preview-social-desc"></div>
- </div>
- </div>
- </div>
-
- <style>
- .seo-preview-social { background: #fff; border: 1px solid #dfe1e5; border-radius: 8px; overflow: hidden; margin-bottom: 14px; font-family: Arial, sans-serif; }
- .seo-preview-social-image { height: 150px; background: #e9ecef center / cover no-repeat; }
- .seo-preview-social-body { padding: 10px 12px; background: #f0f2f5; }
- .seo-preview-social-domain { color: #65676b; font-size: 11px; text-transform: uppercase; letter-spacing: .3px; }
- .seo-preview-social-title { color: #050505; font-size: 15px; font-weight: 600; line-height: 1.3; margin: 2px 0; }
- .seo-preview-social-desc { color: #65676b; font-size: 13px; line-height: 1.4; }
- </style>
-
- <script>
- (function () {
- var root = document.getElementById('seo-preview');
- var form = document.getElementById('article-form');
- if (!root || !form) { return; }
-
- var siteName = root.getAttribute('data-site-name');
- var url = root.getAttribute('data-url');
- var logo = root.getAttribute('data-logo');
- var imageSource = root.getAttribute('data-image-source');
-
- function field(name) {
- var el = form.elements[name];
- return el ? String(el.value || '').trim() : '';
- }
-
- function clip(text, max) {
- return text.length > max ? text.slice(0, max - 1).trimEnd() + '…' : text;
- }
-
- function set(id, text) {
- document.getElementById(id).textContent = text;
- }
-
- function render() {
- var title = (field('seo_title') || field('title') || 'Article title') + ' - ' + siteName;
- var description = field('seo_description') || field('excerpt');
- var cover = field('cover_image_path');
- var image = imageSource === 'cover' && cover !== '' ? cover : logo;
- var host = url;
-
- try { host = new URL(url, document.baseURI).host; } catch (e) {}
-
- set('seo-preview-url', host);
- set('seo-preview-title', clip(title, 70));
- set('seo-preview-desc', clip(description || 'No description. Search engines will pick text from the page.', 160));
-
- var favicon = document.getElementById('seo-preview-favicon');
- if (logo) {
- favicon.src = logo;
- favicon.style.display = '';
- } else {
- favicon.style.display = 'none';
- }
- set('seo-preview-domain', host);
- set('seo-preview-social-title', clip(title, 70));
- set('seo-preview-social-desc', clip(description, 110));
-
- var imageEl = document.getElementById('seo-preview-image');
- if (image) {
- var src = image;
- try { src = new URL(image, document.baseURI).href; } catch (e) {}
- imageEl.style.backgroundImage = 'url("' + src.replace(/"/g, '%22') + '")';
- imageEl.style.display = '';
- } else {
- imageEl.style.backgroundImage = '';
- imageEl.style.display = 'none';
- }
- }
-
- ['title', 'excerpt', 'seo_title', 'seo_description', 'cover_image_path'].forEach(function (name) {
- var el = form.elements[name];
- if (el) { el.addEventListener('input', render); }
- });
- render();
- })();
- </script>
- <?php endif; ?>
-
- <?php if (!$readOnlyMeta): ?>
- <div class="sidebar-box">
- <label>Schedule for</label>
- <input type="datetime-local" name="scheduled_at" value="<?= htmlspecialchars(!empty($article['scheduled_at']) ? date('Y-m-d\TH:i', strtotime((string) $article['scheduled_at'])) : '') ?>">
-
- <div class="publish-actions">
- <?php $articleIsLive = $article !== null && in_array($article['status'], ['published', 'scheduled'], true) && Auth::canPublishDirectly(); ?>
- <?php if ($articleIsLive): ?>
- <button type="submit" name="publish_action" value="update" class="dash-btn dash-btn-primary"><?= Icons::icon('check', 'icon icon-sm') ?>Update</button>
- <?php if ($article['status'] === 'scheduled'): ?>
- <button type="submit" name="publish_action" value="publish_now" class="dash-btn"><?= Icons::icon('check', 'icon icon-sm') ?>Publish now</button>
- <?php endif; ?>
- <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>
- <?php else: ?>
- <button type="submit" name="publish_action" value="save_draft" class="dash-btn"><?= Icons::icon('pages', 'icon icon-sm') ?>Save draft</button>
- <?php endif; ?>
- <?php if ($articleIsLive): ?>
- <?php elseif (!Auth::canPublishDirectly()): ?>
- <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>
- <?php else: ?>
- <button type="submit" name="publish_action" value="publish_now" class="dash-btn dash-btn-primary"><?= Icons::icon('check', 'icon icon-sm') ?>Publish now</button>
- <button type="submit" name="publish_action" value="schedule" class="dash-btn"><?= Icons::icon('stats', 'icon icon-sm') ?>Schedule</button>
- <?php endif; ?>
- </div>
- </div>
- <?php else: ?>
- <button type="submit" class="dash-btn dash-btn-primary"><?= Icons::icon('check', 'icon icon-sm') ?>Save corrections</button>
- <?php endif; ?>
-
- <?php $articleVersions = ($article && !$readOnlyMeta) ? ArticleVersions::recent((int) $article['id']) : []; ?>
- <?php if (!empty($articleVersions)): ?>
- <div class="sidebar-box">
- <label>Versions</label>
- <ul style="list-style:none;padding:0;margin:0;display:grid;gap:10px;font-size:13px;">
- <?php foreach ($articleVersions as $version): ?>
- <li style="display:flex;justify-content:space-between;align-items:center;gap:8px;">
- <span><?= htmlspecialchars($version['created_at']) ?><br><span style="color:var(--muted);"><?= htmlspecialchars((string) $version['saved_by_name']) ?> · <?= htmlspecialchars($version['note']) ?></span></span>
- <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>
- </li>
- <?php endforeach; ?>
- </ul>
- </div>
- <?php endif; ?>
-
- <?php if ($article && $article['status'] === 'rejected' && !empty($article['rejection_reason'])): ?>
- <div class="dash-flash dash-flash-error">Sent back with a note: <?= htmlspecialchars($article['rejection_reason']) ?></div>
- <?php endif; ?>
-
- </div>
- </div>
- </form>
- <script>
- (function () {
- // Enter in a one-line field would submit with the first button ("Save draft"), so it does nothing here.
- var form = document.getElementById('article-form');
- if (!form) { return; }
- form.addEventListener('keydown', function (event) {
- var field = event.target;
- if (event.key === 'Enter' && field.tagName === 'INPUT' && ['text', 'search', 'url', 'number', 'datetime-local', 'email'].indexOf(field.type) !== -1) {
- event.preventDefault();
- }
- });
- })();
- </script>
-
- <?php if (!empty($articleVersions)): ?>
- <form method="post" id="version-restore-form">
- <?= Csrf::field() ?>
- <input type="hidden" name="action" value="restore_version">
- <input type="hidden" name="article_id" value="<?= (int) $article['id'] ?>">
- </form>
- <?php endif; ?>
-
- <script>
- document.getElementById('access_type_select').addEventListener('change', function () {
- document.getElementById('access_note').style.display = this.value === 'paid' ? '' : 'none';
- });
- </script>
-