1 <?php 2 require_once __DIR__ . '/../includes/bootstrap.php'; 3 4 $slug = get('slug'); 5 $topic = DB::row(" 6 SELECT t.*, u.username, u.avatar, 7 c.name AS cat_name, c.slug AS cat_slug, c.color AS cat_color 8 FROM topics t 9 JOIN users u ON u.id = t.user_id 10 JOIN categories c ON c.id = t.category_id 11 WHERE t.slug = ? 12 ", [$slug]); 13 if (!$topic) render_404(); 14 15 // Fetch full category for permission checks 16 $topicCat = DB::row('SELECT * FROM categories WHERE id=?', [$topic['category_id']]); 17 if (!can_read_category($topicCat ?? [])) render_403(); 18 19 DB::run('UPDATE topics SET views = views + 1 WHERE id = ?', [$topic['id']]); 20 21 $page = max(1, (int)get('page','1')); 22 $pp = max(1, (int)cfg('posts_per_page','20')); 23 24 // ?goto=POST_ID β jump directly to a specific post, calculating its page 25 $gotoId = (int)get('goto'); 26 if ($gotoId) { 27 // Find what position this post is in the thread (0-indexed) 28 $postPos = (int)DB::val( 29 'SELECT COUNT(*) FROM posts WHERE topic_id=? AND deleted=0 AND id<=?', 30 [$topic['id'], $gotoId] 31 ); 32 if ($postPos > 0) { 33 $targetPage = (int)ceil($postPos / $pp); 34 if ($targetPage !== $page) { 35 // Redirect to the correct page with the anchor 36 $redirectUrl = u('forum/topic.php?slug='.urlencode($slug).'&page='.$targetPage).'#post-'.$gotoId; 37 header('Location: ' . $redirectUrl); 38 exit; 39 } 40 // Already on the right page β anchor will handle scrolling 41 $page = $targetPage; 42 } 43 } 44 45 $off = ($page - 1) * $pp; 46 47 $posts = DB::rows(" 48 SELECT p.*, u.username, u.avatar, u.role, u.post_count, u.karma, u.location, 49 (SELECT COUNT(*) FROM likes l WHERE l.post_id = p.id) AS likes 50 FROM posts p 51 JOIN users u ON u.id = p.user_id 52 WHERE p.topic_id = ? AND p.deleted = 0 53 ORDER BY p.post_num 54 LIMIT ? OFFSET ? 55 ", [$topic['id'], $pp, $off]); 56 57 $total = (int)DB::val('SELECT COUNT(*) FROM posts WHERE topic_id=? AND deleted=0', [$topic['id']]); 58 $pages = max(1, (int)ceil($total / $pp)); 59 60 $tags = DB::rows(" 61 SELECT tg.* FROM tags tg 62 JOIN topic_tags tt ON tg.id = tt.tag_id 63 WHERE tt.topic_id = ? 64 ", [$topic['id']]); 65 66 $liked = []; 67 if ($USER) { 68 foreach (DB::rows('SELECT post_id FROM likes WHERE user_id=?', [$USER['id']]) as $l) 69 $liked[$l['post_id']] = true; 70 } 71 72 // Post captcha for reply box 73 $post_cap = post_captcha_enabled() ? post_captcha_generate() : null; 74 75 $PAGE_TITLE = $topic['title']; 76 include __DIR__ . '/../views/partials/layout.php'; 77 ?> 78 79 <nav class="bc"> 80 <a href="<?= u('/') ?>">Home</a> βΊ 81 <a href="<?= u('forum/category.php?slug='.urlencode($topic['cat_slug'])) ?>"><?= e($topic['cat_name']) ?></a> βΊ 82 <span><?= e(mb_substr($topic['title'],0,60)) ?></span> 83 </nav> 84 85 <!-- Topic header --> 86 <div class="topic-hdr"> 87 <div class="topic-hdr-main"> 88 <div class="topic-badges"> 89 <?php if ($topic['pinned']): ?><span class="tbadge pin">π Pinned</span><?php endif; ?> 90 <?php if ($topic['closed']): ?><span class="tbadge closed">π Closed</span><?php endif; ?> 91 </div> 92 <h1 class="topic-title"><?= e($topic['title']) ?></h1> 93 <div class="topic-meta"> 94 <a href="<?= u('forum/category.php?slug='.urlencode($topic['cat_slug'])) ?>" 95 class="cat-tag" style="--cc:<?= e($topic['cat_color']) ?>"><?= e($topic['cat_name']) ?></a> 96 <?php foreach ($tags as $tg): ?> 97 <span class="tag"><?= e($tg['name']) ?></span> 98 <?php endforeach; ?> 99 <span>Β·</span> 100 <span><?= max(0, $total - 1) ?> <?= $total === 2 ? 'reply' : 'replies' ?></span> 101 <span>Β·</span> 102 <span><?= number_format($topic['views']) ?> views</span> 103 </div> 104 </div> 105 <?php if ($USER && is_admin()): ?> 106 <div class="topic-mod"> 107 <?php foreach ([ 108 [$topic['pinned'], 'unpin', 'pin', 'π', $topic['pinned'] ? 'Unpin' : 'Pin'], 109 [$topic['closed'], 'open', 'close', 'π', $topic['closed'] ? 'Open' : 'Close'], 110 [$topic['archived'], 'unarchive','archive', 'π¦', $topic['archived'] ? 'Unarchive': 'Archive'], 111 ] as [$state, $onAct, $offAct, $icon, $label]): ?> 112 <form method="POST" action="<?= u('api/topic_action.php') ?>" style="display:inline"> 113 <?= csrf_input() ?> 114 <input type="hidden" name="id" value="<?= $topic['id'] ?>"> 115 <input type="hidden" name="action" value="<?= $state ? $onAct : $offAct ?>"> 116 <button class="btn-sm btn-ghost"><?= $icon ?> <?= $label ?></button> 117 </form> 118 <?php endforeach; ?> 119 </div> 120 <?php endif; ?> 121 </div> 122 123 <?php 124 /* ββ render_topic_header addon hook (collector) ββ */ 125 echo addon_collect('render_topic_header', $topic); 126 ?> 127 128 <!-- Posts list --> 129 <?php $postLayoutH = cfg('post_layout_horizontal','0') === '1'; ?> 130 <div id="postsList" class="<?= $postLayoutH ? 'posts-horizontal' : '' ?>"> 131 <?php foreach ($posts as $p): ?> 132 <div class="post <?= $p['post_num'] == 1 ? 'post-op' : '' ?><?= $postLayoutH ? ' post-h' : '' ?>" id="post-<?= $p['id'] ?>"> 133 134 <!-- Author sidebar --> 135 <div class="post-side"> 136 <?php if ($p['avatar']): ?> 137 <img src="<?= e($p['avatar']) ?>" class="av-lg" alt=""> 138 <?php else: ?> 139 <span class="av-lg av-init"><?= strtoupper($p['username'][0]) ?></span> 140 <?php endif; ?> 141 <a href="<?= u('users/profile.php?u='.urlencode($p['username'])) ?>" class="post-name"> 142 @<?= e($p['username']) ?> 143 </a> 144 <?php if ($p['role'] === 'admin'): ?><span class="role-flair admin">Admin</span><?php endif; ?> 145 <?php if ($p['role'] === 'moderator'): ?><span class="role-flair mod">Mod</span><?php endif; ?> 146 <span class="post-pcnt"><?= $p['post_count'] ?> posts</span> 147 <?php if (!empty($p['location'])): ?> 148 <span class="post-location" title="Location"> 149 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="9" height="9"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg> 150 <?= e(mb_substr($p['location'],0,20)) ?> 151 </span> 152 <?php endif; ?> 153 <div class="post-karma-badge"> 154 <span class="pkb-icon"> 155 <svg viewBox="0 0 24 24" fill="none" stroke="#16a34a" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" width="10" height="10"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg> 156 </span> 157 <span class="pkb-val"><?= number_format((int)$p['karma']) ?></span> 158 </div> 159 </div> 160 161 <!-- Post body --> 162 <div class="post-body"> 163 <div class="post-meta-bar"> 164 <a class="pnum" href="#post-<?= $p['id'] ?>" title="Permalink to this post">#<?= $p['post_num'] ?></a> 165 <button class="post-link-btn" title="Copy link to this post" 166 onclick="copyPostLink('<?= $p['id'] ?>',this)" 167 data-url="<?= e(rtrim(u('forum/topic.php?slug='.urlencode($slug)), '/')) ?>&goto=<?= $p['id'] ?>"> 168 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> 169 <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/> 170 <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/> 171 </svg> 172 </button> 173 <time class="ago" data-ts="<?= e($p['created_at']) ?>"></time> 174 <?php if ($p['edited']): ?> 175 <em class="edit-lbl" title="<?= e($p['edit_reason'] ?? '') ?>">(edited)</em> 176 <?php endif; ?> 177 <div class="post-acts"> 178 <?php if ($USER): ?> 179 <button class="pa-btn like-btn <?= isset($liked[$p['id']]) ? 'liked' : '' ?>" 180 onclick="doLike(<?= $p['id'] ?>,this)"> 181 β₯ <span class="lc"><?= $p['likes'] ?></span> 182 </button> 183 <?php if (!$topic['closed']): ?> 184 <button class="pa-btn" onclick="doQuote(<?= $p['id'] ?>,'<?= e($p['username']) ?>')">β© Quote</button> 185 <?php endif; ?> 186 <?php if ($USER['id'] == $p['user_id'] || is_admin()): ?> 187 <button class="pa-btn" onclick="doEdit(<?= $p['id'] ?>)">β Edit</button> 188 <button class="pa-btn del" onclick="doDelete(<?= $p['id'] ?>)">π Delete</button> 189 <?php endif; ?> 190 <?php endif; ?> 191 </div> 192 </div> 193 194 <?php if ($p['reply_to']): ?> 195 <div class="reply-ref"> 196 β© In reply to <a href="#post-<?= $p['reply_to'] ?>">#<?= $p['reply_to'] ?></a> 197 </div> 198 <?php endif; ?> 199 200 <!-- Rendered content β server-side markdown + embeds --> 201 <div class="post-content rendered-post" id="pc-<?= $p['id'] ?>" 202 data-raw="<?= e(base64_encode($p['content'])) ?>"> 203 <?= addon_hook('render_post_content', render_post($p['content'])) ?> 204 </div> 205 206 <?php /* ββ render_post_footer addon hook (collector) ββ */ 207 echo addon_collect('render_post_footer', $p); 208 ?> 209 210 <!-- Edit box β hidden by default, toggled by doEdit() --> 211 <div class="edit-box" id="eb-<?= $p['id'] ?>"> 212 <div class="ed-toolbar"> 213 <button type="button" onclick="fmt('bold','et-<?= $p['id'] ?>')"><b>B</b></button> 214 <button type="button" onclick="fmt('italic','et-<?= $p['id'] ?>')"><i>I</i></button> 215 <button type="button" onclick="fmt('quote','et-<?= $p['id'] ?>')" title="Quote"><svg viewBox="0 0 24 24" fill="currentColor" width="13" height="13" style="vertical-align:middle"><path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z"/></svg></button> 216 <button type="button" onclick="pickImg('pi-<?= $p['id'] ?>','et-<?= $p['id'] ?>')">πΌ</button> 217 <input type="file" id="pi-<?= $p['id'] ?>" accept="image/*" style="display:none" 218 onchange="uploadImg(this,'et-<?= $p['id'] ?>')"> 219 </div> 220 <textarea id="et-<?= $p['id'] ?>" class="edit-ta fi" rows="7" 221 placeholder="Edit your postβ¦"></textarea> 222 <input type="text" id="er-<?= $p['id'] ?>" class="fi edit-reason-inp" 223 placeholder="Edit reason (optional)" style="margin-top:6px"> 224 <div class="edit-btns"> 225 <button class="btn-ghost btn-sm" onclick="cancelEdit(<?= $p['id'] ?>)">Cancel</button> 226 <button class="btn-primary btn-sm" onclick="saveEdit(<?= $p['id'] ?>)">Save Changes</button> 227 </div> 228 </div> 229 230 </div> 231 </div> 232 <?php endforeach; ?> 233 </div> 234 235 <!-- Pagination --> 236 <?php if ($pages > 1): ?> 237 <nav class="pager"> 238 <?php if ($page > 1): ?> 239 <a href="?slug=<?= urlencode($slug) ?>&page=<?= $page-1 ?>" class="pg-btn">β Prev</a> 240 <?php endif; ?> 241 <?php for ($i = max(1,$page-2); $i <= min($pages,$page+2); $i++): ?> 242 <a href="?slug=<?= urlencode($slug) ?>&page=<?= $i ?>" 243 class="pg-btn <?= $i===$page?'active':'' ?>"><?= $i ?></a> 244 <?php endfor; ?> 245 <?php if ($page < $pages): ?> 246 <a href="?slug=<?= urlencode($slug) ?>&page=<?= $page+1 ?>" class="pg-btn">Next β</a> 247 <?php endif; ?> 248 </nav> 249 <?php endif; ?> 250 251 <!-- Reply box --> 252 <?php if ($USER && !$topic['closed']): ?> 253 <div class="reply-box" id="replyBox"> 254 <div class="reply-hdr"> 255 <?php if ($USER['avatar']): ?> 256 <img src="<?= e($USER['avatar']) ?>" class="av-md" alt=""> 257 <?php else: ?> 258 <span class="av-md av-init"><?= strtoupper($USER['username'][0]) ?></span> 259 <?php endif; ?> 260 <span>Reply as <strong>@<?= e($USER['username']) ?></strong></span> 261 </div> 262 263 <div class="ed-toolbar"> 264 <button type="button" onclick="fmt('bold')"><b>B</b></button> 265 <button type="button" onclick="fmt('italic')"><i>I</i></button> 266 <button type="button" onclick="fmt('link')">π</button> 267 <button type="button" onclick="fmt('quote')" title="Quote"><svg viewBox="0 0 24 24" fill="currentColor" width="13" height="13" style="vertical-align:middle"><path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z"/></svg></button> 268 <button type="button" onclick="fmt('codeblock')">π</button> 269 <button type="button" onclick="fmt('heading')">H</button> 270 <button type="button" onclick="fmt('ul')">β‘</button> 271 <div class="ed-sep"></div> 272 <button type="button" onclick="pickImg('mainImg','replyTa')">πΌ</button> 273 <input type="file" id="mainImg" accept="image/*" style="display:none" 274 onchange="uploadImg(this,'replyTa')"> 275 <div class="ed-sep"></div> 276 <button type="button" id="prevBtn" onclick="togglePreview()">π Preview</button> 277 </div> 278 279 <div class="ed-panes"> 280 <textarea id="replyTa" class="reply-ta" 281 placeholder="Write your replyβ¦ Markdown supported. Paste or drag images to embed. Type @username to mention someone." 282 rows="8"></textarea> 283 <div id="replyPreview" class="reply-preview hidden"></div> 284 </div> 285 286 <div class="reply-footer"> 287 <span class="hint">Markdown Β· Images Β· @mentions Β· Auto-embeds YouTube & more</span> 288 <div class="reply-footer-right"> 289 <span id="charCnt" class="cnt">0</span> 290 <button class="btn-primary" id="replyBtn" 291 onclick="sendReply('<?= e($topic['slug']) ?>')">Post Reply</button> 292 </div> 293 </div> 294 295 <?php if ($post_cap): ?> 296 <div class="post-captcha-row" id="postCaptchaRow"> 297 <span class="post-captcha-label"> 298 π Security β What is <strong class="captcha-q"><?= e($post_cap['q']) ?></strong> 299 </span> 300 <input type="number" id="postCaptchaInput" class="fi post-captcha-inp" 301 placeholder="?" autocomplete="off" min="-99" max="99"> 302 <span class="hint">Solve to post</span> 303 </div> 304 <?php endif; ?> 305 306 <div class="rate-limit-info" id="rateLimitInfo" style="display:none"> 307 <div class="rate-limit-bar"> 308 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> 309 <span id="rateLimitMsg">Please wait before posting again:</span> 310 <div class="rate-countdown" id="rateCountdown"></div> 311 </div> 312 </div> 313 </div> 314 315 <?php elseif (!$USER): ?> 316 <div class="reply-cta"> 317 <p>Join the discussion!</p> 318 <a href="<?= u('auth/login.php') ?>" class="btn-ghost">Log In</a> 319 <a href="<?= u('auth/register.php') ?>" class="btn-primary">Sign Up to Reply</a> 320 </div> 321 322 <?php else: ?> 323 <div class="closed-notice">π This topic is closed to new replies.</div> 324 <?php endif; ?> 325 326 <?php include __DIR__ . '/../views/partials/layout_end.php'; ?>