Code
Publisium
A modern, fast CMS for building article and blog websites.
Version history
Version 2.0.0.1 2026-10-02
Browse filesFiles changed14
Lines added+133
Lines removed-17
article.php
⋮ 207 unchanged lines ⋮ <?php if (($settings['article_reading_time_enabled'] ?? '1') === '1'): ?> · <?= htmlspecialchars(sprintf(Language::get('article_reading_time', '%d min read'), BlockEditor::readingTimeMinutes($article['content_blocks']))) ?> <?php endif; ?> + <?php if (($settings['article_views_enabled'] ?? '0') === '1'): ?> + · <?= htmlspecialchars(str_replace('{count}', SiteFront::formatNumber((int) $article['views_count'] + ($isPreview ? 0 : 1)), Language::get('article_views', '{count} views'))) ?> + <?php endif; ?> </div> <?= SiteFront::renderShareControl($article) ?> ⋮ 183 unchanged lines ⋮
assets/site.css
Not shown (binary file or too large to diff).
comments.php
⋮ 10 unchanged lines ⋮ require_once __DIR__ . '/includes/site-comments.php'; Auth::boot(); - Auth::requireRoleAtLeast(Auth::ROLE_MANAGING_EDITOR); + Auth::requireRoleAtLeast(Auth::ROLE_WRITER); $currentUser = Auth::user(); $currentRole = Auth::role(); + $canModerate = Auth::canModerate(); $db = Database::site(); $flashMessage = null; ⋮ 8 unchanged lines ⋮ } } - $statusFilters = ['pending' => 'Waiting', 'approved' => 'Published', 'all' => 'All']; - $statusFilter = array_key_exists((string) ($_GET['status'] ?? ''), $statusFilters) ? (string) $_GET['status'] : 'pending'; + $statusFilters = $canModerate ? ['pending' => 'Waiting', 'approved' => 'Published', 'all' => 'All'] : ['approved' => 'Published']; + $defaultFilter = $canModerate ? 'pending' : 'approved'; + $statusFilter = array_key_exists((string) ($_GET['status'] ?? ''), $statusFilters) ? (string) $_GET['status'] : $defaultFilter; $perPage = 50; $currentPage = max(1, (int) ($_GET['page'] ?? 1)); + $ownArticlesSql = $canModerate ? '' : ' AND a.author_id = ' . (int) $currentUser['id']; + $counts = ['pending' => 0, 'approved' => 0, 'all' => 0]; - foreach ($db->query('SELECT status, COUNT(*) AS total FROM article_comments GROUP BY status') as $countRow) { + foreach ($db->query('SELECT c.status, COUNT(*) AS total FROM article_comments c LEFT JOIN articles a ON a.id = c.article_id WHERE 1 = 1' . $ownArticlesSql . ' GROUP BY c.status') as $countRow) { if (isset($counts[$countRow['status']])) { $counts[$countRow['status']] = (int) $countRow['total']; } ⋮ 2 unchanged lines ⋮ $totalPages = max(1, (int) ceil($counts[$statusFilter] / $perPage)); $currentPage = min($currentPage, $totalPages); - $where = $statusFilter === 'all' ? '' : 'WHERE c.status = :status'; + $where = 'WHERE 1 = 1' . $ownArticlesSql . ($statusFilter === 'all' ? '' : ' AND c.status = :status'); $statement = $db->prepare( 'SELECT c.*, a.title AS article_title, a.slug AS article_slug, a.status AS article_status FROM article_comments c ⋮ 18 unchanged lines ⋮ <div class="dash-header-row"> <h1 class="dash-title"><?= Icons::icon('message', 'icon icon-lg') ?>Comments</h1> - <?php if ($statusFilter === 'pending' && $counts['pending'] > 0): ?> + <?php if ($canModerate && $statusFilter === 'pending' && $counts['pending'] > 0): ?> <form method="post" onsubmit="return confirm('Delete all <?= (int) $counts['pending'] ?> waiting comments? This can’t be undone.');"> <?= Csrf::field() ?> <input type="hidden" name="action" value="delete_pending_comments"> ⋮ 30 unchanged lines ⋮ <td class="comments-table-body"> <?php if ($comment['parent_id'] !== null): ?><span class="status-pill status-draft">Reply</span><?php endif; ?> <?= SiteComments::formatBody((string) $comment['body']) ?> + <?php if ($comment['status'] === 'approved' && $comment['article_status'] === 'published'): ?> + <details class="comments-reply"> + <summary class="dash-btn-small"><?= Icons::icon('message', 'icon icon-sm') ?>Reply</summary> + <form method="post"> + <?= Csrf::field() ?> + <input type="hidden" name="action" value="reply_comment"> + <input type="hidden" name="comment_id" value="<?= $commentId ?>"> + <textarea name="reply_body" rows="3" required maxlength="<?= SiteComments::MAX_BODY ?>" placeholder="Your reply, shown with your name and a team badge"></textarea> + <button type="submit" class="dash-btn dash-btn-primary"><?= Icons::icon('check', 'icon icon-sm') ?>Publish reply</button> + </form> + </details> + <?php endif; ?> </td> <td> <?= htmlspecialchars((string) $comment['author_name']) ?> - <span class="status-pill status-draft"><?= $comment['user_account_id'] !== null ? 'Reader' : 'Guest' ?></span> - <?php if (!empty($comment['author_email'])): ?> + <span class="status-pill status-draft"><?= !empty($comment['team_account_id']) ? 'Team' : ($comment['user_account_id'] !== null ? 'Reader' : 'Guest') ?></span> + <?php if ($canModerate && !empty($comment['author_email'])): ?> <div class="comments-email"><a href="mailto:<?= htmlspecialchars((string) $comment['author_email'], ENT_QUOTES) ?>"><?= htmlspecialchars((string) $comment['author_email']) ?></a></div> <?php endif; ?> </td> ⋮ 15 unchanged lines ⋮ <?php endif; ?> </td> <td class="dash-table-actions"> + <?php if ($canModerate): ?> <form method="post" style="display:inline;"> <?= Csrf::field() ?> <input type="hidden" name="comment_id" value="<?= $commentId ?>"> ⋮ 9 unchanged lines ⋮ <input type="hidden" name="comment_id" value="<?= $commentId ?>"> <button type="submit" class="dash-btn-small dash-btn-danger"><?= Icons::icon('trash', 'icon icon-sm') ?>Delete</button> </form> + <?php endif; ?> </td> </tr> <?php endforeach; ?> <?php if (empty($comments)): ?> <tr> - <td colspan="6"><?= $statusFilter === 'pending' ? 'Nothing is waiting for approval.' : 'No comments here yet.' ?></td> + <td colspan="6"><?= $statusFilter === 'pending' ? 'Nothing is waiting for approval.' : ($canModerate ? 'No comments here yet.' : 'No comments on your articles yet.') ?></td> </tr> <?php endif; ?> </tbody> ⋮ 13 unchanged lines ⋮ .comments-table-body { max-width: 420px; overflow-wrap: anywhere; white-space: normal; } .comments-table-body .status-pill { margin-right: 6px; } .comments-email { margin-top: 4px; font-size: 12px; overflow-wrap: anywhere; } + .comments-reply { margin-top: 10px; } + .comments-reply summary { display: inline-flex; cursor: pointer; list-style: none; } + .comments-reply summary::-webkit-details-marker { display: none; } + .comments-reply form { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; } + .comments-reply textarea { width: 100%; } + .comments-reply button { align-self: flex-start; } </style> <?php require __DIR__ . '/includes/dash-footer.php'; ?> ⋮ 1 unchanged line ⋮
dashboard-actions/manage-comments.php
⋮ 5 unchanged lines ⋮ $action = (string) ($_POST['action'] ?? ''); $commentId = (int) ($_POST['comment_id'] ?? 0); + if ($action === 'reply_comment' && $commentId > 0) { + [$flashMessage, $flashType] = SiteComments::replyAsTeam($commentId, $currentUser, (string) ($_POST['reply_body'] ?? ''), $canModerate); + if ($flashType === 'success') { + ActivityLog::record('comments.reply', 'comment', $commentId); + } + return; + } + + if (!$canModerate) { + $flashMessage = 'Only editors can moderate comments.'; + $flashType = 'error'; + return; + } + if ($action === 'approve_comment' && $commentId > 0) { $db->prepare("UPDATE article_comments SET status = 'approved' WHERE id = :id")->execute(['id' => $commentId]); ActivityLog::record('comments.approve', 'comment', $commentId); ⋮ 17 unchanged lines ⋮
dashboard-actions/save-settings.php
⋮ 119 unchanged lines ⋮ 'featured_banner_auto_window_unit' => in_array($_POST['featured_banner_auto_window_unit'] ?? 'hours', ['minutes', 'hours'], true) ? $_POST['featured_banner_auto_window_unit'] : 'hours', 'featured_banner_auto_category_mode' => in_array($_POST['featured_banner_auto_category_mode'] ?? 'mixed', ['mixed', 'one_per_category', 'no_limit'], true) ? $_POST['featured_banner_auto_category_mode'] : 'mixed', 'article_reading_time_enabled' => isset($_POST['article_reading_time_enabled']) ? '1' : '0', + 'article_views_enabled' => isset($_POST['article_views_enabled']) ? '1' : '0', 'article_time_enabled' => isset($_POST['article_time_enabled']) ? '1' : '0', 'article_time_format' => ($_POST['article_time_format'] ?? '24') === '12' ? '12' : '24', 'back_to_top_enabled' => isset($_POST['back_to_top_enabled']) ? '1' : '0', ⋮ 132 unchanged lines ⋮
includes/dash-header.php
⋮ 138 unchanged lines ⋮ <?php $dashPendingProfiles = AuthorProfile::pendingCount(); ?> <a href="dashboard.php?view=profile-reviews" class="<?= ($dashView ?? '') === 'profile-reviews' ? 'active' : '' ?>"><?= Icons::icon('flag') ?>Artist reviews<?= $dashPendingProfiles > 0 ? ' (' . $dashPendingProfiles . ')' : '' ?></a> <?php endif; ?> - <?php if (Auth::canModerate() && SiteComments::isActive()): ?> - <?php $dashPendingComments = SiteComments::pendingCount(); ?> + <?php if (Auth::hasRoleAtLeast(Auth::ROLE_WRITER) && SiteComments::isActive()): ?> + <?php $dashPendingComments = Auth::canModerate() ? SiteComments::pendingCount() : 0; ?> <a href="comments.php" class="<?= ($dashActivePage ?? '') === 'comments' ? 'active' : '' ?>"><?= Icons::icon('message') ?>Comments<?= $dashPendingComments > 0 ? ' (' . $dashPendingComments . ')' : '' ?></a> <?php endif; ?> <a href="dashboard.php?view=pages" class="<?= ($dashView ?? '') === 'pages' ? 'active' : '' ?>"><?= Icons::icon('pages') ?>Pages</a> ⋮ 27 unchanged lines ⋮
includes/database.php
⋮ 113 unchanged lines ⋮ ], 'article_comments' => [ 'author_email' => "ALTER TABLE article_comments ADD COLUMN author_email VARCHAR(190) NULL AFTER author_name", + 'team_account_id' => "ALTER TABLE article_comments ADD COLUMN team_account_id INT UNSIGNED NULL AFTER user_account_id", ], 'author_profiles' => [ 'schema_type' => "ALTER TABLE author_profiles ADD COLUMN schema_type VARCHAR(12) NOT NULL DEFAULT 'Person' AFTER website_url", ⋮ 122 unchanged lines ⋮ article_id INT UNSIGNED NOT NULL, parent_id INT UNSIGNED NULL, user_account_id INT UNSIGNED NULL, + team_account_id INT UNSIGNED NULL, author_name VARCHAR(80) NOT NULL, author_email VARCHAR(190) NULL, body TEXT NOT NULL, ⋮ 123 unchanged lines ⋮
includes/language.php
⋮ 149 unchanged lines ⋮ 'comments_load_button' => 'Show comments', 'site_comments_empty' => 'No comments yet. Be the first to share your thoughts.', 'site_comments_reply' => 'Reply', + 'site_comments_team' => 'Team', 'site_comments_replying_to' => 'Replying to', 'site_comments_cancel_reply' => 'Cancel', 'site_comments_as' => 'Commenting as', ⋮ 52 unchanged lines ⋮
includes/site-comments.php
⋮ 7 unchanged lines ⋮ require_once __DIR__ . '/user-auth.php'; require_once __DIR__ . '/antibot.php'; require_once __DIR__ . '/csrf.php'; + require_once __DIR__ . '/author-profile.php'; // Publisium's own comments. Stored in article_comments with a name and the text (an email only when the site requires it, never an IP). // Comments are plain text: no HTML or links are ever rendered. ⋮ 93 unchanged lines ⋮ { try { $statement = Database::site()->prepare( - "SELECT id, parent_id, user_account_id, author_name, body, created_at FROM article_comments - WHERE article_id = :article AND status = 'approved' - ORDER BY created_at ASC, id ASC LIMIT " . self::MAX_SHOWN + "SELECT c.id, c.parent_id, c.user_account_id, t.id AS team_member_id, COALESCE(t.display_name, c.author_name) AS author_name, + p.slug AS author_slug, c.body, c.created_at + FROM article_comments c + LEFT JOIN team_accounts t ON t.id = c.team_account_id AND t.status = 'active' + LEFT JOIN author_profiles p ON p.account_id = c.team_account_id AND p.is_public = 1 + WHERE c.article_id = :article AND c.status = 'approved' + ORDER BY c.created_at ASC, c.id ASC LIMIT " . self::MAX_SHOWN ); $statement->execute(['article' => $articleId]); ⋮ 8 unchanged lines ⋮ $id = (int) $comment['id']; $name = htmlspecialchars((string) $comment['author_name']); $time = strtotime((string) $comment['created_at']) ?: time(); + $isTeam = !empty($comment['team_member_id']); - return '<article class="site-comment" id="comment-' . $id . '">' - . '<header><strong>' . $name . '</strong>' + $nameHtml = '<strong>' . $name . '</strong>'; + if ($isTeam && !empty($comment['author_slug']) && AuthorProfile::isEnabled()) { + $nameHtml = '<strong><a href="' . htmlspecialchars(SiteFront::authorUrl((string) $comment['author_slug']), ENT_QUOTES) . '">' . $name . '</a></strong>'; + } + if ($isTeam) { + $teamLabel = htmlspecialchars(Language::get('site_comments_team', 'Team'), ENT_QUOTES); + $nameHtml .= '<span class="site-comment-team" title="' . $teamLabel . '">' + . '<svg class="icon icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>' + . $teamLabel . '</span>'; + } + + return '<article class="site-comment' . ($isTeam ? ' site-comment-by-team' : '') . '" id="comment-' . $id . '">' + . '<header>' . $nameHtml . '<time datetime="' . date('c', $time) . '">' . htmlspecialchars(SiteFront::formatDate($time)) . '</time></header>' . '<div class="site-comment-body">' . self::formatBody((string) $comment['body']) . '</div>' . ($canReply ? '<button type="button" class="site-comment-reply" data-comment-id="' . $id . '" data-comment-name="' . $name . '" hidden>' ⋮ 160 unchanged lines ⋮ ]; return $articleId; + } + + public static function replyAsTeam(int $commentId, array $teamAccount, string $body, bool $canModerate): array + { + $body = self::cleanBody($body); + if (mb_strlen($body) < 2) { + return ['Write a reply first.', 'error']; + } + + $db = Database::site(); + $statement = $db->prepare( + "SELECT c.id, c.parent_id, c.article_id, a.author_id + FROM article_comments c + JOIN articles a ON a.id = c.article_id AND a.status = 'published' AND a.deleted_at IS NULL + WHERE c.id = :id AND c.status = 'approved' LIMIT 1" + ); + $statement->execute(['id' => $commentId]); + $parent = $statement->fetch(); + + if (!$parent) { + return ['You can only reply to published comments on published articles.', 'error']; + } + + if (!$canModerate && (int) $parent['author_id'] !== (int) $teamAccount['id']) { + return ['You can reply only to comments on your own articles.', 'error']; + } + + $db->prepare( + "INSERT INTO article_comments (article_id, parent_id, team_account_id, author_name, body, status) VALUES (:article, :parent, :team, :name, :body, 'approved')" + )->execute([ + 'article' => (int) $parent['article_id'], + 'parent' => $parent['parent_id'] !== null ? (int) $parent['parent_id'] : (int) $parent['id'], + 'team' => (int) $teamAccount['id'], + 'name' => self::singleLine((string) $teamAccount['display_name'], self::MAX_NAME), + 'body' => $body, + ]); + + return ['Your reply is published.', 'success']; } // The article must be published, have comments turned on, and not be behind a paywall for this reader. ⋮ 38 unchanged lines ⋮
includes/site-front.php
⋮ 82 unchanged lines ⋮ // A date in the site's language (for example "28 września 2026"), optionally with the time // in the 24-hour or 12-hour format picked in Layout. Without the intl extension it falls back to English. + public static function formatNumber(int $number): string + { + if (class_exists('NumberFormatter')) { + $formatted = (new NumberFormatter(str_replace('-', '_', Languages::htmlLang()), NumberFormatter::DECIMAL))->format($number); + if (is_string($formatted) && $formatted !== '') { + return $formatted; + } + } + + return number_format($number); + } + public static function formatDate(string|int $when, bool $withTime = false): string { $timestamp = is_int($when) ? $when : strtotime($when); ⋮ 163 unchanged lines ⋮ 'featured_banner_auto_category_mode' => 'mixed', 'related_articles_enabled' => '1', 'article_reading_time_enabled' => '1', + 'article_views_enabled' => '0', 'article_time_enabled' => '0', 'article_time_format' => '24', 'back_to_top_enabled' => '0', ⋮ 980 unchanged lines ⋮
settings.php
Not shown (binary file or too large to diff).
sql/schema_site.sql
⋮ 188 unchanged lines ⋮ article_id INT UNSIGNED NOT NULL, parent_id INT UNSIGNED NULL, user_account_id INT UNSIGNED NULL, + team_account_id INT UNSIGNED NULL, author_name VARCHAR(80) NOT NULL, author_email VARCHAR(190) NULL, body TEXT NOT NULL, ⋮ 7 unchanged lines ⋮
translations/language.php
⋮ 33 unchanged lines ⋮ 'article_comments_title' => 'Comments', 'article_share' => 'Share', 'article_reading_time' => '%d min read', + 'article_views' => '{count} views', 'article_toc_title' => 'Table of contents', 'back_to_top' => 'Back to top', 'reading_progress_left' => '{percent}% left', ⋮ 151 unchanged lines ⋮ 'comments_load_button' => 'Show comments', 'site_comments_empty' => 'No comments yet. Be the first to share your thoughts.', 'site_comments_reply' => 'Reply', + 'site_comments_team' => 'Team', 'site_comments_replying_to' => 'Replying to', 'site_comments_cancel_reply' => 'Cancel', 'site_comments_as' => 'Commenting as', ⋮ 18 unchanged lines ⋮
version.txt
- 2.0.0.0 + 2.0.0.1