v2.0.0.0
Publisium
- <?php
-
- declare(strict_types=1);
-
- require_once __DIR__ . '/includes/config.php';
- require_once __DIR__ . '/includes/database.php';
- require_once __DIR__ . '/includes/user-auth.php';
- require_once __DIR__ . '/includes/language.php';
- require_once __DIR__ . '/includes/site-front.php';
- require_once __DIR__ . '/includes/block-editor.php';
- require_once __DIR__ . '/includes/table-of-contents.php';
- require_once __DIR__ . '/includes/avatar.php';
- require_once __DIR__ . '/includes/author-profile.php';
- require_once __DIR__ . '/includes/tags.php';
- require_once __DIR__ . '/includes/auth.php';
- require_once __DIR__ . '/includes/read-aloud.php';
- require_once __DIR__ . '/includes/currency.php';
- require_once __DIR__ . '/includes/comments.php';
-
- $previewId = (int) ($_GET['preview'] ?? 0);
- $isPreview = $previewId > 0;
-
- $db = Database::site();
- $settings = SiteFront::settings();
-
- $slug = trim((string) ($_GET['slug'] ?? ''));
-
- 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)) {
- $_GET['publisium_lang'] = $languageMatch[1];
- Languages::resetCache();
- SiteFront::resetSettings();
- $settings = SiteFront::settings();
- $languageRest = trim($languageMatch[2], '/');
-
- if ($languageRest === '') {
- require __DIR__ . '/index.php';
- exit;
- }
-
- if (preg_match('/^[a-z0-9-]+\.php$/', $languageRest) === 1 && $languageRest !== 'article.php' && is_file(__DIR__ . '/' . $languageRest)) {
- require __DIR__ . '/' . $languageRest;
- exit;
- }
-
- $slug = $languageRest;
- $_GET['slug'] = $languageRest;
- }
-
- // Short links (/about, /jan-kowalski, /my-article) all arrive here; send pages and author pages on.
- $viaShortLink = false;
- if (!$isPreview && $slug !== '') {
- $shortTarget = CleanUrls::resolve($slug);
- if ($shortTarget !== null) {
- $viaShortLink = true;
- if ($shortTarget['type'] === 'page' || $shortTarget['type'] === 'author') {
- $_GET['slug'] = $shortTarget['slug'];
- define('PUBLISIUM_SHORT_LINK', true);
- require __DIR__ . '/' . ($shortTarget['type'] === 'page' ? 'page.php' : 'artist.php');
- exit;
- }
- $slug = $shortTarget['slug'];
- }
- }
-
- if ($isPreview) {
- Auth::boot();
- } else {
- UserAuth::boot();
- }
-
- $query = $isPreview
- ? "SELECT a.*, c.name AS category_name, c.slug AS category_slug, COALESCE(t.display_name, a.author_name, '') AS author_name
- FROM articles a
- LEFT JOIN categories c ON c.id = a.category_id
- LEFT JOIN team_accounts t ON t.id = a.author_id
- WHERE a.id = :id LIMIT 1"
- : "SELECT a.*, c.name AS category_name, c.slug AS category_slug, COALESCE(t.display_name, a.author_name, '') AS author_name
- FROM articles a
- LEFT JOIN categories c ON c.id = a.category_id
- LEFT JOIN team_accounts t ON t.id = a.author_id
- WHERE a.slug = :slug AND a.status = 'published' LIMIT 1";
- $statement = $db->prepare($query);
- $statement->execute($isPreview ? ['id' => $previewId] : ['slug' => $slug]);
- $article = $statement->fetch();
-
- if (!$article && !$isPreview && Languages::enabled() && in_array(strtolower($slug), Languages::activeCodes(), true)) {
- header('Location: ' . Languages::homeUrl(strtolower($slug)), true, 301);
- exit;
- }
-
- if ($article && !$isPreview && Languages::current() !== '' && !Languages::hasTranslation('article', (int) $article['id'])) {
- header('Location: ' . SiteFront::articleUrl((string) $article['slug'], ''), true, 302);
- exit;
- }
-
- if (!$article) {
- http_response_code(404);
- $pageTitle = Language::get('not_found_title', 'Not found') . ' - ' . $settings['site_name'];
- $showBanner = false;
- require __DIR__ . '/includes/front-header.php';
- echo '<p>' . htmlspecialchars(Language::get('article_not_found', 'We couldn’t find this article. It may have been moved or deleted.')) . '</p>';
- require __DIR__ . '/includes/front-footer.php';
- exit;
- }
-
- if (!$isPreview && !$viaShortLink && CleanUrls::slugFor('article', (string) $article['slug']) !== null) {
- CleanUrls::redirect(SiteFront::articleUrl((string) $article['slug']));
- }
-
- $article = Languages::article($article);
- SiteFront::setAdContext('article', (int) $article['id']);
-
- if ($isPreview) {
- if (!Auth::check()) {
- http_response_code(403);
- exit('Preview access requires team login.');
- }
-
- $currentTeamUser = Auth::user();
- $canPreview = $currentTeamUser !== null
- && ((int) $article['author_id'] === (int) $currentTeamUser['id']
- || Auth::hasRoleAtLeast(Auth::ROLE_EDITOR_IN_CHIEF)
- || Auth::role() === Auth::ROLE_PROOFREADER);
- if (!$canPreview) {
- http_response_code(403);
- exit('You do not have permission to preview this article.');
- }
- } else {
- $updateViews = $db->prepare('UPDATE articles SET views_count = views_count + 1, updated_at = updated_at WHERE id = :id');
- $updateViews->execute(['id' => $article['id']]);
- }
-
- $hasSubscription = false;
- if ($article['access_type'] === 'paid' && UserAuth::check()) {
- $hasSubscription = UserAuth::hasActiveSubscription((int) UserAuth::user()['id']);
- }
-
- $isLocked = !$isPreview && $article['access_type'] === 'paid' && !$hasSubscription;
-
- $relatedArticles = [];
- $latestArticles = [];
-
- if ($settings['related_articles_enabled'] === '1') {
- if (!empty($article['category_id'])) {
- $relatedStatement = $db->prepare(
- "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"
- );
- $relatedStatement->execute(['category_id' => $article['category_id'], 'id' => $article['id']]);
- $relatedArticles = Languages::articles($relatedStatement->fetchAll());
- }
-
- $latestStatement = $db->prepare(
- "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"
- );
- $latestStatement->execute(['id' => $article['id']]);
- $latestArticles = Languages::articles($latestStatement->fetchAll());
- }
-
- $pageTitle = ($article['seo_title'] ?: $article['title']) . ' - ' . $settings['site_name'];
- $pageDescription = $article['seo_description'] ?: $article['excerpt'];
- $showBanner = false;
- $ogType = 'article';
- $ogImagePath = !empty($article['cover_image_path']) ? $article['cover_image_path'] : null;
- $pageType = 'article';
- $authorPage = AuthorProfile::isEnabled() ? AuthorProfile::publicSummary((int) $article['author_id']) : null;
- $authorPageUrl = $authorPage !== null ? SiteFront::authorUrl((string) $authorPage['slug']) : null;
- $ogArticleMeta = [
- 'published' => $article['published_at'] ?? null,
- 'modified' => $article['updated_at'] ?? null,
- 'author' => $authorPageUrl !== null ? SiteFront::absoluteUrl($authorPageUrl) : (string) $article['author_name'],
- 'section' => (string) ($article['category_name'] ?? ''),
- ];
- $pageSchema = !$isPreview && $settings['seo_article_schema_enabled'] === '1'
- ? SiteFront::renderArticleSchema($article, AuthorProfile::schemaType((int) $article['author_id']), $authorPageUrl)
- : '';
- $pageNoindex = !empty($article['noindex']);
- $pageTitle = $isPreview ? 'Preview - ' . $pageTitle : $pageTitle;
-
- require __DIR__ . '/includes/front-header.php';
-
- ?>
- <article class="article-view">
-
- <?php if (($settings['breadcrumbs_enabled'] ?? '0') === '1'): ?>
- <nav class="article-breadcrumbs" aria-label="<?= htmlspecialchars(Language::get('breadcrumb_label', 'Breadcrumb')) ?>">
- <ol>
- <li><a href="<?= htmlspecialchars(SiteFront::homeUrl(), ENT_QUOTES) ?>"><?= htmlspecialchars(Language::get('nav_home', 'Home')) ?></a></li>
- <?php if (!empty($article['category_name'])): ?>
- <li><a href="category.php?slug=<?= urlencode($article['category_slug']) ?>"><?= htmlspecialchars($article['category_name']) ?></a></li>
- <?php endif; ?>
- <li aria-current="page"><span><?= htmlspecialchars($article['title']) ?></span></li>
- </ol>
- </nav>
- <?php endif; ?>
-
- <?php if (!empty($article['category_name'])): ?>
- <a href="category.php?slug=<?= urlencode($article['category_slug']) ?>" class="article-category-link"><?= htmlspecialchars($article['category_name']) ?></a>
- <?php endif; ?>
-
- <h1 class="article-title"><?= htmlspecialchars($article['title']) ?></h1>
-
- <div class="article-meta">
- <?php if ($article['author_name'] !== ''): ?>
- <?= 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; ?>
- ·
- <?php endif; ?>
- <?= htmlspecialchars(Language::get('article_published_on', 'Published on')) ?> <?= htmlspecialchars(SiteFront::formatDate((string) $article['published_at'], $settings['article_time_enabled'] === '1')) ?>
- <?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; ?>
- </div>
-
- <?= SiteFront::renderShareControl($article) ?>
-
- <?= SiteFront::renderAds('article-top') ?>
-
- <?php if (!empty($article['cover_image_path'])): ?>
- <img src="<?= htmlspecialchars($article['cover_image_path']) ?>" alt="" class="article-cover">
- <?php endif; ?>
-
- <?php foreach (SiteFront::activeAds('in-article') as $inArticleAd): ?>
- <?= SiteFront::renderAd($inArticleAd) ?>
- <?php endforeach; ?>
-
- <?php if ($isLocked): ?>
- <div class="article-content">
- <p class="article-excerpt-locked"><?= htmlspecialchars($article['excerpt']) ?></p>
- </div>
- <div class="paid-overlay">
- <div class="paid-overlay-box">
- <span class="badge-paid"><?= htmlspecialchars(Language::get('article_subscribers_only', 'Subscribers only')) ?></span>
-
- <div class="subscription-offer">
- <div class="subscription-offer-name"><?= htmlspecialchars($settings['subscription_name']) ?></div>
- <div class="subscription-offer-price">
- <?php
- $offerPrice = Currency::format((int) $settings['subscription_price_cents'], (string) $settings['subscription_currency']);
- $offerInterval = (string) $settings['subscription_interval'];
- ?>
- <?= htmlspecialchars($offerInterval === 'lifetime'
- ? $offerPrice . ' · ' . Language::get('subscription_one_time', 'one-time payment')
- : $offerPrice . ' / ' . ($offerInterval === 'year' ? Language::get('subscription_per_year', 'year') : Language::get('subscription_per_month', 'month'))) ?>
- </div>
- <?php if ($settings['subscription_description'] !== ''): ?>
- <p class="subscription-offer-description"><?= htmlspecialchars($settings['subscription_description']) ?></p>
- <?php endif; ?>
- <?php if ($settings['subscription_checkout_url'] !== ''): ?>
- <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>
- <?php else: ?>
- <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>
- <?php endif; ?>
- </div>
-
- <?php if (!UserAuth::check() && ($settings['login_enabled'] ?? '1') !== '0'): ?>
- <div class="subscription-divider"><?= htmlspecialchars(Language::get('article_already_subscriber', 'Already a subscriber?')) ?></div>
- <a href="user-login.php?mode=login" class="unlock-btn unlock-btn-outline"><?= htmlspecialchars(Language::get('article_login_button', 'Log in')) ?></a>
- <?php endif; ?>
-
- </div>
- </div>
- <?php else: ?>
- <?php
- $middleAdMarker = '<!--publisium-ad-middle-->';
- $articleHtml = BlockEditor::renderWithMiddle($article['content_blocks'], $middleAdMarker);
- $tocHtml = '';
- if (($settings['toc_enabled'] ?? '0') === '1') {
- [$articleHtml, $tocItems] = TableOfContents::build($articleHtml);
- $tocHtml = TableOfContents::render($tocItems);
- }
- ?>
- <?php $articleHtml = str_replace($middleAdMarker, SiteFront::renderAds('article-middle'), $articleHtml); ?>
- <?php $readAloudTracks = Languages::hasTranslation('article', (int) $article['id']) ? [] : ReadAloud::tracksFor($article); ?>
- <?php if ($readAloudTracks !== []): ?>
- <div class="read-aloud" data-read-aloud data-tracks="<?= htmlspecialchars((string) json_encode($readAloudTracks, JSON_UNESCAPED_SLASHES), ENT_QUOTES) ?>"
- data-label-play="<?= htmlspecialchars(Language::get('read_aloud_play', 'Play'), ENT_QUOTES) ?>"
- data-label-pause="<?= htmlspecialchars(Language::get('read_aloud_pause', 'Pause'), ENT_QUOTES) ?>">
- <button type="button" class="read-aloud-toggle" aria-label="<?= htmlspecialchars(Language::get('read_aloud_play', 'Play')) ?>">
- <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>
- <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>
- </button>
- <div class="read-aloud-main">
- <div class="read-aloud-title"><?= htmlspecialchars(Language::get('read_aloud_title', 'Listen to this article')) ?></div>
- <div class="read-aloud-track">
- <input type="range" class="read-aloud-seek" min="0" max="1000" value="0" step="1" aria-label="<?= htmlspecialchars(Language::get('read_aloud_seek', 'Seek')) ?>">
- <span class="read-aloud-time" aria-live="off">0:00</span>
- </div>
- </div>
- <button type="button" class="read-aloud-speed" aria-label="<?= htmlspecialchars(Language::get('read_aloud_speed', 'Playback speed')) ?>">1×</button>
- </div>
- <?= Asset::js('assets/read-aloud.js') ?>
- <?php endif; ?>
- <?= $tocHtml ?>
- <div class="article-content">
- <?= $articleHtml ?>
- </div>
- <?php endif; ?>
-
- <?php $articleTags = ArticleTags::isEnabled() && !$isLocked ? ArticleTags::forArticle((int) $article['id']) : []; ?>
- <?php if (!empty($articleTags)): ?>
- <div class="article-tags">
- <span class="article-tags-label"><?= htmlspecialchars(Language::get('article_tags_label', 'Tags:')) ?></span>
- <?php foreach ($articleTags as $articleTag): ?>
- <a href="tag.php?slug=<?= urlencode($articleTag['slug']) ?>" class="article-tag"><?= htmlspecialchars($articleTag['name']) ?></a>
- <?php endforeach; ?>
- </div>
- <?php endif; ?>
-
- <?php foreach (SiteFront::activeAds('article-bottom') as $articleBottomAd): ?>
- <?= SiteFront::renderAd($articleBottomAd) ?>
- <?php endforeach; ?>
-
- <?php if (($settings['author_box_enabled'] ?? '0') === '1' && !$isLocked && $article['author_name'] !== ''): ?>
- <?php
- if ($article['author_id'] !== null) {
- $authorCountStatement = $db->prepare("SELECT COUNT(*) FROM articles WHERE author_id = :id AND status = 'published'");
- $authorCountStatement->execute(['id' => $article['author_id']]);
- } else {
- $authorCountStatement = $db->prepare("SELECT COUNT(*) FROM articles WHERE author_id IS NULL AND author_name = :name AND status = 'published'");
- $authorCountStatement->execute(['name' => $article['author_name']]);
- }
- $authorArticleCount = (int) $authorCountStatement->fetchColumn();
- $authorAvatar = '';
- try {
- $authorAvatarStatement = $db->prepare('SELECT avatar_path FROM team_accounts WHERE id = :id LIMIT 1');
- $authorAvatarStatement->execute(['id' => $article['author_id']]);
- $authorAvatar = (string) $authorAvatarStatement->fetchColumn();
- } catch (PDOException $e) {
- $authorAvatar = '';
- }
- ?>
- <aside class="author-box">
- <?= Avatar::html(['avatar_path' => $authorAvatar, 'display_name' => $article['author_name']], 'author-box-avatar') ?>
- <div class="author-box-info">
- <div class="author-box-label"><?= htmlspecialchars(Language::get('article_written_by', 'Written by')) ?></div>
- <div class="author-box-name">
- <?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; ?>
- </div>
- <?php if ($authorPage !== null && $authorPage['headline'] !== ''): ?>
- <div class="author-box-headline"><?= htmlspecialchars($authorPage['headline']) ?></div>
- <?php endif; ?>
- <div class="author-box-meta"><?= htmlspecialchars(str_replace('{count}', (string) $authorArticleCount, Language::get('author_articles_count', 'Articles: {count}'))) ?></div>
- </div>
- </aside>
- <?php endif; ?>
-
- </article>
-
- <?php if ($settings['related_articles_enabled'] === '1' && (!empty($relatedArticles) || !empty($latestArticles))): ?>
- <section class="related-section">
- <?php if (!empty($relatedArticles)): ?>
- <h2><?= htmlspecialchars(Language::get('article_related', 'Related articles')) ?></h2>
- <div class="article-grid article-grid-small">
- <?php foreach ($relatedArticles as $related): ?>
- <a href="<?= htmlspecialchars(SiteFront::articleUrl($related['slug'])) ?>" class="article-card">
- <?php if (!empty($related['cover_image_path'])): ?>
- <img src="<?= htmlspecialchars($related['cover_image_path']) ?>" alt="" class="article-card-image">
- <?php endif; ?>
- <div class="article-card-body">
- <h3><?= htmlspecialchars($related['title']) ?></h3>
- </div>
- </a>
- <?php endforeach; ?>
- </div>
- <?php endif; ?>
-
- <?php if (!empty($latestArticles)): ?>
- <h2><?= htmlspecialchars(Language::get('article_latest', 'More recent articles')) ?></h2>
- <div class="article-grid article-grid-small">
- <?php foreach ($latestArticles as $latest): ?>
- <a href="<?= htmlspecialchars(SiteFront::articleUrl($latest['slug'])) ?>" class="article-card">
- <?php if (!empty($latest['cover_image_path'])): ?>
- <img src="<?= htmlspecialchars($latest['cover_image_path']) ?>" alt="" class="article-card-image">
- <?php endif; ?>
- <div class="article-card-body">
- <h3><?= htmlspecialchars($latest['title']) ?></h3>
- </div>
- </a>
- <?php endforeach; ?>
- </div>
- <?php endif; ?>
- </section>
- <?php endif; ?>
-
- <?php if ($settings['comments_enabled'] === '1' && !$isLocked): ?>
- <section class="comments-section">
- <h2><?= htmlspecialchars(Language::get('article_comments_title', 'Comments')) ?></h2>
-
- <?= Comments::render($settings, $article) ?>
- </section>
- <?php endif; ?>
-
- <?php require __DIR__ . '/includes/front-footer.php'; ?>