- [index.php](/Users/tyemeclifford/Documents/GH/MyKeyser/index.php): events now appear before featured businesses, plus forum pretty-route fallback.

[events.php](/Users/tyemeclifford/Documents/GH/MyKeyser/events.php) and 
[admin/events.php](/Users/tyemeclifford/Documents/GH/MyKeyser/admin/events.php): 
event image uploads.
[menu.php](/Users/tyemeclifford/Documents/GH/MyKeyser/menu.php), 
[admin/menus.php](/Users/tyemeclifford/Documents/GH/MyKeyser/admin/menus.php), 
and 
[business.php](/Users/tyemeclifford/Documents/GH/MyKeyser/business.php): 
one configurable menu item image slot.
[account.php](/Users/tyemeclifford/Documents/GH/MyKeyser/account.php): 
circular avatar upload and crop controls.
[forum.php](/Users/tyemeclifford/Documents/GH/MyKeyser/forum.php), 
[admin/forum.php](/Users/tyemeclifford/Documents/GH/MyKeyser/admin/forum.php), 
[.htaccess](/Users/tyemeclifford/Documents/GH/MyKeyser/.htaccess), and 
[forum/index.php](/Users/tyemeclifford/Documents/GH/MyKeyser/forum/index.php): 
forum, categories, topic/reply uploads, permalinks, toggle/moderation.
[includes/db.php](/Users/tyemeclifford/Documents/GH/MyKeyser/includes/db.php) 
and 
[includes/functions.php](/Users/tyemeclifford/Documents/GH/MyKeyser/includes/functions.php): 
schema upgrades and shared upload/image helpers.
[assets/css/style.css](/Users/tyemeclifford/Documents/GH/MyKeyser/assets/css/style.css): 
neon styling for forum, images, avatars, attachments.
This commit is contained in:
Ty Clifford
2026-06-19 10:49:48 -04:00
parent 2ce3eec65d
commit 25327e3302
21 changed files with 1363 additions and 74 deletions
+267
View File
@@ -0,0 +1,267 @@
<?php
require_once __DIR__.'/includes/boot.php';
$activeNav = 'forum';
$pageTitle = 'Community Forum - Keyser, WV';
if (setting('forum_enabled','1') !== '1' && !isAdmin()) {
flash('The community forum is currently offline.', 'warning');
go('/index.php');
}
requireLogin('/forum');
$topicSlug = gs('topic');
$path = (string)(parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?? '');
if ($topicSlug === '' && preg_match('#^/forum/([^/]+)/?$#', $path, $m)) {
$topicSlug = urldecode($m[1]);
}
function forumAllowedExts(): array {
return uploadAllowedExts(setting('forum_allowed_filetypes','jpg,jpeg,png,gif,webp,pdf,txt,doc,docx'), ['jpg','jpeg','png','gif','webp','pdf','txt','doc','docx']);
}
function forumUploadOpts(): array {
return [
'resize_over_bytes' => mbToBytes(setting('forum_image_resize_threshold_mb','1'), 1),
'image_max_width' => (int)setting('forum_image_max_width','1600'),
'image_quality' => (int)setting('forum_image_quality','82'),
];
}
function forumStoreAttachments(): array {
return storeMultipleUploads(
'attachments',
'forum',
forumAllowedExts(),
mbToBytes(setting('forum_upload_max_mb','5'), 5),
forumUploadOpts()
);
}
function forumSaveAttachments(array $files, int $userId, int $topicId = 0, int $replyId = 0): void {
foreach ($files as $file) {
qrun(
"INSERT INTO forum_attachments(topic_id,reply_id,user_id,file_path,original_name,mime_type,file_size)VALUES(?,?,?,?,?,?,?)",
[$topicId ?: null, $replyId ?: null, $userId, $file['path'], $file['name'] ?? '', $file['mime'] ?? '', (int)($file['size'] ?? 0)]
);
}
}
function forumAttachmentList(array $attachments): string {
if (!$attachments) return '';
$out = '<div class="forum-attachments">';
foreach ($attachments as $att) {
$name = $att['original_name'] ?: basename((string)$att['file_path']);
if (isImagePath((string)$att['file_path'])) {
$out .= '<a href="'.e($att['file_path']).'" class="forum-attachment-image" target="_blank" rel="noopener"><img src="'.e($att['file_path']).'" alt="'.e($name).'"></a>';
} else {
$out .= '<a href="'.e($att['file_path']).'" class="forum-file" target="_blank" rel="noopener"><span>'.e($name).'</span><em>'.formatBytes((int)$att['file_size']).'</em></a>';
}
}
return $out.'</div>';
}
function forumText(string $text): string {
return nl2br(e($text));
}
$errors = [];
$allowedHint = implode(', ', forumAllowedExts());
$maxHint = setting('forum_upload_max_mb','5').' MB';
if ($topicSlug === '') {
if (isPost() && ps('_act') === 'create_topic') {
csrfCheck();
$title = ps('title');
$body = ps('body');
$catId = (int)p('category_id');
$cat = qone("SELECT * FROM forum_categories WHERE id=? AND is_active=1", [$catId]);
if ($title === '') $errors[] = 'Topic title is required.';
if ($body === '') $errors[] = 'Topic body is required.';
if (!$cat) $errors[] = 'Please choose a forum category.';
$uploaded = ['ok'=>true, 'files'=>[]];
if (!$errors) {
$uploaded = forumStoreAttachments();
if (!$uploaded['ok']) $errors[] = $uploaded['error'];
}
if (!$errors) {
$tempSlug = 'draft-'.bin2hex(random_bytes(8));
$topicId = qrun(
"INSERT INTO forum_topics(category_id,user_id,title,slug,body)VALUES(?,?,?,?,?)",
[$catId, uid(), $title, $tempSlug, $body]
);
$slug = uniqueForumSlug($title, $topicId);
qrun("UPDATE forum_topics SET slug=? WHERE id=?", [$slug, $topicId]);
forumSaveAttachments($uploaded['files'] ?? [], uid(), $topicId, 0);
flash('Topic posted.', 'success');
go(forumTopicUrl(['slug'=>$slug]));
}
}
$categories = qall("SELECT * FROM forum_categories WHERE is_active=1 ORDER BY sort_order,name");
$topics = qall("
SELECT ft.*, fc.name category_name, u.username, u.avatar_path,
(SELECT COUNT(*) FROM forum_replies fr WHERE fr.topic_id=ft.id) AS reply_count,
COALESCE((SELECT MAX(fr.created_at) FROM forum_replies fr WHERE fr.topic_id=ft.id), ft.created_at) AS last_activity
FROM forum_topics ft
JOIN forum_categories fc ON fc.id=ft.category_id
JOIN users u ON u.id=ft.user_id
ORDER BY ft.is_pinned DESC, datetime(last_activity) DESC
");
include __DIR__.'/includes/header.php';
?>
<div class="page-hdr">
<div class="page-hdr-inner" style="display:flex;justify-content:space-between;align-items:flex-end;gap:1rem;flex-wrap:wrap">
<div>
<div class="eyebrow">COMMUNITY FORUM</div>
<h1 style="font-size:clamp(2rem,4vw,2.9rem);margin-bottom:.4rem">Keyser Community Forum</h1>
<p style="color:var(--text-m)">Topics and replies from registered members and business owners.</p>
</div>
<?php if(setting('forum_enabled','1') !== '1' && isAdmin()): ?><span class="badge badge-gold">Disabled for non-admins</span><?php endif; ?>
</div>
</div>
<div class="section forum-layout">
<div>
<div class="section-head">
<div>
<div class="eyebrow">LATEST TOPICS</div>
<h2 class="sec-title">Conversations</h2>
</div>
</div>
<?php if($topics): ?>
<div class="forum-topic-list">
<?php foreach($topics as $topic): ?>
<a href="<?=e(forumTopicUrl($topic))?>" class="forum-topic-row">
<?=userAvatarHtml($topic, 'avatar-sm')?>
<span class="forum-topic-main">
<strong><?=e($topic['title'])?></strong>
<em><?=e($topic['category_name'])?> by <?=e($topic['username'])?> · <?=ago($topic['last_activity'])?></em>
</span>
<span class="forum-topic-count"><?=$topic['reply_count']?> repl<?=$topic['reply_count']==1?'y':'ies'?></span>
</a>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="empty"><div class="empty-ico">+</div><h3>No topics yet</h3><p style="margin-top:.5rem">Start the first community conversation.</p></div>
<?php endif; ?>
</div>
<aside>
<div class="ibox" style="border-color:var(--gold-d)">
<div class="ibox-title">START A TOPIC</div>
<?php if($errors): ?><div class="err-box"><?=implode('<br>',array_map('e',$errors))?></div><?php endif; ?>
<?php if($categories): ?>
<form method="POST" enctype="multipart/form-data">
<?=csrfField()?><input type="hidden" name="_act" value="create_topic">
<div class="fg"><label class="flabel">CATEGORY *</label><select name="category_id" class="fselect" required><option value="">Choose category</option><?php foreach($categories as $cat): ?><option value="<?=$cat['id']?>"<?=((int)($_POST['category_id']??0)===(int)$cat['id'])?' selected':''?>><?=e($cat['name'])?></option><?php endforeach; ?></select></div>
<div class="fg"><label class="flabel">TITLE *</label><input type="text" name="title" class="finput" value="<?=e($_POST['title']??'')?>" required></div>
<div class="fg"><label class="flabel">BODY *</label><textarea name="body" class="ftextarea" required><?=e($_POST['body']??'')?></textarea></div>
<div class="fg"><label class="flabel">ATTACH FILES</label><input type="file" name="attachments[]" class="finput" multiple><div class="fhint">Allowed: <?=e($allowedHint)?>. Max <?=e($maxHint)?> each.</div></div>
<button class="btn btn-primary btn-block">Post Topic</button>
</form>
<?php else: ?>
<p style="color:var(--text-m)">No active categories are available yet.</p>
<?php endif; ?>
</div>
</aside>
</div>
<?php
include __DIR__.'/includes/footer.php';
exit;
}
$topic = qone("
SELECT ft.*, fc.name category_name, u.username, u.avatar_path
FROM forum_topics ft
JOIN forum_categories fc ON fc.id=ft.category_id
JOIN users u ON u.id=ft.user_id
WHERE ft.slug=?
", [$topicSlug]);
if (!$topic) { http_response_code(404); flash('Forum topic not found.', 'error'); go('/forum'); }
if (isPost() && ps('_act') === 'reply') {
csrfCheck();
if ((int)$topic['is_locked'] === 1 && !isAdmin()) {
$errors[] = 'This topic is locked.';
} else {
$body = ps('body');
$hasFile = false;
foreach (normalizeUploadFiles('attachments') as $file) {
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) $hasFile = true;
}
if ($body === '' && !$hasFile) $errors[] = 'Add a reply or attach a file.';
$uploaded = ['ok'=>true, 'files'=>[]];
if (!$errors) {
$uploaded = forumStoreAttachments();
if (!$uploaded['ok']) $errors[] = $uploaded['error'];
}
if (!$errors) {
$replyId = qrun("INSERT INTO forum_replies(topic_id,user_id,body)VALUES(?,?,?)", [$topic['id'], uid(), $body]);
forumSaveAttachments($uploaded['files'] ?? [], uid(), 0, $replyId);
qrun("UPDATE forum_topics SET updated_at=datetime('now') WHERE id=?", [$topic['id']]);
flash('Reply posted.', 'success');
go(forumTopicUrl($topic).'#reply-'.$replyId);
}
}
}
$topicAttachments = qall("SELECT * FROM forum_attachments WHERE topic_id=? AND reply_id IS NULL ORDER BY created_at", [$topic['id']]);
$replies = qall("
SELECT fr.*, u.username, u.avatar_path
FROM forum_replies fr
JOIN users u ON u.id=fr.user_id
WHERE fr.topic_id=?
ORDER BY fr.created_at ASC
", [$topic['id']]);
$replyAttachments = [];
foreach ($replies as $reply) {
$replyAttachments[$reply['id']] = qall("SELECT * FROM forum_attachments WHERE reply_id=? ORDER BY created_at", [$reply['id']]);
}
include __DIR__.'/includes/header.php';
?>
<div class="page-hdr">
<div class="page-hdr-inner">
<div class="breadcrumb"><a href="/index.php">Home</a><span></span><a href="/forum">Forum</a><span></span><?=e($topic['title'])?></div>
<div class="cat-badge"><?=e($topic['category_name'])?></div>
<h1 style="font-size:clamp(2rem,4vw,2.9rem);margin:.45rem 0"><?=e($topic['title'])?></h1>
<div class="forum-byline"><?=userAvatarHtml($topic, 'avatar-xs')?> Posted by <?=e($topic['username'])?> · <?=ago($topic['created_at'])?><?php if($topic['is_locked']): ?> · <span class="badge badge-gray">Locked</span><?php endif; ?></div>
</div>
</div>
<div class="section-sm forum-thread">
<article class="forum-post">
<div class="forum-post-body"><?=forumText($topic['body'])?></div>
<?=forumAttachmentList($topicAttachments)?>
</article>
<div class="forum-replies-head">
<div class="eyebrow">REPLIES</div>
<h2 class="sec-title"><?=count($replies)?> repl<?=count($replies)===1?'y':'ies'?></h2>
</div>
<?php foreach($replies as $reply): ?>
<article class="forum-reply" id="reply-<?=$reply['id']?>">
<div class="forum-reply-meta">
<div class="forum-reply-author"><?=userAvatarHtml($reply, 'avatar-sm')?><span><?=e($reply['username'])?></span></div>
<a href="<?=e(forumTopicUrl($topic).'#reply-'.$reply['id'])?>" class="forum-permalink">#reply-<?=$reply['id']?></a>
</div>
<?php if($reply['body'] !== ''): ?><div class="forum-post-body"><?=forumText($reply['body'])?></div><?php endif; ?>
<?=forumAttachmentList($replyAttachments[$reply['id']] ?? [])?>
</article>
<?php endforeach; ?>
<div class="ibox" style="border-color:var(--gold-d)">
<div class="ibox-title">POST A REPLY</div>
<?php if($errors): ?><div class="err-box"><?=implode('<br>',array_map('e',$errors))?></div><?php endif; ?>
<?php if((int)$topic['is_locked'] === 1 && !isAdmin()): ?>
<p style="color:var(--text-m)">This topic is locked.</p>
<?php else: ?>
<form method="POST" enctype="multipart/form-data">
<?=csrfField()?><input type="hidden" name="_act" value="reply">
<div class="fg"><label class="flabel">REPLY</label><textarea name="body" class="ftextarea"><?=e($_POST['body']??'')?></textarea></div>
<div class="fg"><label class="flabel">ATTACH FILES</label><input type="file" name="attachments[]" class="finput" multiple><div class="fhint">Allowed: <?=e($allowedHint)?>. Max <?=e($maxHint)?> each.</div></div>
<button class="btn btn-primary">Post Reply</button>
</form>
<?php endif; ?>
</div>
</div>
<?php include __DIR__.'/includes/footer.php'; ?>