- 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:
@@ -0,0 +1,136 @@
|
||||
(function(){
|
||||
function escapeHtml(value){
|
||||
return String(value || '').replace(/[&<>"']/g, function(ch){
|
||||
return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[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);
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user