- Reviews and forum posts/replies now use a modern comment layout: avatar + username/meta on the left, content/actions on the right.

Added sanitized Markdown rendering for reviews, forum topics, and 
replies.
Added a local Markdown editor with toolbar and live visual preview 
for:Review body on 
[business.php](/Users/tyemeclifford/Documents/GH/MyKeyser/business.php)
Forum topic body and reply body on 
[forum.php](/Users/tyemeclifford/Documents/GH/MyKeyser/forum.php)

Added editor/comment styling in 
[assets/css/style.css](/Users/tyemeclifford/Documents/GH/MyKeyser/assets/css/style.css).
Added the editor script at 
[assets/js/markdown-editor.js](/Users/tyemeclifford/Documents/GH/MyKeyser/assets/js/markdown-editor.js), 
included globally through 
[includes/footer.php](/Users/tyemeclifford/Documents/GH/MyKeyser/includes/footer.php).
This commit is contained in:
Ty Clifford
2026-06-19 11:07:43 -04:00
parent 25327e3302
commit ed932a6cd6
6 changed files with 305 additions and 37 deletions
+34 -10
View File
@@ -233,13 +233,16 @@ address{font-style:normal}
.mi-desc{font-size:.78rem;color:var(--text-d);margin-top:.12rem} .mi-desc{font-size:.78rem;color:var(--text-d);margin-top:.12rem}
.mi-price{font-family:'Oswald',sans-serif;font-size:.95rem;color:var(--gold);flex-shrink:0} .mi-price{font-family:'Oswald',sans-serif;font-size:.95rem;color:var(--gold);flex-shrink:0}
/* ── Reviews ────────────────────────────────────────────── */ /* ── Reviews / Comments ─────────────────────────────────── */
.rev-card{background:var(--bg3);border:1px solid var(--bdr);border-radius:var(--r);padding:1.2rem;margin-bottom:.9rem} .rev-card{margin-bottom:1rem}
.rev-hdr{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:.65rem} .comment-card{display:grid;grid-template-columns:150px minmax(0,1fr);gap:1.2rem;background:linear-gradient(180deg,rgba(14,23,43,.96),rgba(8,13,27,.96));border:1px solid rgba(38,244,255,.16);border-radius:var(--r);padding:1.15rem;box-shadow:0 0 0 1px rgba(255,209,102,.03)}
.rev-author{font-family:'Oswald',sans-serif;font-size:.88rem;letter-spacing:.05em;color:var(--text)} .comment-author{display:grid;justify-items:center;align-content:start;gap:.45rem;text-align:center;padding:.65rem .5rem;border-right:1px solid rgba(38,244,255,.14);min-width:0}
.rev-date{font-size:.73rem;color:var(--text-d)} .comment-name{font-family:'Oswald',sans-serif;font-size:.86rem;letter-spacing:.08em;color:var(--text);max-width:100%;overflow:hidden;text-overflow:ellipsis}
.rev-title{font-size:.9rem;font-weight:600;color:var(--text);margin:.4rem 0 .3rem} .comment-meta{font-size:.74rem;color:var(--text-d)}
.rev-body{font-size:.88rem;color:var(--text-m);line-height:1.62} .comment-content{min-width:0;padding:.2rem 0}
.comment-topline{display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;margin-bottom:.55rem}
.rev-title{font-size:1rem;font-weight:700;color:var(--text);line-height:1.3}
.rev-body{font-size:.92rem;color:var(--text-m);line-height:1.68}
/* ── Forms ──────────────────────────────────────────────── */ /* ── Forms ──────────────────────────────────────────────── */
.form-box{background:var(--bg3);border:1px solid var(--bdr);border-radius:var(--r-lg);padding:2.25rem;max-width:480px;margin:4rem auto} .form-box{background:var(--bg3);border:1px solid var(--bdr);border-radius:var(--r-lg);padding:2.25rem;max-width:480px;margin:4rem auto}
@@ -255,6 +258,26 @@ address{font-style:normal}
.fhint{font-size:.75rem;color:var(--text-d);margin-top:.3rem} .fhint{font-size:.75rem;color:var(--text-d);margin-top:.3rem}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:1rem} .form-row{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
.err-box{padding:.65rem 1rem;border-radius:4px;margin-bottom:1rem;font-size:.88rem;background:rgba(143,45,45,.28);border:1px solid var(--red-l);color:#ff9a9a} .err-box{padding:.65rem 1rem;border-radius:4px;margin-bottom:1rem;font-size:.88rem;background:rgba(143,45,45,.28);border:1px solid var(--red-l);color:#ff9a9a}
.md-editor{border:1px solid rgba(38,244,255,.2);border-radius:var(--r);background:rgba(0,0,0,.18);overflow:hidden}
.md-toolbar{display:flex;gap:.3rem;flex-wrap:wrap;padding:.45rem;background:rgba(18,31,56,.9);border-bottom:1px solid rgba(38,244,255,.14)}
.md-tool{width:32px;height:30px;display:grid;place-items:center;border:1px solid rgba(38,244,255,.2);border-radius:4px;background:rgba(3,5,11,.48);color:var(--cyan-l);font-family:'Oswald',sans-serif;font-size:.74rem;letter-spacing:.04em;transition:all var(--t)}
.md-tool:hover{border-color:var(--cyan);background:var(--cyan-dim);color:var(--text)}
.md-panes{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);min-height:220px}
.md-write{min-width:0;border-right:1px solid rgba(38,244,255,.14)}
.md-source{border:none!important;border-radius:0!important;background:rgba(0,0,0,.32)!important;min-height:220px;height:100%;resize:vertical}
.md-preview{min-height:220px;padding:.85rem 1rem;background:rgba(10,16,32,.58);overflow:auto}
.md-empty{color:var(--text-d)!important;font-style:italic}
.markdown-body{color:var(--text-m);line-height:1.72;overflow-wrap:anywhere}
.markdown-body p{margin:0 0 .8rem}
.markdown-body p:last-child{margin-bottom:0}
.markdown-body h3,.markdown-body h4,.markdown-body h5{font-family:'Oswald',sans-serif;letter-spacing:.08em;color:var(--text);margin:.9rem 0 .45rem}
.markdown-body ul,.markdown-body ol{list-style:revert;margin:.45rem 0 .85rem 1.25rem}
.markdown-body li{margin:.2rem 0}
.markdown-body blockquote{border-left:3px solid var(--cyan);padding:.45rem .75rem;margin:.7rem 0;background:rgba(38,244,255,.07);color:var(--text)}
.markdown-body code{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;background:rgba(255,209,102,.12);border:1px solid rgba(255,209,102,.18);border-radius:3px;padding:.05rem .25rem;color:var(--gold-l)}
.markdown-body pre{background:rgba(0,0,0,.46);border:1px solid rgba(38,244,255,.18);border-radius:var(--r);padding:.75rem;overflow:auto;margin:.8rem 0}
.markdown-body pre code{background:none;border:none;padding:0;color:var(--text-m)}
.markdown-body a{color:var(--cyan-l);text-decoration:underline;text-underline-offset:2px}
.hp-field{position:absolute;left:-9999px;top:-9999px;opacity:0;height:0;width:0;overflow:hidden;pointer-events:none} .hp-field{position:absolute;left:-9999px;top:-9999px;opacity:0;height:0;width:0;overflow:hidden;pointer-events:none}
.tos-check{display:flex;align-items:flex-start;gap:.65rem;background:rgba(18,31,56,.78);border:1px solid rgba(38,244,255,.18);border-radius:var(--r);padding:1rem 1.15rem;cursor:pointer;transition:border-color var(--t);margin:1.4rem 0} .tos-check{display:flex;align-items:flex-start;gap:.65rem;background:rgba(18,31,56,.78);border:1px solid rgba(38,244,255,.18);border-radius:var(--r);padding:1rem 1.15rem;cursor:pointer;transition:border-color var(--t);margin:1.4rem 0}
.tos-check:has(input:checked){border-color:rgba(77,255,155,.5)} .tos-check:has(input:checked){border-color:rgba(77,255,155,.5)}
@@ -446,7 +469,7 @@ address{font-style:normal}
.forum-topic-count{font-family:'Oswald',sans-serif;letter-spacing:.08em;font-size:.74rem;color:var(--cyan-l);white-space:nowrap} .forum-topic-count{font-family:'Oswald',sans-serif;letter-spacing:.08em;font-size:.74rem;color:var(--cyan-l);white-space:nowrap}
.forum-byline{display:flex;align-items:center;gap:.45rem;color:var(--text-d);font-size:.86rem;flex-wrap:wrap} .forum-byline{display:flex;align-items:center;gap:.45rem;color:var(--text-d);font-size:.86rem;flex-wrap:wrap}
.forum-thread{max-width:980px} .forum-thread{max-width:980px}
.forum-post,.forum-reply{background:var(--bg3);border:1px solid var(--bdr);border-radius:var(--r);padding:1.25rem;margin-bottom:1rem} .forum-post,.forum-reply{background:linear-gradient(180deg,rgba(14,23,43,.96),rgba(8,13,27,.96));border:1px solid rgba(38,244,255,.16);border-radius:var(--r);padding:1.25rem;margin-bottom:1rem}
.forum-post-body{color:var(--text-m);line-height:1.75;overflow-wrap:anywhere} .forum-post-body{color:var(--text-m);line-height:1.75;overflow-wrap:anywhere}
.forum-attachments{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:.75rem;margin-top:1rem} .forum-attachments{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:.75rem;margin-top:1rem}
.forum-attachment-image{display:block;overflow:hidden;border-radius:var(--r);border:1px solid rgba(38,244,255,.2);background:var(--bg4);aspect-ratio:4/3} .forum-attachment-image{display:block;overflow:hidden;border-radius:var(--r);border:1px solid rgba(38,244,255,.2);background:var(--bg4);aspect-ratio:4/3}
@@ -468,13 +491,14 @@ address{font-style:normal}
/* ── Responsive ─────────────────────────────────────────── */ /* ── Responsive ─────────────────────────────────────────── */
@media(max-width:1100px){.footer-grid{grid-template-columns:1fr 1fr}.home-hero-grid{grid-template-columns:1fr}.hero-signal{grid-template-columns:1fr 1fr}.signal-card-main{min-height:auto}.about-split{grid-template-columns:1fr}} @media(max-width:1100px){.footer-grid{grid-template-columns:1fr 1fr}.home-hero-grid{grid-template-columns:1fr}.hero-signal{grid-template-columns:1fr 1fr}.signal-card-main{min-height:auto}.about-split{grid-template-columns:1fr}}
@media(max-width:900px){.detail-body{grid-template-columns:1fr}.admin-wrap{grid-template-columns:1fr}.admin-side{position:static;max-height:none;border-right:none;border-bottom:1px solid var(--bdr)}.form-row{grid-template-columns:1fr}.section-head{align-items:flex-start;flex-direction:column}.random-list{grid-template-columns:1fr}.signup-grid,.tier-grid{grid-template-columns:1fr}.home-facts{grid-template-columns:1fr}.forum-layout{grid-template-columns:1fr}.avatar-upload-grid{grid-template-columns:1fr}.evt-image-wrap{width:140px;flex-basis:140px}} @media(max-width:900px){.detail-body{grid-template-columns:1fr}.admin-wrap{grid-template-columns:1fr}.admin-side{position:static;max-height:none;border-right:none;border-bottom:1px solid var(--bdr)}.form-row{grid-template-columns:1fr}.section-head{align-items:flex-start;flex-direction:column}.random-list{grid-template-columns:1fr}.signup-grid,.tier-grid{grid-template-columns:1fr}.home-facts{grid-template-columns:1fr}.forum-layout{grid-template-columns:1fr}.avatar-upload-grid{grid-template-columns:1fr}.evt-image-wrap{width:140px;flex-basis:140px}.md-panes{grid-template-columns:1fr}.md-write{border-right:none;border-bottom:1px solid rgba(38,244,255,.14)}}
@media(max-width:768px){ @media(max-width:768px){
.nav-links{display:none;position:absolute;top:64px;left:0;right:0;background:var(--bg0);border-bottom:1px solid var(--bdr-l);flex-direction:column;padding:.75rem 1rem;gap:.1rem;box-shadow:var(--sh)} .nav-links{display:none;position:absolute;top:64px;left:0;right:0;background:var(--bg0);border-bottom:1px solid var(--bdr-l);flex-direction:column;padding:.75rem 1rem;gap:.1rem;box-shadow:var(--sh)}
.nav-links.open{display:flex}.hamburger{display:flex} .nav-links.open{display:flex}.hamburger{display:flex}
.user-wrap{width:100%}.user-btn{width:100%;text-align:left} .user-wrap{width:100%}.user-btn{width:100%;text-align:left}
.user-dd{position:static;box-shadow:none;border:none;background:var(--bg2)} .user-dd{position:static;box-shadow:none;border:none;background:var(--bg2)}
.hero{min-height:80vh}.g3,.g4{grid-template-columns:1fr}.evt-card{flex-direction:column}.evt-image-wrap{width:100%;height:190px;flex-basis:auto}.event-strip .evt-card{display:flex}.event-strip .evt-image-wrap{height:190px} .hero{min-height:80vh}.g3,.g4{grid-template-columns:1fr}.evt-card{flex-direction:column}.evt-image-wrap{width:100%;height:190px;flex-basis:auto}.event-strip .evt-card{display:flex}.event-strip .evt-image-wrap{height:190px}
.comment-card{grid-template-columns:1fr;gap:.85rem}.comment-author{border-right:none;border-bottom:1px solid rgba(38,244,255,.14);display:flex;text-align:left;justify-content:flex-start;align-items:center}.comment-author .stars{margin-left:auto}.comment-content{padding:0}.comment-topline{align-items:center}
.home-hero{min-height:auto;padding:4rem 1rem 3rem}.home-hero .hero-h1{font-size:clamp(3rem,17vw,4.6rem)}.hero-search{flex-direction:column}.hero-search .s-icon{top:1.35rem}.hero-search .btn{justify-content:center}.hero-signal,.signal-mini-grid{grid-template-columns:1fr}.join-band{align-items:flex-start;flex-direction:column;margin-bottom:2.5rem}.signup-shell{padding:3rem 1rem}.signup-card{padding:1rem}.tier-card{min-height:auto} .home-hero{min-height:auto;padding:4rem 1rem 3rem}.home-hero .hero-h1{font-size:clamp(3rem,17vw,4.6rem)}.hero-search{flex-direction:column}.hero-search .s-icon{top:1.35rem}.hero-search .btn{justify-content:center}.hero-signal,.signal-mini-grid{grid-template-columns:1fr}.join-band{align-items:flex-start;flex-direction:column;margin-bottom:2.5rem}.signup-shell{padding:3rem 1rem}.signup-card{padding:1rem}.tier-card{min-height:auto}
} }
@media(max-width:560px){.hero-h1{font-size:3rem}.footer-grid{grid-template-columns:1fr}.hrs-grid{grid-template-columns:1fr}.form-box{margin:2rem 1rem}.random-row{grid-template-columns:36px 1fr}.random-arrow{display:none}.home-hero .hero-eyebrow{letter-spacing:.18em}.btn{white-space:normal;text-align:center}.signal-number{font-size:3.6rem}} @media(max-width:560px){.hero-h1{font-size:3rem}.footer-grid{grid-template-columns:1fr}.hrs-grid{grid-template-columns:1fr}.form-box{margin:2rem 1rem}.random-row{grid-template-columns:36px 1fr}.random-arrow{display:none}.home-hero .hero-eyebrow{letter-spacing:.18em}.btn{white-space:normal;text-align:center}.signal-number{font-size:3.6rem}.comment-author{display:grid;justify-items:start}.comment-author .stars{margin-left:0}.md-tool{width:30px}.forum-topic-row{grid-template-columns:38px minmax(0,1fr)}.forum-topic-count{grid-column:2}}
+136
View File
@@ -0,0 +1,136 @@
(function(){
function escapeHtml(value){
return String(value || '').replace(/[&<>"']/g, function(ch){
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[ch];
});
}
function safeUrl(url){
url = String(url || '').trim();
if (!url) return '#';
try {
const parsed = new URL(url, window.location.origin);
if (!['http:', 'https:', 'mailto:'].includes(parsed.protocol)) return '#';
return parsed.href;
} catch (_) {
return '#';
}
}
function inlineMarkdown(text){
let out = escapeHtml(text);
out = out.replace(/`([^`]+)`/g, '<code>$1</code>');
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
out = out.replace(/\*([^*]+)\*/g, '<em>$1</em>');
out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(_, label, url){
return '<a href="'+escapeHtml(safeUrl(url))+'" target="_blank" rel="noopener">'+label+'</a>';
});
return out;
}
function renderMarkdown(markdown){
const lines = String(markdown || '').replace(/\r\n?/g, '\n').trim().split('\n');
if (!String(markdown || '').trim()) return '<p class="md-empty">Nothing to preview yet.</p>';
let html = '', para = [], list = null, quote = [], code = false, codeLines = [];
function softBreaks(value){ return inlineMarkdown(value).replace(/\n/g, '<br>'); }
function flushPara(){ if (para.length) { html += '<p>'+softBreaks(para.join('\n'))+'</p>'; para = []; } }
function flushList(){ if (!list) return; html += '<'+list.type+'>'; list.items.forEach(item => html += '<li>'+inlineMarkdown(item)+'</li>'); html += '</'+list.type+'>'; list = null; }
function flushQuote(){ if (quote.length) { html += '<blockquote>'+softBreaks(quote.join('\n'))+'</blockquote>'; quote = []; } }
lines.forEach(function(line){
if (/^```/.test(line)) {
if (code) {
html += '<pre><code>'+escapeHtml(codeLines.join('\n'))+'</code></pre>';
code = false; codeLines = [];
} else {
flushPara(); flushList(); flushQuote(); code = true;
}
return;
}
if (code) { codeLines.push(line); return; }
if (!line.trim()) { flushPara(); flushList(); flushQuote(); return; }
let m;
if ((m = line.match(/^(#{1,3})\s+(.+)$/))) {
flushPara(); flushList(); flushQuote();
const level = m[1].length + 2;
html += '<h'+level+'>'+inlineMarkdown(m[2])+'</h'+level+'>';
return;
}
if ((m = line.match(/^>\s?(.*)$/))) { flushPara(); flushList(); quote.push(m[1]); return; }
if ((m = line.match(/^[-*]\s+(.+)$/))) { flushPara(); flushQuote(); if (!list || list.type !== 'ul') list = {type:'ul',items:[]}; list.items.push(m[1]); return; }
if ((m = line.match(/^\d+\.\s+(.+)$/))) { flushPara(); flushQuote(); if (!list || list.type !== 'ol') list = {type:'ol',items:[]}; list.items.push(m[1]); return; }
flushList(); flushQuote(); para.push(line);
});
if (code) html += '<pre><code>'+escapeHtml(codeLines.join('\n'))+'</code></pre>';
flushPara(); flushList(); flushQuote();
return html;
}
function applyWrap(textarea, before, after, placeholder){
const start = textarea.selectionStart || 0;
const end = textarea.selectionEnd || 0;
const value = textarea.value;
const selected = value.slice(start, end) || placeholder || '';
textarea.value = value.slice(0, start) + before + selected + after + value.slice(end);
textarea.focus();
textarea.selectionStart = start + before.length;
textarea.selectionEnd = start + before.length + selected.length;
textarea.dispatchEvent(new Event('input', {bubbles:true}));
}
function applyLine(textarea, prefix){
const start = textarea.selectionStart || 0;
const value = textarea.value;
const lineStart = value.lastIndexOf('\n', Math.max(0, start - 1)) + 1;
textarea.value = value.slice(0, lineStart) + prefix + value.slice(lineStart);
textarea.focus();
textarea.selectionStart = textarea.selectionEnd = start + prefix.length;
textarea.dispatchEvent(new Event('input', {bubbles:true}));
}
function enhance(textarea){
if (textarea.dataset.markdownEnhanced === '1') return;
textarea.dataset.markdownEnhanced = '1';
const shell = document.createElement('div');
shell.className = 'md-editor';
const toolbar = document.createElement('div');
toolbar.className = 'md-toolbar';
const tools = [
['B', 'Bold', () => applyWrap(textarea, '**', '**', 'bold text')],
['I', 'Italic', () => applyWrap(textarea, '*', '*', 'italic text')],
['#', 'Heading', () => applyLine(textarea, '### ')],
['•', 'Bullet list', () => applyLine(textarea, '- ')],
['1.', 'Numbered list', () => applyLine(textarea, '1. ')],
['“”', 'Quote', () => applyLine(textarea, '> ')],
['{}', 'Code', () => applyWrap(textarea, '`', '`', 'code')],
['↗', 'Link', () => {
const url = window.prompt('Link URL');
if (url) applyWrap(textarea, '[', ']('+url+')', 'link text');
}]
];
tools.forEach(function(tool){
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'md-tool';
btn.textContent = tool[0];
btn.title = tool[1];
btn.setAttribute('aria-label', tool[1]);
btn.addEventListener('click', tool[2]);
toolbar.appendChild(btn);
});
const panes = document.createElement('div');
panes.className = 'md-panes';
const write = document.createElement('div');
write.className = 'md-write';
const preview = document.createElement('div');
preview.className = 'md-preview markdown-body';
preview.setAttribute('aria-label', 'Markdown preview');
textarea.parentNode.insertBefore(shell, textarea);
shell.appendChild(toolbar);
shell.appendChild(panes);
panes.appendChild(write);
panes.appendChild(preview);
write.appendChild(textarea);
textarea.classList.add('md-source');
function update(){ preview.innerHTML = renderMarkdown(textarea.value); }
textarea.addEventListener('input', update);
update();
}
document.addEventListener('DOMContentLoaded', function(){
document.querySelectorAll('textarea.markdown-editor').forEach(enhance);
});
})();
+12 -14
View File
@@ -450,7 +450,7 @@ include __DIR__.'/includes/header.php';
</div> </div>
<div class="fg"> <div class="fg">
<label class="flabel">YOUR REVIEW</label> <label class="flabel">YOUR REVIEW</label>
<textarea name="rbody" class="ftextarea" placeholder="Share your experience…"><?=e($myrev['body'] ?? '')?></textarea> <textarea name="rbody" class="ftextarea markdown-editor" placeholder="Share your experience…"><?=e($myrev['body'] ?? '')?></textarea>
</div> </div>
<button type="submit" class="btn btn-primary btn-sm">Submit Review</button> <button type="submit" class="btn btn-primary btn-sm">Submit Review</button>
</form> </form>
@@ -462,17 +462,16 @@ include __DIR__.'/includes/header.php';
<?php endif; ?> <?php endif; ?>
<?php foreach ($reviews as $r): ?> <?php foreach ($reviews as $r): ?>
<div class="rev-card"> <article class="rev-card comment-card">
<div class="rev-hdr"> <aside class="comment-author">
<div class="rev-user"> <?=userAvatarHtml($r, 'avatar-lg')?>
<?=userAvatarHtml($r, 'avatar-sm')?> <div class="comment-name"><?=e($r['username'] ?? 'Anonymous')?></div>
<div> <div class="comment-meta"><?=ago($r['created_at'] ?? '')?></div>
<div class="rev-author"><?=e($r['username'] ?? 'Anonymous')?></div>
<?=starDisplay((float)($r['rating'] ?? 0))?> <?=starDisplay((float)($r['rating'] ?? 0))?>
</div> </aside>
</div> <div class="comment-content">
<div class="flex-row" style="gap:.5rem"> <div class="comment-topline">
<span class="rev-date"><?=ago($r['created_at'] ?? '')?></span> <?php if (!empty($r['title'])): ?><div class="rev-title"><?=e($r['title'])?></div><?php else: ?><div></div><?php endif; ?>
<?php if (isAdmin()): ?> <?php if (isAdmin()): ?>
<form method="POST"> <form method="POST">
<?=csrfField()?> <?=csrfField()?>
@@ -482,10 +481,9 @@ include __DIR__.'/includes/header.php';
</form> </form>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php if (!empty($r['body'])): ?><div class="rev-body markdown-body"><?=renderMarkdown((string)$r['body'])?></div><?php endif; ?>
</div> </div>
<?php if (!empty($r['title'])): ?><div class="rev-title"><?=e($r['title'])?></div><?php endif; ?> </article>
<?php if (!empty($r['body'])): ?><div class="rev-body"><?=e($r['body'])?></div><?php endif; ?>
</div>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (!$reviews): ?> <?php if (!$reviews): ?>
<p style="color:var(--text-d);font-size:.88rem">No reviews yet be the first!</p> <p style="color:var(--text-d);font-size:.88rem">No reviews yet be the first!</p>
+24 -9
View File
@@ -57,7 +57,7 @@ function forumAttachmentList(array $attachments): string {
return $out.'</div>'; return $out.'</div>';
} }
function forumText(string $text): string { function forumText(string $text): string {
return nl2br(e($text)); return renderMarkdown($text);
} }
$errors = []; $errors = [];
@@ -153,7 +153,7 @@ if ($topicSlug === '') {
<?=csrfField()?><input type="hidden" name="_act" value="create_topic"> <?=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">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">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">BODY *</label><textarea name="body" class="ftextarea markdown-editor" 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> <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> <button class="btn btn-primary btn-block">Post Topic</button>
</form> </form>
@@ -228,9 +228,17 @@ include __DIR__.'/includes/header.php';
</div> </div>
<div class="section-sm forum-thread"> <div class="section-sm forum-thread">
<article class="forum-post"> <article class="forum-post comment-card">
<div class="forum-post-body"><?=forumText($topic['body'])?></div> <aside class="comment-author">
<?=userAvatarHtml($topic, 'avatar-lg')?>
<div class="comment-name"><?=e($topic['username'])?></div>
<div class="comment-meta"><?=ago($topic['created_at'])?></div>
<span class="badge badge-blue"><?=e($topic['category_name'])?></span>
</aside>
<div class="comment-content">
<div class="forum-post-body markdown-body"><?=forumText($topic['body'])?></div>
<?=forumAttachmentList($topicAttachments)?> <?=forumAttachmentList($topicAttachments)?>
</div>
</article> </article>
<div class="forum-replies-head"> <div class="forum-replies-head">
@@ -239,13 +247,20 @@ include __DIR__.'/includes/header.php';
</div> </div>
<?php foreach($replies as $reply): ?> <?php foreach($replies as $reply): ?>
<article class="forum-reply" id="reply-<?=$reply['id']?>"> <article class="forum-reply comment-card" id="reply-<?=$reply['id']?>">
<div class="forum-reply-meta"> <aside class="comment-author">
<div class="forum-reply-author"><?=userAvatarHtml($reply, 'avatar-sm')?><span><?=e($reply['username'])?></span></div> <?=userAvatarHtml($reply, 'avatar-lg')?>
<div class="comment-name"><?=e($reply['username'])?></div>
<div class="comment-meta"><?=ago($reply['created_at'])?></div>
</aside>
<div class="comment-content">
<div class="comment-topline">
<div></div>
<a href="<?=e(forumTopicUrl($topic).'#reply-'.$reply['id'])?>" class="forum-permalink">#reply-<?=$reply['id']?></a> <a href="<?=e(forumTopicUrl($topic).'#reply-'.$reply['id'])?>" class="forum-permalink">#reply-<?=$reply['id']?></a>
</div> </div>
<?php if($reply['body'] !== ''): ?><div class="forum-post-body"><?=forumText($reply['body'])?></div><?php endif; ?> <?php if($reply['body'] !== ''): ?><div class="forum-post-body markdown-body"><?=forumText($reply['body'])?></div><?php endif; ?>
<?=forumAttachmentList($replyAttachments[$reply['id']] ?? [])?> <?=forumAttachmentList($replyAttachments[$reply['id']] ?? [])?>
</div>
</article> </article>
<?php endforeach; ?> <?php endforeach; ?>
@@ -257,7 +272,7 @@ include __DIR__.'/includes/header.php';
<?php else: ?> <?php else: ?>
<form method="POST" enctype="multipart/form-data"> <form method="POST" enctype="multipart/form-data">
<?=csrfField()?><input type="hidden" name="_act" value="reply"> <?=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">REPLY</label><textarea name="body" class="ftextarea markdown-editor"><?=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> <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> <button class="btn btn-primary">Post Reply</button>
</form> </form>
+1
View File
@@ -47,6 +47,7 @@
</footer> </footer>
<script src="/assets/js/app.js"></script> <script src="/assets/js/app.js"></script>
<script src="/assets/js/markdown-editor.js"></script>
<?=$footExtra??''?> <?=$footExtra??''?>
</body> </body>
</html> </html>
+94
View File
@@ -369,6 +369,100 @@ function userAvatarHtml(?array $user, string $class = 'avatar-sm'): string {
$initial = strtoupper(substr($name, 0, 1) ?: 'U'); $initial = strtoupper(substr($name, 0, 1) ?: 'U');
return '<span class="avatar avatar-initial '.$class.'" aria-hidden="true">'.e($initial).'</span>'; return '<span class="avatar avatar-initial '.$class.'" aria-hidden="true">'.e($initial).'</span>';
} }
function markdownSafeUrl(string $url): string {
$url = trim(html_entity_decode($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'));
$scheme = strtolower((string)(parse_url($url, PHP_URL_SCHEME) ?? ''));
if ($scheme !== '' && !in_array($scheme, ['http','https','mailto'], true)) return '#';
if ($url === '') return '#';
return $url;
}
function markdownInline(string $text): string {
$text = e($text);
$text = preg_replace_callback('/`([^`]+)`/', fn($m) => '<code>'.e(html_entity_decode($m[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')).'</code>', $text);
$text = preg_replace('/\*\*([^*]+)\*\*/', '<strong>$1</strong>', $text);
$text = preg_replace('/\*([^*]+)\*/', '<em>$1</em>', $text);
$text = preg_replace_callback('/\[([^\]]+)\]\(([^)]+)\)/', function($m) {
return '<a href="'.e(markdownSafeUrl($m[2])).'" target="_blank" rel="noopener">'.$m[1].'</a>';
}, $text);
return $text;
}
function renderMarkdown(string $markdown): string {
$markdown = str_replace(["\r\n", "\r"], "\n", trim($markdown));
if ($markdown === '') return '';
$lines = explode("\n", $markdown);
$html = '';
$para = [];
$list = null;
$quote = [];
$code = false;
$codeLines = [];
$flushPara = function() use (&$html, &$para) {
if (!$para) return;
$html .= '<p>'.str_replace("\n", '<br>', markdownInline(implode("\n", $para))).'</p>';
$para = [];
};
$flushList = function() use (&$html, &$list) {
if (!$list) return;
$tag = $list['type'];
$html .= '<'.$tag.'>';
foreach ($list['items'] as $item) $html .= '<li>'.markdownInline($item).'</li>';
$html .= '</'.$tag.'>';
$list = null;
};
$flushQuote = function() use (&$html, &$quote) {
if (!$quote) return;
$html .= '<blockquote>'.str_replace("\n", '<br>', markdownInline(implode("\n", $quote))).'</blockquote>';
$quote = [];
};
foreach ($lines as $line) {
if (preg_match('/^```/', $line)) {
if ($code) {
$html .= '<pre><code>'.e(implode("\n", $codeLines)).'</code></pre>';
$code = false;
$codeLines = [];
} else {
$flushPara(); $flushList(); $flushQuote();
$code = true;
}
continue;
}
if ($code) { $codeLines[] = $line; continue; }
if (trim($line) === '') {
$flushPara(); $flushList(); $flushQuote();
continue;
}
if (preg_match('/^(#{1,3})\s+(.+)$/', $line, $m)) {
$flushPara(); $flushList(); $flushQuote();
$level = strlen($m[1]) + 2;
$html .= '<h'.$level.'>'.markdownInline($m[2]).'</h'.$level.'>';
continue;
}
if (preg_match('/^>\s?(.*)$/', $line, $m)) {
$flushPara(); $flushList();
$quote[] = $m[1];
continue;
}
if (preg_match('/^[-*]\s+(.+)$/', $line, $m)) {
$flushPara(); $flushQuote();
if (!$list || $list['type'] !== 'ul') $list = ['type'=>'ul','items'=>[]];
$list['items'][] = $m[1];
continue;
}
if (preg_match('/^\d+\.\s+(.+)$/', $line, $m)) {
$flushPara(); $flushQuote();
if (!$list || $list['type'] !== 'ol') $list = ['type'=>'ol','items'=>[]];
$list['items'][] = $m[1];
continue;
}
$flushList(); $flushQuote();
$para[] = $line;
}
if ($code) $html .= '<pre><code>'.e(implode("\n", $codeLines)).'</code></pre>';
$flushPara(); $flushList(); $flushQuote();
return $html;
}
function uniqueForumSlug(string $title, int $id): string { function uniqueForumSlug(string $title, int $id): string {
$base = makeSlug($title) ?: 'topic'; $base = makeSlug($title) ?: 'topic';
return $base.'-'.$id; return $base.'-'.$id;