WebOrbiton

Code

Publisium

A modern, fast CMS for building article and blog websites.

Version history

Version 2.0.0.1 2026-10-02

Browse files
Files changed14
Lines added+133
Lines removed-17
article.php Modified +3 -0
⋮ 207 unchanged lines ⋮
          <?php if (($settings['article_reading_time_enabled'] ?? '1') === '1'): ?>
              &middot; <?= 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'): ?>
+             &middot; <?= 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 Modified

Not shown (binary file or too large to diff).

comments.php Modified +33 -9
⋮ 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 Modified +14 -0
⋮ 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 Modified +1 -0
⋮ 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 Modified +2 -2
⋮ 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 Modified +2 -0
⋮ 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 Modified +1 -0
⋮ 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 Modified +60 -5
⋮ 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 Modified +13 -0
⋮ 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 Modified

Not shown (binary file or too large to diff).

sql/schema_site.sql Modified +1 -0
⋮ 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 Modified +2 -0
⋮ 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 Modified +1 -1
- 2.0.0.0
+ 2.0.0.1