- 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
+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);
});
})();