WebOrbiton
v2.0.0.1

Publisium

399 lines · 21.0 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. require_once __DIR__ . '/includes/config.php';
  6. require_once __DIR__ . '/includes/database.php';
  7. require_once __DIR__ . '/includes/user-auth.php';
  8. require_once __DIR__ . '/includes/language.php';
  9. require_once __DIR__ . '/includes/site-front.php';
  10. require_once __DIR__ . '/includes/block-editor.php';
  11. require_once __DIR__ . '/includes/table-of-contents.php';
  12. require_once __DIR__ . '/includes/avatar.php';
  13. require_once __DIR__ . '/includes/author-profile.php';
  14. require_once __DIR__ . '/includes/tags.php';
  15. require_once __DIR__ . '/includes/auth.php';
  16. require_once __DIR__ . '/includes/read-aloud.php';
  17. require_once __DIR__ . '/includes/currency.php';
  18. require_once __DIR__ . '/includes/comments.php';
  19. ​
  20. $previewId = (int) ($_GET['preview'] ?? 0);
  21. $isPreview = $previewId > 0;
  22. ​
  23. $db = Database::site();
  24. $settings = SiteFront::settings();
  25. ​
  26. $slug = trim((string) ($_GET['slug'] ?? ''));
  27. ​
  28. if (Languages::enabled() && !isset($_GET['publisium_lang']) && preg_match('#^([a-z]{2,3}(?:-[a-z]{2,4})?)/(.*)$#', $slug, $languageMatch) === 1 && in_array($languageMatch[1], Languages::activeCodes(), true)) {
  29. $_GET['publisium_lang'] = $languageMatch[1];
  30. Languages::resetCache();
  31. SiteFront::resetSettings();
  32. $settings = SiteFront::settings();
  33. $languageRest = trim($languageMatch[2], '/');
  34. ​
  35. if ($languageRest === '') {
  36. require __DIR__ . '/index.php';
  37. exit;
  38. }
  39. ​
  40. if (preg_match('/^[a-z0-9-]+\.php$/', $languageRest) === 1 && $languageRest !== 'article.php' && is_file(__DIR__ . '/' . $languageRest)) {
  41. require __DIR__ . '/' . $languageRest;
  42. exit;
  43. }
  44. ​
  45. $slug = $languageRest;
  46. $_GET['slug'] = $languageRest;
  47. }
  48. ​
  49. // Short links (/about, /jan-kowalski, /my-article) all arrive here; send pages and author pages on.
  50. $viaShortLink = false;
  51. if (!$isPreview && $slug !== '') {
  52. $shortTarget = CleanUrls::resolve($slug);
  53. if ($shortTarget !== null) {
  54. $viaShortLink = true;
  55. if ($shortTarget['type'] === 'page' || $shortTarget['type'] === 'author') {
  56. $_GET['slug'] = $shortTarget['slug'];
  57. define('PUBLISIUM_SHORT_LINK', true);
  58. require __DIR__ . '/' . ($shortTarget['type'] === 'page' ? 'page.php' : 'artist.php');
  59. exit;
  60. }
  61. $slug = $shortTarget['slug'];
  62. }
  63. }
  64. ​
  65. if ($isPreview) {
  66. Auth::boot();
  67. } else {
  68. UserAuth::boot();
  69. }
  70. ​
  71. $query = $isPreview
  72. ? "SELECT a.*, c.name AS category_name, c.slug AS category_slug, COALESCE(t.display_name, a.author_name, '') AS author_name
  73. FROM articles a
  74. LEFT JOIN categories c ON c.id = a.category_id
  75. LEFT JOIN team_accounts t ON t.id = a.author_id
  76. WHERE a.id = :id LIMIT 1"
  77. : "SELECT a.*, c.name AS category_name, c.slug AS category_slug, COALESCE(t.display_name, a.author_name, '') AS author_name
  78. FROM articles a
  79. LEFT JOIN categories c ON c.id = a.category_id
  80. LEFT JOIN team_accounts t ON t.id = a.author_id
  81. WHERE a.slug = :slug AND a.status = 'published' LIMIT 1";
  82. $statement = $db->prepare($query);
  83. $statement->execute($isPreview ? ['id' => $previewId] : ['slug' => $slug]);
  84. $article = $statement->fetch();
  85. ​
  86. if (!$article && !$isPreview && Languages::enabled() && in_array(strtolower($slug), Languages::activeCodes(), true)) {
  87. header('Location: ' . Languages::homeUrl(strtolower($slug)), true, 301);
  88. exit;
  89. }
  90. ​
  91. if ($article && !$isPreview && Languages::current() !== '' && !Languages::hasTranslation('article', (int) $article['id'])) {
  92. header('Location: ' . SiteFront::articleUrl((string) $article['slug'], ''), true, 302);
  93. exit;
  94. }
  95. ​
  96. if (!$article) {
  97. http_response_code(404);
  98. $pageTitle = Language::get('not_found_title', 'Not found') . ' - ' . $settings['site_name'];
  99. $showBanner = false;
  100. require __DIR__ . '/includes/front-header.php';
  101. echo '<p>' . htmlspecialchars(Language::get('article_not_found', 'We couldn’t find this article. It may have been moved or deleted.')) . '</p>';
  102. require __DIR__ . '/includes/front-footer.php';
  103. exit;
  104. }
  105. ​
  106. if (!$isPreview && !$viaShortLink && CleanUrls::slugFor('article', (string) $article['slug']) !== null) {
  107. CleanUrls::redirect(SiteFront::articleUrl((string) $article['slug']));
  108. }
  109. ​
  110. $article = Languages::article($article);
  111. SiteFront::setAdContext('article', (int) $article['id']);
  112. ​
  113. if ($isPreview) {
  114. if (!Auth::check()) {
  115. http_response_code(403);
  116. exit('Preview access requires team login.');
  117. }
  118. ​
  119. $currentTeamUser = Auth::user();
  120. $canPreview = $currentTeamUser !== null
  121. && ((int) $article['author_id'] === (int) $currentTeamUser['id']
  122. || Auth::hasRoleAtLeast(Auth::ROLE_EDITOR_IN_CHIEF)
  123. || Auth::role() === Auth::ROLE_PROOFREADER);
  124. if (!$canPreview) {
  125. http_response_code(403);
  126. exit('You do not have permission to preview this article.');
  127. }
  128. } else {
  129. $updateViews = $db->prepare('UPDATE articles SET views_count = views_count + 1, updated_at = updated_at WHERE id = :id');
  130. $updateViews->execute(['id' => $article['id']]);
  131. }
  132. ​
  133. $hasSubscription = false;
  134. if ($article['access_type'] === 'paid' && UserAuth::check()) {
  135. $hasSubscription = UserAuth::hasActiveSubscription((int) UserAuth::user()['id']);
  136. }
  137. ​
  138. $isLocked = !$isPreview && $article['access_type'] === 'paid' && !$hasSubscription;
  139. ​
  140. $relatedArticles = [];
  141. $latestArticles = [];
  142. ​
  143. if ($settings['related_articles_enabled'] === '1') {
  144. if (!empty($article['category_id'])) {
  145. $relatedStatement = $db->prepare(
  146. "SELECT a.id, a.title, a.slug, a.cover_image_path FROM articles a WHERE a.status = 'published' AND a.category_id = :category_id AND a.id != :id" . Languages::articleFilterSql('a') . " ORDER BY a.published_at DESC LIMIT 4"
  147. );
  148. $relatedStatement->execute(['category_id' => $article['category_id'], 'id' => $article['id']]);
  149. $relatedArticles = Languages::articles($relatedStatement->fetchAll());
  150. }
  151. ​
  152. $latestStatement = $db->prepare(
  153. "SELECT a.id, a.title, a.slug, a.cover_image_path FROM articles a WHERE a.status = 'published' AND a.id != :id" . Languages::articleFilterSql('a') . " ORDER BY a.published_at DESC LIMIT 4"
  154. );
  155. $latestStatement->execute(['id' => $article['id']]);
  156. $latestArticles = Languages::articles($latestStatement->fetchAll());
  157. }
  158. ​
  159. $pageTitle = ($article['seo_title'] ?: $article['title']) . ' - ' . $settings['site_name'];
  160. $pageDescription = $article['seo_description'] ?: $article['excerpt'];
  161. $showBanner = false;
  162. $ogType = 'article';
  163. $ogImagePath = !empty($article['cover_image_path']) ? $article['cover_image_path'] : null;
  164. $pageType = 'article';
  165. $authorPage = AuthorProfile::isEnabled() ? AuthorProfile::publicSummary((int) $article['author_id']) : null;
  166. $authorPageUrl = $authorPage !== null ? SiteFront::authorUrl((string) $authorPage['slug']) : null;
  167. $ogArticleMeta = [
  168. 'published' => $article['published_at'] ?? null,
  169. 'modified' => $article['updated_at'] ?? null,
  170. 'author' => $authorPageUrl !== null ? SiteFront::absoluteUrl($authorPageUrl) : (string) $article['author_name'],
  171. 'section' => (string) ($article['category_name'] ?? ''),
  172. ];
  173. $pageSchema = !$isPreview && $settings['seo_article_schema_enabled'] === '1'
  174. ? SiteFront::renderArticleSchema($article, AuthorProfile::schemaType((int) $article['author_id']), $authorPageUrl)
  175. : '';
  176. $pageNoindex = !empty($article['noindex']);
  177. $pageTitle = $isPreview ? 'Preview - ' . $pageTitle : $pageTitle;
  178. ​
  179. require __DIR__ . '/includes/front-header.php';
  180. ​
  181. ?>
  182. <article class="article-view">
  183. ​
  184. <?php if (($settings['breadcrumbs_enabled'] ?? '0') === '1'): ?>
  185. <nav class="article-breadcrumbs" aria-label="<?= htmlspecialchars(Language::get('breadcrumb_label', 'Breadcrumb')) ?>">
  186. <ol>
  187. <li><a href="<?= htmlspecialchars(SiteFront::homeUrl(), ENT_QUOTES) ?>"><?= htmlspecialchars(Language::get('nav_home', 'Home')) ?></a></li>
  188. <?php if (!empty($article['category_name'])): ?>
  189. <li><a href="category.php?slug=<?= urlencode($article['category_slug']) ?>"><?= htmlspecialchars($article['category_name']) ?></a></li>
  190. <?php endif; ?>
  191. <li aria-current="page"><span><?= htmlspecialchars($article['title']) ?></span></li>
  192. </ol>
  193. </nav>
  194. <?php endif; ?>
  195. ​
  196. <?php if (!empty($article['category_name'])): ?>
  197. <a href="category.php?slug=<?= urlencode($article['category_slug']) ?>" class="article-category-link"><?= htmlspecialchars($article['category_name']) ?></a>
  198. <?php endif; ?>
  199. ​
  200. <h1 class="article-title"><?= htmlspecialchars($article['title']) ?></h1>
  201. ​
  202. <div class="article-meta">
  203. <?php if ($article['author_name'] !== ''): ?>
  204. <?= htmlspecialchars(Language::get('article_by', 'By')) ?> <?php if ($authorPage !== null): ?><a href="<?= htmlspecialchars(SiteFront::authorUrl((string) $authorPage['slug']), ENT_QUOTES) ?>"><?= htmlspecialchars($article['author_name']) ?></a><?php else: ?><?= htmlspecialchars($article['author_name']) ?><?php endif; ?>
  205. &middot;
  206. <?php endif; ?>
  207. <?= htmlspecialchars(Language::get('article_published_on', 'Published on')) ?> <?= htmlspecialchars(SiteFront::formatDate((string) $article['published_at'], $settings['article_time_enabled'] === '1')) ?>
  208. <?php if (($settings['article_reading_time_enabled'] ?? '1') === '1'): ?>
  209. &middot; <?= htmlspecialchars(sprintf(Language::get('article_reading_time', '%d min read'), BlockEditor::readingTimeMinutes($article['content_blocks']))) ?>
  210. <?php endif; ?>
  211. <?php if (($settings['article_views_enabled'] ?? '0') === '1'): ?>
  212. &middot; <?= htmlspecialchars(str_replace('{count}', SiteFront::formatNumber((int) $article['views_count'] + ($isPreview ? 0 : 1)), Language::get('article_views', '{count} views'))) ?>
  213. <?php endif; ?>
  214. </div>
  215. ​
  216. <?= SiteFront::renderShareControl($article) ?>
  217. ​
  218. <?= SiteFront::renderAds('article-top') ?>
  219. ​
  220. <?php if (!empty($article['cover_image_path'])): ?>
  221. <img src="<?= htmlspecialchars($article['cover_image_path']) ?>" alt="" class="article-cover">
  222. <?php endif; ?>
  223. ​
  224. <?php foreach (SiteFront::activeAds('in-article') as $inArticleAd): ?>
  225. <?= SiteFront::renderAd($inArticleAd) ?>
  226. <?php endforeach; ?>
  227. ​
  228. <?php if ($isLocked): ?>
  229. <div class="article-content">
  230. <p class="article-excerpt-locked"><?= htmlspecialchars($article['excerpt']) ?></p>
  231. </div>
  232. <div class="paid-overlay">
  233. <div class="paid-overlay-box">
  234. <span class="badge-paid"><?= htmlspecialchars(Language::get('article_subscribers_only', 'Subscribers only')) ?></span>
  235. ​
  236. <div class="subscription-offer">
  237. <div class="subscription-offer-name"><?= htmlspecialchars($settings['subscription_name']) ?></div>
  238. <div class="subscription-offer-price">
  239. <?php
  240. $offerPrice = Currency::format((int) $settings['subscription_price_cents'], (string) $settings['subscription_currency']);
  241. $offerInterval = (string) $settings['subscription_interval'];
  242. ?>
  243. <?= htmlspecialchars($offerInterval === 'lifetime'
  244. ? $offerPrice . ' · ' . Language::get('subscription_one_time', 'one-time payment')
  245. : $offerPrice . ' / ' . ($offerInterval === 'year' ? Language::get('subscription_per_year', 'year') : Language::get('subscription_per_month', 'month'))) ?>
  246. </div>
  247. <?php if ($settings['subscription_description'] !== ''): ?>
  248. <p class="subscription-offer-description"><?= htmlspecialchars($settings['subscription_description']) ?></p>
  249. <?php endif; ?>
  250. <?php if ($settings['subscription_checkout_url'] !== ''): ?>
  251. <a href="<?= htmlspecialchars(SiteFront::subscriptionCheckoutUrl((string) (UserAuth::user()['email'] ?? ''))) ?>" class="unlock-btn"><?= htmlspecialchars(($settings['subscription_interval'] === 'lifetime' ? Language::get('article_buy_lifetime_button', 'Get lifetime access') : Language::get('article_subscribe_button', 'Subscribe'))) ?></a>
  252. <?php else: ?>
  253. <a href="user-login.php?mode=<?= UserAuth::check() ? 'login' : 'register' ?>" class="unlock-btn"><?= htmlspecialchars(($settings['subscription_interval'] === 'lifetime' ? Language::get('article_buy_lifetime_button', 'Get lifetime access') : Language::get('article_subscribe_button', 'Subscribe'))) ?></a>
  254. <?php endif; ?>
  255. </div>
  256. ​
  257. <?php if (!UserAuth::check() && ($settings['login_enabled'] ?? '1') !== '0'): ?>
  258. <div class="subscription-divider"><?= htmlspecialchars(Language::get('article_already_subscriber', 'Already a subscriber?')) ?></div>
  259. <a href="user-login.php?mode=login" class="unlock-btn unlock-btn-outline"><?= htmlspecialchars(Language::get('article_login_button', 'Log in')) ?></a>
  260. <?php endif; ?>
  261. ​
  262. </div>
  263. </div>
  264. <?php else: ?>
  265. <?php
  266. $middleAdMarker = '<!--publisium-ad-middle-->';
  267. $articleHtml = BlockEditor::renderWithMiddle($article['content_blocks'], $middleAdMarker);
  268. $tocHtml = '';
  269. if (($settings['toc_enabled'] ?? '0') === '1') {
  270. [$articleHtml, $tocItems] = TableOfContents::build($articleHtml);
  271. $tocHtml = TableOfContents::render($tocItems);
  272. }
  273. ?>
  274. <?php $articleHtml = str_replace($middleAdMarker, SiteFront::renderAds('article-middle'), $articleHtml); ?>
  275. <?php $readAloudTracks = Languages::hasTranslation('article', (int) $article['id']) ? [] : ReadAloud::tracksFor($article); ?>
  276. <?php if ($readAloudTracks !== []): ?>
  277. <div class="read-aloud" data-read-aloud data-tracks="<?= htmlspecialchars((string) json_encode($readAloudTracks, JSON_UNESCAPED_SLASHES), ENT_QUOTES) ?>"
  278. data-label-play="<?= htmlspecialchars(Language::get('read_aloud_play', 'Play'), ENT_QUOTES) ?>"
  279. data-label-pause="<?= htmlspecialchars(Language::get('read_aloud_pause', 'Pause'), ENT_QUOTES) ?>">
  280. <button type="button" class="read-aloud-toggle" aria-label="<?= htmlspecialchars(Language::get('read_aloud_play', 'Play')) ?>">
  281. <svg class="read-aloud-icon-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5z" fill="currentColor"/></svg>
  282. <svg class="read-aloud-icon-pause" viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="5" width="4.2" height="14" rx="1.2" fill="currentColor"/><rect x="13.8" y="5" width="4.2" height="14" rx="1.2" fill="currentColor"/></svg>
  283. </button>
  284. <div class="read-aloud-main">
  285. <div class="read-aloud-title"><?= htmlspecialchars(Language::get('read_aloud_title', 'Listen to this article')) ?></div>
  286. <div class="read-aloud-track">
  287. <input type="range" class="read-aloud-seek" min="0" max="1000" value="0" step="1" aria-label="<?= htmlspecialchars(Language::get('read_aloud_seek', 'Seek')) ?>">
  288. <span class="read-aloud-time" aria-live="off">0:00</span>
  289. </div>
  290. </div>
  291. <button type="button" class="read-aloud-speed" aria-label="<?= htmlspecialchars(Language::get('read_aloud_speed', 'Playback speed')) ?>">1&times;</button>
  292. </div>
  293. <?= Asset::js('assets/read-aloud.js') ?>
  294. <?php endif; ?>
  295. <?= $tocHtml ?>
  296. <div class="article-content">
  297. <?= $articleHtml ?>
  298. </div>
  299. <?php endif; ?>
  300. ​
  301. <?php $articleTags = ArticleTags::isEnabled() && !$isLocked ? ArticleTags::forArticle((int) $article['id']) : []; ?>
  302. <?php if (!empty($articleTags)): ?>
  303. <div class="article-tags">
  304. <span class="article-tags-label"><?= htmlspecialchars(Language::get('article_tags_label', 'Tags:')) ?></span>
  305. <?php foreach ($articleTags as $articleTag): ?>
  306. <a href="tag.php?slug=<?= urlencode($articleTag['slug']) ?>" class="article-tag"><?= htmlspecialchars($articleTag['name']) ?></a>
  307. <?php endforeach; ?>
  308. </div>
  309. <?php endif; ?>
  310. ​
  311. <?php foreach (SiteFront::activeAds('article-bottom') as $articleBottomAd): ?>
  312. <?= SiteFront::renderAd($articleBottomAd) ?>
  313. <?php endforeach; ?>
  314. ​
  315. <?php if (($settings['author_box_enabled'] ?? '0') === '1' && !$isLocked && $article['author_name'] !== ''): ?>
  316. <?php
  317. if ($article['author_id'] !== null) {
  318. $authorCountStatement = $db->prepare("SELECT COUNT(*) FROM articles WHERE author_id = :id AND status = 'published'");
  319. $authorCountStatement->execute(['id' => $article['author_id']]);
  320. } else {
  321. $authorCountStatement = $db->prepare("SELECT COUNT(*) FROM articles WHERE author_id IS NULL AND author_name = :name AND status = 'published'");
  322. $authorCountStatement->execute(['name' => $article['author_name']]);
  323. }
  324. $authorArticleCount = (int) $authorCountStatement->fetchColumn();
  325. $authorAvatar = '';
  326. try {
  327. $authorAvatarStatement = $db->prepare('SELECT avatar_path FROM team_accounts WHERE id = :id LIMIT 1');
  328. $authorAvatarStatement->execute(['id' => $article['author_id']]);
  329. $authorAvatar = (string) $authorAvatarStatement->fetchColumn();
  330. } catch (PDOException $e) {
  331. $authorAvatar = '';
  332. }
  333. ?>
  334. <aside class="author-box">
  335. <?= Avatar::html(['avatar_path' => $authorAvatar, 'display_name' => $article['author_name']], 'author-box-avatar') ?>
  336. <div class="author-box-info">
  337. <div class="author-box-label"><?= htmlspecialchars(Language::get('article_written_by', 'Written by')) ?></div>
  338. <div class="author-box-name">
  339. <?php if ($authorPage !== null): ?>
  340. <a href="<?= htmlspecialchars(SiteFront::authorUrl((string) $authorPage['slug']), ENT_QUOTES) ?>"><?= htmlspecialchars($article['author_name']) ?></a>
  341. <?php else: ?>
  342. <?= htmlspecialchars($article['author_name']) ?>
  343. <?php endif; ?>
  344. </div>
  345. <?php if ($authorPage !== null && $authorPage['headline'] !== ''): ?>
  346. <div class="author-box-headline"><?= htmlspecialchars($authorPage['headline']) ?></div>
  347. <?php endif; ?>
  348. <div class="author-box-meta"><?= htmlspecialchars(str_replace('{count}', (string) $authorArticleCount, Language::get('author_articles_count', 'Articles: {count}'))) ?></div>
  349. </div>
  350. </aside>
  351. <?php endif; ?>
  352. ​
  353. </article>
  354. ​
  355. <?php if ($settings['related_articles_enabled'] === '1' && (!empty($relatedArticles) || !empty($latestArticles))): ?>
  356. <section class="related-section">
  357. <?php if (!empty($relatedArticles)): ?>
  358. <h2><?= htmlspecialchars(Language::get('article_related', 'Related articles')) ?></h2>
  359. <div class="article-grid article-grid-small">
  360. <?php foreach ($relatedArticles as $related): ?>
  361. <a href="<?= htmlspecialchars(SiteFront::articleUrl($related['slug'])) ?>" class="article-card">
  362. <?php if (!empty($related['cover_image_path'])): ?>
  363. <img src="<?= htmlspecialchars($related['cover_image_path']) ?>" alt="" class="article-card-image">
  364. <?php endif; ?>
  365. <div class="article-card-body">
  366. <h3><?= htmlspecialchars($related['title']) ?></h3>
  367. </div>
  368. </a>
  369. <?php endforeach; ?>
  370. </div>
  371. <?php endif; ?>
  372. ​
  373. <?php if (!empty($latestArticles)): ?>
  374. <h2><?= htmlspecialchars(Language::get('article_latest', 'More recent articles')) ?></h2>
  375. <div class="article-grid article-grid-small">
  376. <?php foreach ($latestArticles as $latest): ?>
  377. <a href="<?= htmlspecialchars(SiteFront::articleUrl($latest['slug'])) ?>" class="article-card">
  378. <?php if (!empty($latest['cover_image_path'])): ?>
  379. <img src="<?= htmlspecialchars($latest['cover_image_path']) ?>" alt="" class="article-card-image">
  380. <?php endif; ?>
  381. <div class="article-card-body">
  382. <h3><?= htmlspecialchars($latest['title']) ?></h3>
  383. </div>
  384. </a>
  385. <?php endforeach; ?>
  386. </div>
  387. <?php endif; ?>
  388. </section>
  389. <?php endif; ?>
  390. ​
  391. <?php if ($settings['comments_enabled'] === '1' && !$isLocked): ?>
  392. <section class="comments-section">
  393. <h2><?= htmlspecialchars(Language::get('article_comments_title', 'Comments')) ?></h2>
  394. ​
  395. <?= Comments::render($settings, $article) ?>
  396. </section>
  397. <?php endif; ?>
  398. ​
  399. <?php require __DIR__ . '/includes/front-footer.php'; ?>