feat(forum): @mention autocomplete with user picker
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
a68910d029
commit
dc6c711264
194
static/js/mention-autocomplete.js
Normal file
194
static/js/mention-autocomplete.js
Normal file
@ -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 = '<div style="padding:10px 14px;color:var(--text-muted,#666);font-size:0.9em;">Brak wyników</div>';
|
||||
dropdown.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
dropdown.innerHTML = results.map((u, i) => {
|
||||
const avatar = u.avatar_url
|
||||
? `<img src="${u.avatar_url}" style="width:32px;height:32px;border-radius:50%;object-fit:cover;flex-shrink:0;">`
|
||||
: `<div style="width:32px;height:32px;border-radius:50%;background:var(--primary,#0a6);color:#fff;display:flex;align-items:center;justify-content:center;font-weight:600;flex-shrink:0;">${(u.name || '?')[0].toUpperCase()}</div>`;
|
||||
const company = u.company_name ? `<div style="font-size:0.8em;color:var(--text-muted,#666);">${escapeHtml(u.company_name)}</div>` : '';
|
||||
return `<div class="mention-item" data-idx="${i}" style="display:flex;align-items:center;gap:10px;padding:8px 12px;cursor:pointer;${i === activeIndex ? 'background:var(--primary-light,#eef);' : ''}">
|
||||
${avatar}
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-weight:500;">${escapeHtml(u.name)}</div>
|
||||
${company}
|
||||
</div>
|
||||
</div>`;
|
||||
}).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'));
|
||||
};
|
||||
})();
|
||||
@ -290,6 +290,9 @@
|
||||
<label for="content" class="form-label">
|
||||
Treść <span class="required">*</span>
|
||||
</label>
|
||||
<div style="display:flex;gap:6px;margin-bottom:6px;">
|
||||
<button type="button" class="btn btn-outline btn-sm" id="mentionBtnNew" title="Wspomnij użytkownika (@)" style="padding:4px 10px;font-weight:600;">@</button>
|
||||
</div>
|
||||
<textarea
|
||||
id="content"
|
||||
name="content"
|
||||
@ -298,7 +301,7 @@
|
||||
required
|
||||
minlength="10"
|
||||
></textarea>
|
||||
<p class="form-hint">Minimum 10 znaków. Im więcej szczegółów, tym lepsze odpowiedzi.</p>
|
||||
<p class="form-hint">Minimum 10 znaków. Wpisz @ aby wspomnieć użytkownika.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@ -351,6 +354,20 @@
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('static', filename='js/mention-autocomplete.js') }}"></script>
|
||||
<script>
|
||||
(function() {
|
||||
var ta = document.getElementById('content');
|
||||
var mentionBtn = document.getElementById('mentionBtnNew');
|
||||
if (ta && window.attachMentionAutocomplete) window.attachMentionAutocomplete(ta);
|
||||
if (mentionBtn && ta && window.insertMentionTrigger) {
|
||||
mentionBtn.addEventListener('click', function() { window.insertMentionTrigger(ta); });
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
function showToast(message, type = 'info', duration = 4000) {
|
||||
const container = document.getElementById('toastContainer');
|
||||
|
||||
@ -1332,9 +1332,12 @@
|
||||
<form class="reply-form" method="POST" action="{{ url_for('forum_reply', topic_id=topic.id) }}" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<h3>Dodaj odpowiedź</h3>
|
||||
<div style="display:flex;gap:6px;margin-bottom:6px;">
|
||||
<button type="button" class="btn btn-outline btn-sm" id="mentionBtn" title="Wspomnij użytkownika (@)" style="padding:4px 10px;font-weight:600;">@</button>
|
||||
</div>
|
||||
<textarea name="content" id="replyContent" placeholder="Twoja odpowiedź..." required></textarea>
|
||||
<div style="font-size: var(--font-size-xs); color: var(--text-muted); margin-top: var(--spacing-xs);">
|
||||
Formatowanie: **pogrubienie**, *kursywa*, `kod`, [link](url), @wzmianka, > cytat
|
||||
Formatowanie: **pogrubienie**, *kursywa*, `kod`, [link](url), @wzmianka (wpisz @ i imię), > cytat
|
||||
</div>
|
||||
|
||||
<div class="upload-counter" id="uploadCounter"></div>
|
||||
@ -1349,8 +1352,18 @@
|
||||
<button type="submit" class="btn btn-primary" id="replySubmitBtn">Wyślij odpowiedź</button>
|
||||
</div>
|
||||
</form>
|
||||
<script src="{{ url_for('static', filename='js/mention-autocomplete.js') }}"></script>
|
||||
<script>
|
||||
(function() {
|
||||
var ta = document.getElementById('replyContent');
|
||||
var mentionBtn = document.getElementById('mentionBtn');
|
||||
if (ta && window.attachMentionAutocomplete) {
|
||||
window.attachMentionAutocomplete(ta);
|
||||
}
|
||||
if (mentionBtn && ta && window.insertMentionTrigger) {
|
||||
mentionBtn.addEventListener('click', function() { window.insertMentionTrigger(ta); });
|
||||
}
|
||||
|
||||
var form = document.querySelector('.reply-form');
|
||||
var btn = document.getElementById('replySubmitBtn');
|
||||
if (form && btn) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user