- Modello Tag con nome e colore, M2M su Conversazione - Modello Appuntamento con luogo, note, partecipanti, link a Conversazione - @menzioni nei commenti e aggiornamenti: @username → link al profilo - Autocomplete JS per @menzioni nelle textarea - Auto-data conversazioni (default=now) - CRUD completo appuntamenti con permessi autore - Appuntamenti in agenda, dashboard, dettaglio conversazione - Crea riunione direttamente da una conversazione (pre-compila titolo e partecipanti) - Admin: Tag, Appuntamento registrati
254 lines
9.0 KiB
JavaScript
254 lines
9.0 KiB
JavaScript
/* ── Olimpic Nastri — App JS v2 ─────────────────────────────────────────── */
|
|
|
|
// ── CSRF helper ──
|
|
function getCsrf() {
|
|
return document.cookie.split('; ')
|
|
.find(r => r.startsWith('csrftoken='))
|
|
?.split('=')[1] ?? '';
|
|
}
|
|
|
|
// ── Toast notifications ──
|
|
function showToast(message, type = 'success') {
|
|
const container = document.getElementById('toast-container');
|
|
if (!container) return;
|
|
|
|
const icons = {
|
|
success: 'bi-check-circle-fill',
|
|
danger: 'bi-exclamation-triangle-fill',
|
|
info: 'bi-info-circle-fill',
|
|
};
|
|
const colors = {
|
|
success: '#15803d',
|
|
danger: '#dc2626',
|
|
info: '#4361ee',
|
|
};
|
|
|
|
const id = 'toast-' + Date.now();
|
|
const html = `
|
|
<div id="${id}" class="toast align-items-center text-white border-0 mb-2" role="alert"
|
|
style="background:${colors[type] || colors.success};">
|
|
<div class="d-flex">
|
|
<div class="toast-body d-flex align-items-center gap-2">
|
|
<i class="bi ${icons[type] || icons.success}"></i>
|
|
${message}
|
|
</div>
|
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
|
</div>
|
|
</div>`;
|
|
container.insertAdjacentHTML('beforeend', html);
|
|
const toastEl = document.getElementById(id);
|
|
const bsToast = new bootstrap.Toast(toastEl, { delay: 3000 });
|
|
bsToast.show();
|
|
toastEl.addEventListener('hidden.bs.toast', () => toastEl.remove());
|
|
}
|
|
|
|
// ── Convert Django messages to toasts on page load ──
|
|
function convertMessagesToToasts() {
|
|
document.querySelectorAll('[data-toast-message]').forEach(el => {
|
|
showToast(el.dataset.toastMessage, el.dataset.toastType || 'success');
|
|
el.remove();
|
|
});
|
|
}
|
|
|
|
// ── Progress slider AJAX ──
|
|
function initSliders() {
|
|
document.querySelectorAll('.progress-slider').forEach(slider => {
|
|
if (slider._sliderInit) return;
|
|
slider._sliderInit = true;
|
|
|
|
const id = slider.dataset.id;
|
|
const lbl = document.getElementById('lbl-' + id);
|
|
const saving = document.getElementById('saving-' + id);
|
|
let timer = null;
|
|
|
|
slider.addEventListener('input', () => {
|
|
const v = slider.value;
|
|
if (lbl) lbl.textContent = v + '%';
|
|
slider.style.setProperty('--val', v + '%');
|
|
});
|
|
|
|
slider.addEventListener('change', () => {
|
|
clearTimeout(timer);
|
|
if (saving) {
|
|
saving.classList.remove('d-none');
|
|
saving.textContent = 'salvataggio…';
|
|
}
|
|
timer = setTimeout(() => {
|
|
fetch(slider.dataset.url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'X-CSRFToken': getCsrf(),
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
body: 'avanzamento=' + slider.value,
|
|
})
|
|
.then(r => r.json())
|
|
.then(d => {
|
|
if (saving) {
|
|
saving.textContent = d.ok ? '✓ salvato' : '⚠ errore';
|
|
setTimeout(() => saving.classList.add('d-none'), 1500);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (saving) {
|
|
saving.textContent = '⚠ errore';
|
|
setTimeout(() => saving.classList.add('d-none'), 1500);
|
|
}
|
|
});
|
|
}, 400);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── Live search dropdown ──
|
|
function initLiveSearch() {
|
|
const input = document.getElementById('search-input');
|
|
const dropdown = document.getElementById('search-dropdown');
|
|
if (!input || !dropdown) return;
|
|
|
|
let timer = null;
|
|
|
|
input.addEventListener('input', () => {
|
|
const q = input.value.trim();
|
|
clearTimeout(timer);
|
|
|
|
if (q.length < 2) {
|
|
dropdown.classList.remove('show');
|
|
dropdown.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
timer = setTimeout(() => {
|
|
fetch('/ricerca/?q=' + encodeURIComponent(q), {
|
|
headers: { 'HX-Request': 'true' }
|
|
})
|
|
.then(r => r.text())
|
|
.then(html => {
|
|
dropdown.innerHTML = html;
|
|
dropdown.classList.add('show');
|
|
});
|
|
}, 300);
|
|
});
|
|
|
|
// Close dropdown on click outside
|
|
document.addEventListener('click', (e) => {
|
|
if (!input.contains(e.target) && !dropdown.contains(e.target)) {
|
|
dropdown.classList.remove('show');
|
|
}
|
|
});
|
|
|
|
// Navigate to full search on Enter
|
|
input.closest('form')?.addEventListener('submit', (e) => {
|
|
dropdown.classList.remove('show');
|
|
});
|
|
}
|
|
|
|
// ── Init everything ──
|
|
function initApp() {
|
|
convertMessagesToToasts();
|
|
initSliders();
|
|
initLiveSearch();
|
|
initMentions();
|
|
}
|
|
|
|
// ── @Mentions autocomplete ──
|
|
function initMentions() {
|
|
document.querySelectorAll('textarea.form-control').forEach(textarea => {
|
|
if (textarea._mentionInit) return;
|
|
textarea._mentionInit = true;
|
|
|
|
let dropdown = textarea.parentElement.querySelector('.mention-dropdown');
|
|
if (!dropdown) {
|
|
dropdown = document.createElement('div');
|
|
dropdown.className = 'mention-dropdown';
|
|
textarea.parentElement.style.position = 'relative';
|
|
textarea.parentElement.appendChild(dropdown);
|
|
}
|
|
|
|
let mentionStart = -1;
|
|
let timer = null;
|
|
|
|
textarea.addEventListener('input', () => {
|
|
const pos = textarea.selectionStart;
|
|
const text = textarea.value.substring(0, pos);
|
|
const atMatch = text.match(/@(\w[\w.]*)$/);
|
|
|
|
if (!atMatch) {
|
|
dropdown.classList.remove('show');
|
|
mentionStart = -1;
|
|
return;
|
|
}
|
|
|
|
mentionStart = pos - atMatch[0].length;
|
|
const query = atMatch[1];
|
|
|
|
clearTimeout(timer);
|
|
timer = setTimeout(() => {
|
|
fetch('/api/utenti/?q=' + encodeURIComponent(query))
|
|
.then(r => r.json())
|
|
.then(users => {
|
|
if (!users.length) {
|
|
dropdown.classList.remove('show');
|
|
return;
|
|
}
|
|
dropdown.innerHTML = users.map(u =>
|
|
`<div class="mention-item" data-username="${u.username}" data-nome="${u.nome}">
|
|
<span class="avatar" style="width:22px;height:22px;font-size:.6rem;">${u.username.substring(0,2).toUpperCase()}</span>
|
|
<span>${u.nome}</span>
|
|
<small class="text-muted">@${u.username}</small>
|
|
</div>`
|
|
).join('');
|
|
dropdown.classList.add('show');
|
|
});
|
|
}, 200);
|
|
});
|
|
|
|
dropdown.addEventListener('click', (e) => {
|
|
const item = e.target.closest('.mention-item');
|
|
if (!item) return;
|
|
const username = item.dataset.username;
|
|
const before = textarea.value.substring(0, mentionStart);
|
|
const after = textarea.value.substring(textarea.selectionStart);
|
|
textarea.value = before + '@' + username + ' ' + after;
|
|
textarea.focus();
|
|
const newPos = mentionStart + username.length + 2;
|
|
textarea.setSelectionRange(newPos, newPos);
|
|
dropdown.classList.remove('show');
|
|
});
|
|
|
|
textarea.addEventListener('blur', () => {
|
|
setTimeout(() => dropdown.classList.remove('show'), 200);
|
|
});
|
|
|
|
textarea.addEventListener('keydown', (e) => {
|
|
if (!dropdown.classList.contains('show')) return;
|
|
const items = dropdown.querySelectorAll('.mention-item');
|
|
const active = dropdown.querySelector('.mention-item.active');
|
|
let idx = Array.from(items).indexOf(active);
|
|
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
if (active) active.classList.remove('active');
|
|
idx = (idx + 1) % items.length;
|
|
items[idx].classList.add('active');
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
if (active) active.classList.remove('active');
|
|
idx = idx <= 0 ? items.length - 1 : idx - 1;
|
|
items[idx].classList.add('active');
|
|
} else if (e.key === 'Enter' && active) {
|
|
e.preventDefault();
|
|
active.click();
|
|
} else if (e.key === 'Escape') {
|
|
dropdown.classList.remove('show');
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Run on initial page load
|
|
document.addEventListener('DOMContentLoaded', initApp);
|
|
|
|
// Re-init after HTMX swaps
|
|
document.addEventListener('htmx:afterSettle', initApp);
|