diff --git a/assets/css/style.css b/assets/css/style.css index c9ea97f..6a1b9c5 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -233,13 +233,16 @@ address{font-style:normal} .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} -/* ── Reviews ────────────────────────────────────────────── */ -.rev-card{background:var(--bg3);border:1px solid var(--bdr);border-radius:var(--r);padding:1.2rem;margin-bottom:.9rem} -.rev-hdr{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:.65rem} -.rev-author{font-family:'Oswald',sans-serif;font-size:.88rem;letter-spacing:.05em;color:var(--text)} -.rev-date{font-size:.73rem;color:var(--text-d)} -.rev-title{font-size:.9rem;font-weight:600;color:var(--text);margin:.4rem 0 .3rem} -.rev-body{font-size:.88rem;color:var(--text-m);line-height:1.62} +/* ── Reviews / Comments ─────────────────────────────────── */ +.rev-card{margin-bottom:1rem} +.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)} +.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} +.comment-name{font-family:'Oswald',sans-serif;font-size:.86rem;letter-spacing:.08em;color:var(--text);max-width:100%;overflow:hidden;text-overflow:ellipsis} +.comment-meta{font-size:.74rem;color:var(--text-d)} +.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 ──────────────────────────────────────────────── */ .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} .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} +.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} .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)} @@ -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-byline{display:flex;align-items:center;gap:.45rem;color:var(--text-d);font-size:.86rem;flex-wrap:wrap} .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-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} @@ -468,13 +491,14 @@ address{font-style:normal} /* ── 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: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){ .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} .user-wrap{width:100%}.user-btn{width:100%;text-align:left} .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} + .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} } -@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}} diff --git a/assets/js/markdown-editor.js b/assets/js/markdown-editor.js new file mode 100644 index 0000000..3f32a81 --- /dev/null +++ b/assets/js/markdown-editor.js @@ -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, '$1'); + out = out.replace(/\*\*([^*]+)\*\*/g, '$1'); + out = out.replace(/\*([^*]+)\*/g, '$1'); + out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(_, label, url){ + return ''+label+''; + }); + return out; + } + function renderMarkdown(markdown){ + const lines = String(markdown || '').replace(/\r\n?/g, '\n').trim().split('\n'); + if (!String(markdown || '').trim()) return '

Nothing to preview yet.

'; + let html = '', para = [], list = null, quote = [], code = false, codeLines = []; + function softBreaks(value){ return inlineMarkdown(value).replace(/\n/g, '
'); } + function flushPara(){ if (para.length) { html += '

'+softBreaks(para.join('\n'))+'

'; para = []; } } + function flushList(){ if (!list) return; html += '<'+list.type+'>'; list.items.forEach(item => html += '
  • '+inlineMarkdown(item)+'
  • '); html += ''; list = null; } + function flushQuote(){ if (quote.length) { html += '
    '+softBreaks(quote.join('\n'))+'
    '; quote = []; } } + lines.forEach(function(line){ + if (/^```/.test(line)) { + if (code) { + html += '
    '+escapeHtml(codeLines.join('\n'))+'
    '; + 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 += ''+inlineMarkdown(m[2])+''; + 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 += '
    '+escapeHtml(codeLines.join('\n'))+'
    '; + 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); + }); +})(); diff --git a/business.php b/business.php index 42ac434..2daf1bd 100644 --- a/business.php +++ b/business.php @@ -450,7 +450,7 @@ include __DIR__.'/includes/header.php';
    - +
    @@ -462,17 +462,16 @@ include __DIR__.'/includes/header.php'; -
    -
    -
    - -
    -
    - -
    -
    -
    - +
    + +
    +
    +
    @@ -482,10 +481,9 @@ include __DIR__.'/includes/header.php';
    +
    -
    -
    -
    +

    No reviews yet — be the first!

    diff --git a/forum.php b/forum.php index 0ded3e8..8034591 100644 --- a/forum.php +++ b/forum.php @@ -57,7 +57,7 @@ function forumAttachmentList(array $attachments): string { return $out.'
    '; } function forumText(string $text): string { - return nl2br(e($text)); + return renderMarkdown($text); } $errors = []; @@ -153,7 +153,7 @@ if ($topicSlug === '') {
    -
    +
    Allowed: . Max each.
    @@ -228,9 +228,17 @@ include __DIR__.'/includes/header.php';
    -
    -
    - +
    + +
    +
    + +
    @@ -239,13 +247,20 @@ include __DIR__.'/includes/header.php';
    -
    -
    -
    - #reply- +
    + +
    +
    +
    + #reply- +
    +
    +
    -
    -
    @@ -257,7 +272,7 @@ include __DIR__.'/includes/header.php';
    -
    +
    Allowed: . Max each.
    diff --git a/includes/footer.php b/includes/footer.php index 886d618..3a11db3 100644 --- a/includes/footer.php +++ b/includes/footer.php @@ -47,6 +47,7 @@ + diff --git a/includes/functions.php b/includes/functions.php index e2a5448..8a8ce50 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -369,6 +369,100 @@ function userAvatarHtml(?array $user, string $class = 'avatar-sm'): string { $initial = strtoupper(substr($name, 0, 1) ?: 'U'); return ''; } +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) => ''.e(html_entity_decode($m[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')).'', $text); + $text = preg_replace('/\*\*([^*]+)\*\*/', '$1', $text); + $text = preg_replace('/\*([^*]+)\*/', '$1', $text); + $text = preg_replace_callback('/\[([^\]]+)\]\(([^)]+)\)/', function($m) { + return ''.$m[1].''; + }, $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 .= '

    '.str_replace("\n", '
    ', markdownInline(implode("\n", $para))).'

    '; + $para = []; + }; + $flushList = function() use (&$html, &$list) { + if (!$list) return; + $tag = $list['type']; + $html .= '<'.$tag.'>'; + foreach ($list['items'] as $item) $html .= '
  • '.markdownInline($item).'
  • '; + $html .= ''; + $list = null; + }; + $flushQuote = function() use (&$html, &$quote) { + if (!$quote) return; + $html .= '
    '.str_replace("\n", '
    ', markdownInline(implode("\n", $quote))).'
    '; + $quote = []; + }; + + foreach ($lines as $line) { + if (preg_match('/^```/', $line)) { + if ($code) { + $html .= '
    '.e(implode("\n", $codeLines)).'
    '; + $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 .= ''.markdownInline($m[2]).''; + 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 .= '
    '.e(implode("\n", $codeLines)).'
    '; + $flushPara(); $flushList(); $flushQuote(); + return $html; +} function uniqueForumSlug(string $title, int $id): string { $base = makeSlug($title) ?: 'topic'; return $base.'-'.$id;