From dc6c711264240b68fbd89a61ef07057976c14cb9 Mon Sep 17 00:00:00 2001 From: Maciej Pienczyn Date: Mon, 13 Apr 2026 13:38:09 +0200 Subject: [PATCH] feat(forum): @mention autocomplete with user picker - New reusable component static/js/mention-autocomplete.js - @ button above reply textarea + new topic textarea - Typing @xxx after space triggers dropdown with avatar/name/company - Arrow keys navigate, Enter/Tab selects, Esc closes - Inserts @firstname.lastname (matches existing backend mention parser) - Uses existing GET /api/users/search endpoint Co-Authored-By: Claude Opus 4.6 (1M context) --- static/js/mention-autocomplete.js | 194 ++++++++++++++++++++++++++++++ templates/forum/new_topic.html | 19 ++- templates/forum/topic.html | 15 ++- 3 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 static/js/mention-autocomplete.js diff --git a/static/js/mention-autocomplete.js b/static/js/mention-autocomplete.js new file mode 100644 index 0000000..655e6ff --- /dev/null +++ b/static/js/mention-autocomplete.js @@ -0,0 +1,194 @@ +(function() { + 'use strict'; + + function nameToMention(name) { + return (name || '') + .trim() + .toLowerCase() + .replace(/\s+/g, '.') + .replace(/[^\wąćęłńóśźż.\-]/g, ''); + } + + function createDropdown() { + const el = document.createElement('div'); + el.className = 'mention-autocomplete'; + el.style.cssText = 'position:absolute;z-index:1500;background:var(--surface,#fff);border:1px solid var(--border,#ddd);border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,0.15);max-height:280px;overflow-y:auto;min-width:260px;display:none;'; + document.body.appendChild(el); + return el; + } + + function getCaretCoords(textarea) { + const div = document.createElement('div'); + const style = getComputedStyle(textarea); + ['fontFamily','fontSize','fontWeight','letterSpacing','lineHeight','padding','border','boxSizing','whiteSpace','wordWrap','width'].forEach(p => { + div.style[p] = style[p]; + }); + div.style.position = 'absolute'; + div.style.visibility = 'hidden'; + div.style.whiteSpace = 'pre-wrap'; + div.style.wordWrap = 'break-word'; + const value = textarea.value.substring(0, textarea.selectionStart); + div.textContent = value; + const span = document.createElement('span'); + span.textContent = '\u200b'; + div.appendChild(span); + document.body.appendChild(div); + const rect = textarea.getBoundingClientRect(); + const spanRect = span.getBoundingClientRect(); + const divRect = div.getBoundingClientRect(); + const top = rect.top + window.scrollY + (spanRect.top - divRect.top) - textarea.scrollTop + parseInt(style.fontSize, 10) + 4; + const left = rect.left + window.scrollX + (spanRect.left - divRect.left) - textarea.scrollLeft; + document.body.removeChild(div); + return { top: top, left: left }; + } + + let dropdown = null; + let activeTextarea = null; + let activeIndex = 0; + let currentResults = []; + let mentionStart = -1; + let searchTimer = null; + + function hideDropdown() { + if (dropdown) dropdown.style.display = 'none'; + currentResults = []; + mentionStart = -1; + } + + function renderDropdown(results) { + if (!dropdown) dropdown = createDropdown(); + if (!results.length) { + dropdown.innerHTML = '
Brak wyników
'; + dropdown.style.display = 'block'; + return; + } + dropdown.innerHTML = results.map((u, i) => { + const avatar = u.avatar_url + ? `` + : `
${(u.name || '?')[0].toUpperCase()}
`; + const company = u.company_name ? `
${escapeHtml(u.company_name)}
` : ''; + return `
+ ${avatar} +
+
${escapeHtml(u.name)}
+ ${company} +
+
`; + }).join(''); + dropdown.style.display = 'block'; + Array.from(dropdown.querySelectorAll('.mention-item')).forEach(item => { + item.addEventListener('mousedown', function(e) { + e.preventDefault(); + selectUser(parseInt(this.dataset.idx, 10)); + }); + item.addEventListener('mouseenter', function() { + activeIndex = parseInt(this.dataset.idx, 10); + updateActiveItem(); + }); + }); + } + + function escapeHtml(s) { + return (s || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + } + + function updateActiveItem() { + if (!dropdown) return; + Array.from(dropdown.querySelectorAll('.mention-item')).forEach((item, i) => { + item.style.background = i === activeIndex ? 'var(--primary-light,#eef)' : ''; + }); + } + + function selectUser(idx) { + if (!activeTextarea || !currentResults[idx]) return; + const user = currentResults[idx]; + const mention = '@' + nameToMention(user.name) + ' '; + const textarea = activeTextarea; + const before = textarea.value.substring(0, mentionStart); + const after = textarea.value.substring(textarea.selectionStart); + textarea.value = before + mention + after; + const newCaret = before.length + mention.length; + textarea.setSelectionRange(newCaret, newCaret); + textarea.focus(); + hideDropdown(); + } + + function search(query) { + clearTimeout(searchTimer); + searchTimer = setTimeout(() => { + fetch('/api/users/search?q=' + encodeURIComponent(query), { credentials: 'same-origin' }) + .then(r => r.json()) + .then(data => { + currentResults = Array.isArray(data) ? data.slice(0, 8) : []; + activeIndex = 0; + if (!activeTextarea || mentionStart < 0) return; + const coords = getCaretCoords(activeTextarea); + if (!dropdown) dropdown = createDropdown(); + dropdown.style.top = coords.top + 'px'; + dropdown.style.left = coords.left + 'px'; + renderDropdown(currentResults); + }) + .catch(() => hideDropdown()); + }, 150); + } + + function detectMention(textarea) { + const pos = textarea.selectionStart; + const text = textarea.value.substring(0, pos); + const match = text.match(/(?:^|\s)@([\wąćęłńóśźż.\-]{0,30})$/i); + if (!match) { + hideDropdown(); + return; + } + const query = match[1]; + mentionStart = pos - query.length - 1; + activeTextarea = textarea; + if (query.length < 1) { + hideDropdown(); + return; + } + search(query); + } + + window.attachMentionAutocomplete = function(textarea) { + if (!textarea || textarea.dataset.mentionAttached === '1') return; + textarea.dataset.mentionAttached = '1'; + + textarea.addEventListener('input', () => detectMention(textarea)); + textarea.addEventListener('keyup', e => { + if (['ArrowUp','ArrowDown','Enter','Escape','Tab'].indexOf(e.key) === -1) detectMention(textarea); + }); + textarea.addEventListener('keydown', e => { + if (!dropdown || dropdown.style.display === 'none' || !currentResults.length) return; + if (e.key === 'ArrowDown') { + e.preventDefault(); + activeIndex = (activeIndex + 1) % currentResults.length; + updateActiveItem(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + activeIndex = (activeIndex - 1 + currentResults.length) % currentResults.length; + updateActiveItem(); + } else if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault(); + selectUser(activeIndex); + } else if (e.key === 'Escape') { + hideDropdown(); + } + }); + textarea.addEventListener('blur', () => setTimeout(hideDropdown, 150)); + }; + + window.insertMentionTrigger = function(textarea) { + if (!textarea) return; + textarea.focus(); + const pos = textarea.selectionStart; + const before = textarea.value.substring(0, pos); + const after = textarea.value.substring(pos); + const needsSpace = before.length > 0 && !/\s$/.test(before); + const insert = (needsSpace ? ' ' : '') + '@'; + textarea.value = before + insert + after; + const newPos = pos + insert.length; + textarea.setSelectionRange(newPos, newPos); + textarea.dispatchEvent(new Event('input')); + }; +})(); diff --git a/templates/forum/new_topic.html b/templates/forum/new_topic.html index 1020a95..cb0fa1b 100755 --- a/templates/forum/new_topic.html +++ b/templates/forum/new_topic.html @@ -290,6 +290,9 @@ +
+ +
-

Minimum 10 znaków. Im więcej szczegółów, tym lepsze odpowiedzi.

+

Minimum 10 znaków. Wpisz @ aby wspomnieć użytkownika.

@@ -351,6 +354,20 @@ {% endblock %} +{% block extra_scripts %} + + +{% endblock %} + {% block extra_js %} function showToast(message, type = 'info', duration = 4000) { const container = document.getElementById('toastContainer'); diff --git a/templates/forum/topic.html b/templates/forum/topic.html index 4134e26..2e0f86a 100755 --- a/templates/forum/topic.html +++ b/templates/forum/topic.html @@ -1332,9 +1332,12 @@

Dodaj odpowiedź

+
+ +
- Formatowanie: **pogrubienie**, *kursywa*, `kod`, [link](url), @wzmianka, > cytat + Formatowanie: **pogrubienie**, *kursywa*, `kod`, [link](url), @wzmianka (wpisz @ i imię), > cytat
@@ -1349,8 +1352,18 @@
+