feat: add reusable new discussion modal with @mention autocompletion support
This commit is contained in:
parent
1edfb85a0e
commit
fb9664b65a
14 changed files with 669 additions and 73 deletions
|
|
@ -16,6 +16,34 @@
|
|||
<div class="background-layer"></div>
|
||||
<div class="bg-image">
|
||||
<div class="container content-box">
|
||||
{% if unread_notifications_count %}
|
||||
<div id="home-unread-notifications-alert" class="row justify-content-center mt-3 mb-2">
|
||||
<div class="col-md-10 col-lg-8">
|
||||
<div class="alert alert-warning border-0 shadow-sm d-flex align-items-center justify-content-between p-3 rounded-3" role="alert" style="background-color: rgba(255, 243, 205, 0.95);">
|
||||
<div class="d-flex align-items-center text-start me-3">
|
||||
<div class="rounded-circle bg-warning bg-opacity-25 p-2 me-3 d-flex align-items-center justify-content-center" style="width: 42px; height: 42px; flex-shrink: 0;">
|
||||
<i class="bi bi-bell-fill fs-5 text-warning-emphasis"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-bold text-dark">
|
||||
{% translate "Notifications non lues" %}
|
||||
</div>
|
||||
<div class="small text-secondary">
|
||||
{% blocktranslate count count=unread_notifications_count %}
|
||||
Vous avez <strong class="home-unread-notifications-count">{{ count }}</strong> notification non lue.
|
||||
{% plural %}
|
||||
Vous avez <strong class="home-unread-notifications-count">{{ count }}</strong> notifications non lues.
|
||||
{% endblocktranslate %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{% url 'notifications:inbox' %}" class="btn btn-warning btn-sm px-3 py-2 fw-semibold text-nowrap rounded-2 text-dark">
|
||||
<i class="bi bi-envelope-open me-1"></i>{% translate "Consulter" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- <h1 class="display-4 m-3 halo-text"><strong>Brussels Mobility</strong></h1> -->
|
||||
<div class="row justify-content-center mt-2 mb-3">
|
||||
<div class="col-md-12">
|
||||
|
|
@ -56,6 +84,9 @@
|
|||
<i class="bi bi-currency-euro me-2"></i>{% translate "Nouvelle commande" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<button class="btn btn-light main-button my-1 ms-1" type="button" data-bs-toggle="modal" data-bs-target="#newDiscussionModal">
|
||||
<i class="bi bi-chat-dots me-2"></i>{% translate "Nouvelle discussion" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -101,10 +132,18 @@
|
|||
{% empty %}
|
||||
{% include "home/widgets/_default.html" %}
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating Discussion FAB -->
|
||||
<button id="home-discussion-fab" class="btn btn-primary rounded-circle shadow-lg d-flex align-items-center justify-content-center"
|
||||
type="button" data-bs-toggle="modal" data-bs-target="#newDiscussionModal"
|
||||
style="position: fixed; bottom: 28px; right: 28px; width: 58px; height: 58px; z-index: 1050; font-size: 1.5rem;"
|
||||
title="{% translate 'Nouvelle discussion' %}" aria-label="{% translate 'Nouvelle discussion' %}">
|
||||
<i class="bi bi-chat-dots-fill"></i>
|
||||
</button>
|
||||
|
||||
{% include "notifications/_new_discussion_modal.html" %}
|
||||
{% endblock content%}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -267,3 +267,29 @@ class HomeIndexViewTests(TestCase):
|
|||
self.assertContains(response, reverse('inspections:quick_inspection_map'))
|
||||
self.assertNotContains(response, 'data-bs-target="#newInspectionModal"')
|
||||
|
||||
def test_home_index_unread_notifications_banner(self):
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth import get_user_model
|
||||
from common.models import UserConfig
|
||||
from notifications.models import Notification, NotificationRecipient
|
||||
|
||||
User = get_user_model()
|
||||
user = User.objects.create_user('notif_user', password='pass')
|
||||
UserConfig.objects.create(user=user)
|
||||
self.client.force_login(user)
|
||||
|
||||
# 1. No unread notifications -> banner not shown
|
||||
response = self.client.get(reverse('home:index'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotContains(response, 'id="home-unread-notifications-alert"')
|
||||
|
||||
# 2. Add unread notification -> banner shown with count
|
||||
notif = Notification.objects.create(title="Test notification")
|
||||
NotificationRecipient.objects.create(notification=notif, user=user)
|
||||
|
||||
response = self.client.get(reverse('home:index'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'id="home-unread-notifications-alert"')
|
||||
self.assertContains(response, 'Vous avez <strong class="home-unread-notifications-count">1</strong> notification non lue.')
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -812,9 +812,11 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||
let mentionStart = -1;
|
||||
let mentionQuery = null;
|
||||
let searchTimer = null;
|
||||
let currentResults = [];
|
||||
let selectedIndex = -1;
|
||||
|
||||
function escHtml(str) {
|
||||
return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
return (str || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function syncReal() {
|
||||
|
|
@ -842,10 +844,25 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||
mentions = mentions.filter(m => m.end <= start || m.start >= end);
|
||||
}
|
||||
|
||||
function highlightIndex(idx) {
|
||||
const items = dropdown.querySelectorAll('.list-group-item');
|
||||
if (!items || !items.length) return;
|
||||
items.forEach((btn, i) => {
|
||||
if (i === idx) {
|
||||
btn.classList.add('active');
|
||||
btn.scrollIntoView({ block: 'nearest' });
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showDropdown(results) {
|
||||
dropdown.innerHTML = '';
|
||||
currentResults = results || [];
|
||||
selectedIndex = -1;
|
||||
if (!results || !results.length) { hideDropdown(); return; }
|
||||
results.forEach(r => {
|
||||
results.forEach((r, idx) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'list-group-item list-group-item-action d-flex align-items-center gap-2 py-2 small';
|
||||
|
|
@ -858,8 +875,15 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||
e.preventDefault();
|
||||
insertMention(r);
|
||||
});
|
||||
btn.addEventListener('mouseenter', function () {
|
||||
selectedIndex = idx;
|
||||
highlightIndex(selectedIndex);
|
||||
});
|
||||
dropdown.appendChild(btn);
|
||||
});
|
||||
|
||||
selectedIndex = 0;
|
||||
highlightIndex(0);
|
||||
dropdown.style.display = 'block';
|
||||
}
|
||||
|
||||
|
|
@ -868,6 +892,8 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||
dropdown.innerHTML = '';
|
||||
mentionStart = -1;
|
||||
mentionQuery = null;
|
||||
currentResults = [];
|
||||
selectedIndex = -1;
|
||||
}
|
||||
|
||||
function insertMention(r) {
|
||||
|
|
@ -890,6 +916,35 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||
hideDropdown();
|
||||
}
|
||||
|
||||
textarea.addEventListener('keydown', function (e) {
|
||||
if (dropdown.style.display === 'block' && currentResults.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
selectedIndex = (selectedIndex + 1) % currentResults.length;
|
||||
highlightIndex(selectedIndex);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
selectedIndex = (selectedIndex - 1 + currentResults.length) % currentResults.length;
|
||||
highlightIndex(selectedIndex);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
if (selectedIndex >= 0 && selectedIndex < currentResults.length) {
|
||||
e.preventDefault();
|
||||
insertMention(currentResults[selectedIndex]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
hideDropdown();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function searchMentions(q) {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,33 @@
|
|||
|
||||
{% block content %}
|
||||
<div class="row g-3">
|
||||
{% if unread_notifications_count %}
|
||||
<div id="home-unread-notifications-alert" class="col-12">
|
||||
<div class="alert alert-warning border-0 shadow-sm d-flex align-items-center justify-content-between p-3 mb-0 rounded-4" role="alert" style="background-color: rgba(255, 243, 205, 0.95);">
|
||||
<div class="d-flex align-items-center text-start me-2">
|
||||
<div class="rounded-circle bg-warning bg-opacity-25 p-2 me-2.5 d-flex align-items-center justify-content-center" style="width: 38px; height: 38px; flex-shrink: 0;">
|
||||
<i class="bi bi-bell-fill fs-5 text-warning-emphasis"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-bold text-dark small">
|
||||
{% translate "Notifications non lues" %}
|
||||
</div>
|
||||
<div class="small text-secondary" style="font-size: 0.825rem;">
|
||||
{% blocktranslate count count=unread_notifications_count %}
|
||||
Vous avez <strong class="home-unread-notifications-count">{{ count }}</strong> notification non lue.
|
||||
{% plural %}
|
||||
Vous avez <strong class="home-unread-notifications-count">{{ count }}</strong> notifications non lues.
|
||||
{% endblocktranslate %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{% url 'mobile:notifications_mobile' %}" class="btn btn-warning btn-sm px-3 py-2 fw-semibold text-nowrap rounded-3 text-dark">
|
||||
{% translate "Consulter" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- New items / Quick Actions -->
|
||||
{% if user.config|has_any_role:"observer,manager,controller,admin,external_manager,top_manager" or is_inspector or can_add_structures_repair or can_add_intervention_mobile %}
|
||||
<div class="col-12">
|
||||
|
|
@ -40,6 +67,11 @@
|
|||
<i class="bi bi-plus-lg"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<button type="button" data-bs-toggle="modal" data-bs-target="#newDiscussionModal" class="btn btn-outline-primary py-2.5 rounded-3 fw-semibold text-start d-flex align-items-center justify-content-between">
|
||||
<span><i class="bi bi-chat-dots me-2"></i>{% translate "Nouvelle discussion" %}</span>
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -129,4 +161,5 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include "notifications/_new_discussion_modal.html" %}
|
||||
{% endblock content %}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,266 @@
|
|||
{% load i18n %}
|
||||
<!-- Modal Nouvelle Discussion -->
|
||||
<div class="modal fade" id="newDiscussionModal" tabindex="-1" aria-labelledby="newDiscussionModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow rounded-4">
|
||||
<div class="modal-header border-0 pb-0">
|
||||
<h5 class="modal-title fw-bold" id="newDiscussionModalLabel">
|
||||
<i class="bi bi-chat-left-text-fill text-primary me-2"></i>{% translate "Nouvelle discussion" %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
|
||||
</div>
|
||||
<form method="post" action="{% url 'notifications:discussion_create' %}" enctype="multipart/form-data" id="new-discussion-form">
|
||||
{% csrf_token %}
|
||||
<div class="modal-body pt-3">
|
||||
<div class="mb-3">
|
||||
<label for="new-discussion-title" class="form-label small fw-semibold text-secondary">
|
||||
{% translate "Sujet / Titre (optionnel)" %}
|
||||
</label>
|
||||
<input type="text" class="form-control rounded-3" id="new-discussion-title" name="title"
|
||||
placeholder="{% translate 'Ex: Question sur le dossier...' %}">
|
||||
</div>
|
||||
|
||||
<div class="mb-3 position-relative">
|
||||
<label for="new-discussion-content-display" class="form-label small fw-semibold text-secondary">
|
||||
{% translate "Message *" %}
|
||||
</label>
|
||||
<div class="form-text small text-muted mb-1">
|
||||
<i class="bi bi-info-circle me-1"></i>{% translate "Tapez @ pour mentionner/tagger des utilisateurs ou des équipes." %}
|
||||
</div>
|
||||
<input type="hidden" name="content" id="new-discussion-content-real">
|
||||
<textarea class="form-control rounded-3 p-3" id="new-discussion-content-display" rows="4"
|
||||
placeholder="{% translate 'Rédigez votre message... (@ pour tagger quelqu\'un)' %}" required style="resize:none;"></textarea>
|
||||
|
||||
<!-- Autocomplete dropdown pour @mentions -->
|
||||
<div id="new-discussion-mention-dropdown" class="list-group position-absolute shadow-lg border-0 rounded-3"
|
||||
style="display:none; z-index:1060; max-height:200px; overflow-y:auto; width:100%; top: 100%;"></div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-semibold text-secondary d-block">
|
||||
{% translate "Pièces jointes" %}
|
||||
</label>
|
||||
<label class="btn btn-outline-secondary btn-sm rounded-pill px-3 mb-1" for="new-discussion-file-input">
|
||||
<i class="bi bi-paperclip me-1"></i>{% translate "Joindre des fichiers" %}
|
||||
</label>
|
||||
<input type="file" name="attachments" multiple class="d-none" id="new-discussion-file-input">
|
||||
<div id="new-discussion-file-preview" class="d-flex flex-wrap gap-2 mt-2" style="display:none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-0 pt-0">
|
||||
<button type="button" class="btn btn-light rounded-pill px-3" data-bs-dismiss="modal">
|
||||
{% translate "Annuler" %}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary rounded-pill px-4">
|
||||
<i class="bi bi-send-fill me-1.5"></i>{% translate "Envoyer" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const modalEl = document.getElementById('newDiscussionModal');
|
||||
if (modalEl && modalEl.parentElement !== document.body) {
|
||||
document.body.appendChild(modalEl);
|
||||
}
|
||||
|
||||
const textarea = document.getElementById('new-discussion-content-display');
|
||||
const realInput = document.getElementById('new-discussion-content-real');
|
||||
const dropdown = document.getElementById('new-discussion-mention-dropdown');
|
||||
const form = document.getElementById('new-discussion-form');
|
||||
const fileInput = document.getElementById('new-discussion-file-input');
|
||||
const filePreview = document.getElementById('new-discussion-file-preview');
|
||||
|
||||
if (!textarea || !dropdown || !realInput || !form) return;
|
||||
|
||||
let mentions = [];
|
||||
let mentionStart = -1;
|
||||
let mentionQuery = null;
|
||||
let searchTimer = null;
|
||||
let currentResults = [];
|
||||
let selectedIndex = -1;
|
||||
|
||||
function escHtml(str) {
|
||||
return (str || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function syncReal() {
|
||||
const dv = textarea.value;
|
||||
if (!mentions.length) { realInput.value = dv; return; }
|
||||
let real = '';
|
||||
let pos = 0;
|
||||
const sorted = [...mentions].sort((a, b) => a.start - b.start);
|
||||
for (const m of sorted) {
|
||||
if (m.start > pos) real += dv.slice(pos, m.start);
|
||||
real += m.marker;
|
||||
pos = m.end;
|
||||
}
|
||||
real += dv.slice(pos);
|
||||
realInput.value = real;
|
||||
}
|
||||
|
||||
function shiftMentions(fromPos, delta) {
|
||||
for (const m of mentions) {
|
||||
if (m.start >= fromPos) { m.start += delta; m.end += delta; }
|
||||
}
|
||||
}
|
||||
|
||||
function removeMentionsIn(start, end) {
|
||||
mentions = mentions.filter(m => m.end <= start || m.start >= end);
|
||||
}
|
||||
|
||||
function highlightIndex(idx) {
|
||||
const items = dropdown.querySelectorAll('.list-group-item');
|
||||
if (!items || !items.length) return;
|
||||
items.forEach((btn, i) => {
|
||||
if (i === idx) {
|
||||
btn.classList.add('active');
|
||||
btn.scrollIntoView({ block: 'nearest' });
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showDropdown(results) {
|
||||
dropdown.innerHTML = '';
|
||||
currentResults = results || [];
|
||||
selectedIndex = -1;
|
||||
if (!results || !results.length) { hideDropdown(); return; }
|
||||
results.forEach((r, idx) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'list-group-item list-group-item-action d-flex align-items-center gap-2 py-2 small';
|
||||
const icon = r.type === 'user' ? 'bi-person-fill' : 'bi-people-fill';
|
||||
const badgeCls = r.type === 'user'
|
||||
? 'badge bg-primary-subtle text-primary-emphasis border border-primary-subtle'
|
||||
: 'badge bg-info-subtle text-info-emphasis border border-info-subtle';
|
||||
btn.innerHTML = `<span class="${badgeCls}"><i class="bi ${icon}"></i></span><span>${escHtml(r.display)}</span>`;
|
||||
btn.addEventListener('mousedown', function (e) {
|
||||
e.preventDefault();
|
||||
insertMention(r);
|
||||
});
|
||||
btn.addEventListener('mouseenter', function () {
|
||||
selectedIndex = idx;
|
||||
highlightIndex(selectedIndex);
|
||||
});
|
||||
dropdown.appendChild(btn);
|
||||
});
|
||||
|
||||
selectedIndex = 0;
|
||||
highlightIndex(0);
|
||||
dropdown.style.display = 'block';
|
||||
}
|
||||
|
||||
function hideDropdown() {
|
||||
dropdown.style.display = 'none';
|
||||
dropdown.innerHTML = '';
|
||||
mentionStart = -1;
|
||||
mentionQuery = null;
|
||||
currentResults = [];
|
||||
selectedIndex = -1;
|
||||
}
|
||||
|
||||
function insertMention(r) {
|
||||
const shortTag = `@[${r.display}]`;
|
||||
const marker = r.type === 'user'
|
||||
? `@[${r.display}](u:${r.id})`
|
||||
: `@[${r.display}](t:${r.id})`;
|
||||
const dv = textarea.value;
|
||||
const qLen = mentionQuery ? mentionQuery.length : 0;
|
||||
const before = dv.slice(0, mentionStart);
|
||||
const after = dv.slice(mentionStart + 1 + qLen);
|
||||
textarea.value = before + shortTag + ' ' + after;
|
||||
removeMentionsIn(mentionStart, mentionStart + 1 + qLen);
|
||||
shiftMentions(mentionStart + 1 + qLen, shortTag.length + 1 - (1 + qLen));
|
||||
mentions.push({ start: mentionStart, end: mentionStart + shortTag.length, marker });
|
||||
syncReal();
|
||||
const pos = mentionStart + shortTag.length + 1;
|
||||
textarea.setSelectionRange(pos, pos);
|
||||
textarea.focus();
|
||||
hideDropdown();
|
||||
}
|
||||
|
||||
textarea.addEventListener('keydown', function (e) {
|
||||
if (dropdown.style.display === 'block' && currentResults.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
selectedIndex = (selectedIndex + 1) % currentResults.length;
|
||||
highlightIndex(selectedIndex);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
selectedIndex = (selectedIndex - 1 + currentResults.length) % currentResults.length;
|
||||
highlightIndex(selectedIndex);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
if (selectedIndex >= 0 && selectedIndex < currentResults.length) {
|
||||
e.preventDefault();
|
||||
insertMention(currentResults[selectedIndex]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
hideDropdown();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
textarea.addEventListener('input', function () {
|
||||
const val = textarea.value;
|
||||
const cursorPos = textarea.selectionStart;
|
||||
|
||||
const lastAt = val.lastIndexOf('@', cursorPos - 1);
|
||||
if (lastAt !== -1) {
|
||||
const query = val.slice(lastAt + 1, cursorPos);
|
||||
if (!/\s/.test(query) && query.length <= 30) {
|
||||
mentionStart = lastAt;
|
||||
mentionQuery = query;
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
fetch("{% url 'notifications:api_mention_search' %}?q=" + encodeURIComponent(query))
|
||||
.then(r => r.json())
|
||||
.then(data => showDropdown(data.results))
|
||||
.catch(() => hideDropdown());
|
||||
}, 150);
|
||||
syncReal();
|
||||
return;
|
||||
}
|
||||
}
|
||||
hideDropdown();
|
||||
syncReal();
|
||||
});
|
||||
|
||||
form.addEventListener('submit', function () {
|
||||
syncReal();
|
||||
});
|
||||
|
||||
// Aperçu pièces jointes
|
||||
if (fileInput && filePreview) {
|
||||
fileInput.addEventListener('change', function () {
|
||||
filePreview.innerHTML = '';
|
||||
const files = fileInput.files;
|
||||
if (!files || files.length === 0) {
|
||||
filePreview.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
filePreview.style.display = 'flex';
|
||||
Array.from(files).forEach((file) => {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'badge bg-light text-dark border px-2 py-1 small';
|
||||
chip.innerHTML = `<i class="bi bi-paperclip me-1"></i>${escHtml(file.name)}`;
|
||||
filePreview.appendChild(chip);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
|
@ -1,35 +1,41 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load mention_tags %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.thread-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.msg-bubble {
|
||||
max-width: 80%;
|
||||
max-width: 85%;
|
||||
display: inline-block;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
font-size: 0.935rem;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||
}
|
||||
.msg-mine .msg-bubble {
|
||||
background: var(--brand-color, #2f3e8b);
|
||||
color: #fff;
|
||||
border-radius: 16px 16px 4px 16px;
|
||||
border-radius: 18px 18px 4px 18px;
|
||||
}
|
||||
.msg-other .msg-bubble {
|
||||
background: #f1f3f5;
|
||||
color: #212529;
|
||||
border-radius: 16px 16px 16px 4px;
|
||||
border-radius: 18px 18px 18px 4px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
.msg-mine { align-items: flex-end; }
|
||||
.msg-other { align-items: flex-start; }
|
||||
.avatar-xs {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 0.75rem;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -37,83 +43,111 @@
|
|||
flex-shrink: 0;
|
||||
background: #6c757d;
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
#thread-scroll {
|
||||
max-height: 60vh;
|
||||
max-height: 65vh;
|
||||
overflow-y: auto;
|
||||
background-color: #fafbfc;
|
||||
}
|
||||
.attachment-thumb {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #dee2e6;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.attachment-thumb:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.participant-pill {
|
||||
font-size: 0.825rem;
|
||||
font-weight: 500;
|
||||
padding: 0.35rem 0.65rem;
|
||||
background-color: #ffffff;
|
||||
color: #334155;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4" style="max-width:860px;">
|
||||
<div class="container py-4" style="max-width:880px;">
|
||||
|
||||
<!-- Breadcrumb / Header -->
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<a href="{% url 'notifications:discussion_list' %}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
<div class="d-flex align-items-center gap-3 mb-3">
|
||||
<a href="{% url 'notifications:discussion_list' %}" class="btn btn-sm btn-outline-secondary rounded-circle p-2 d-flex align-items-center justify-content-center" style="width:36px; height:36px;">
|
||||
<i class="bi bi-arrow-left fs-6"></i>
|
||||
</a>
|
||||
<div class="flex-grow-1">
|
||||
<h1 class="h4 mb-0">{{ discussion.title|default:discussion }}</h1>
|
||||
<div class="flex-grow-1 min-w-0">
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<h1 class="h4 mb-0 fw-bold text-dark">{{ discussion.title|default:discussion }}</h1>
|
||||
{% if discussion.object_label %}
|
||||
<a href="{{ discussion.object_url }}" class="small text-muted">
|
||||
<a href="{{ discussion.object_url }}" class="badge bg-primary bg-opacity-10 text-primary border border-primary-subtle text-decoration-none py-1.5 px-2.5 rounded-pill">
|
||||
<i class="bi bi-link-45deg me-1"></i>{{ discussion.object_label }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if discussion.is_closed %}
|
||||
<span class="badge bg-secondary"><i class="bi bi-lock me-1"></i>{% translate "Clôturée" %}</span>
|
||||
<span class="badge bg-secondary rounded-pill py-1.5 px-2.5"><i class="bi bi-lock me-1"></i>{% translate "Clôturée" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Participants -->
|
||||
<div class="mb-3 d-flex flex-wrap gap-1">
|
||||
<div class="mb-3 d-flex flex-wrap gap-1.5 align-items-center">
|
||||
<span class="small text-muted me-1"><i class="bi bi-people me-1"></i>{% translate "Participants" %} :</span>
|
||||
{% for p in discussion.participants.all %}
|
||||
<span class="badge bg-light text-dark border">
|
||||
<i class="bi bi-person me-1"></i>{{ p.get_full_name|default:p.username }}
|
||||
<span class="participant-pill">
|
||||
<i class="bi bi-person-fill text-primary opacity-75"></i>
|
||||
{{ p.get_full_name|default:p.username }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body p-3" id="thread-scroll">
|
||||
<div class="card shadow-sm border-0 mb-3 rounded-3 overflow-hidden">
|
||||
<div class="card-body p-3 p-md-4" id="thread-scroll">
|
||||
{% if not thread_messages %}
|
||||
<p class="text-muted text-center py-4 mb-0">
|
||||
<i class="bi bi-chat-square d-block fs-2 mb-2"></i>
|
||||
{% translate "Aucun message pour l'instant. Soyez le premier à écrire." %}
|
||||
</p>
|
||||
<div class="text-muted text-center py-5">
|
||||
<i class="bi bi-chat-square-dots d-block fs-1 mb-2 text-primary opacity-50"></i>
|
||||
<p class="mb-0">{% translate "Aucun message pour l'instant. Soyez le premier à écrire." %}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="thread-container">
|
||||
{% for msg in thread_messages %}
|
||||
<div class="d-flex flex-column {% if msg.author == request.user %}msg-mine{% else %}msg-other{% endif %}">
|
||||
{% if msg.author != request.user %}
|
||||
<div class="d-flex align-items-end gap-2">
|
||||
<div class="avatar-xs">
|
||||
<div class="d-flex align-items-start gap-2">
|
||||
<div class="avatar-xs mt-1">
|
||||
{{ msg.author.get_short_name|first|upper|default:msg.author.username|first|upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
{% if msg.author != request.user %}
|
||||
<div class="small text-muted mb-1">
|
||||
<div class="small text-muted mb-1 ms-1">
|
||||
<strong>{{ msg.author.get_full_name|default:msg.author.username }}</strong>
|
||||
· {{ msg.created_at|date:"d/m/Y H:i" }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="small text-muted mb-1 text-end">
|
||||
<div class="small text-muted mb-1 text-end me-1">
|
||||
{{ msg.created_at|date:"d/m/Y H:i" }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="msg-bubble p-3">
|
||||
{{ msg.content|linebreaksbr }}
|
||||
{% if msg.author == request.user %}
|
||||
{{ msg.content|render_mentions:True }}
|
||||
{% else %}
|
||||
{{ msg.content|render_mentions }}
|
||||
{% endif %}
|
||||
{% if msg.attachments.exists %}
|
||||
<div class="mt-2 d-flex flex-wrap gap-2">
|
||||
<div class="mt-2.5 pt-2 border-top border-opacity-25 d-flex flex-wrap gap-2">
|
||||
{% for att in msg.attachments.all %}
|
||||
{% if att.is_image %}
|
||||
<a href="{{ att.file.url }}" target="_blank">
|
||||
|
|
@ -121,7 +155,7 @@
|
|||
</a>
|
||||
{% else %}
|
||||
<a href="{{ att.file.url }}" target="_blank"
|
||||
class="btn btn-sm {% if msg.author == request.user %}btn-outline-light{% else %}btn-outline-secondary{% endif %}">
|
||||
class="btn btn-sm {% if msg.author == request.user %}btn-light text-dark{% else %}btn-outline-secondary{% endif %}">
|
||||
<i class="bi bi-paperclip me-1"></i>{{ att.name|truncatechars:24 }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
|
@ -144,26 +178,28 @@
|
|||
{% if not discussion.is_closed %}
|
||||
<form method="post" enctype="multipart/form-data" action="">
|
||||
{% csrf_token %}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card shadow-sm border-0 rounded-3">
|
||||
<div class="card-body p-3">
|
||||
<textarea name="content" class="form-control mb-2" rows="3"
|
||||
placeholder="{% translate 'Répondre...' %}" required id="reply-textarea"></textarea>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<label class="btn btn-outline-secondary btn-sm mb-0" id="attach-label">
|
||||
<textarea name="content" class="form-control mb-2 border-0 bg-light rounded-3 p-3" rows="3"
|
||||
placeholder="{% translate 'Écrire un message... (@ pour mentionner)' %}" required id="reply-textarea" style="resize:none;"></textarea>
|
||||
<div class="d-flex justify-content-between align-items-center pt-2">
|
||||
<label class="btn btn-outline-secondary btn-sm mb-0 rounded-pill px-3" id="attach-label">
|
||||
<i class="bi bi-paperclip me-1"></i>{% translate "Joindre" %}
|
||||
<input type="file" name="attachments" multiple class="d-none" id="attach-input"
|
||||
onchange="document.getElementById('attach-count').textContent = this.files.length ? '(' + this.files.length + ')' : ''">
|
||||
</label>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span id="attach-count" class="small text-muted"></span>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-send me-1"></i>{% translate "Envoyer" %}
|
||||
<button type="submit" class="btn btn-primary px-4 rounded-pill">
|
||||
<i class="bi bi-send-fill me-1.5"></i>{% translate "Envoyer" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="alert alert-secondary"><i class="bi bi-lock me-2"></i>{% translate "Cette discussion est clôturée." %}</div>
|
||||
<div class="alert alert-secondary border-0 shadow-sm rounded-3"><i class="bi bi-lock me-2"></i>{% translate "Cette discussion est clôturée." %}</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -36,10 +36,15 @@
|
|||
<h1 class="h3 mb-0">
|
||||
<i class="bi bi-chat-dots me-2 text-primary"></i>{% translate "Discussions" %}
|
||||
</h1>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newDiscussionModal">
|
||||
<i class="bi bi-chat-plus-fill me-1.5"></i>{% translate "Nouvelle discussion" %}
|
||||
</button>
|
||||
<a href="{% url 'notifications:inbox' %}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-bell me-1"></i>{% translate "Notifications" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not items %}
|
||||
<div class="card shadow-sm">
|
||||
|
|
@ -108,4 +113,5 @@
|
|||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% include "notifications/_new_discussion_modal.html" %}
|
||||
{% endblock content %}
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@
|
|||
<i class="bi bi-hourglass me-1"></i>{% translate "Expire le" %} {{ notif.expires_at|date:"d/m/Y H:i" }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if notif.action_url %}
|
||||
{% if notif.action_url and notif.action_url != request.path and notif.action_url != '/notifications/' and notif.action_url != '/notifications' %}
|
||||
<a href="{{ notif.action_url }}" class="btn btn-sm btn-outline-primary py-0 px-2">
|
||||
{{ notif.action_label|default:notif.action_url }}
|
||||
<i class="bi bi-arrow-right ms-1"></i>
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@
|
|||
{% if item.team_name %}
|
||||
<span class="badge bg-secondary notif-type-pill">{{ item.team_name }}</span>
|
||||
{% endif %}
|
||||
{% if item.mobile_action_url %}
|
||||
{% if item.mobile_action_url and item.mobile_action_url != request.path and item.mobile_action_url != '/notifications/' and item.mobile_action_url != '/notifications' and item.mobile_action_url != '/mobile/notifications/' and item.mobile_action_url != '/mobile/notifications' %}
|
||||
<a href="{{ item.mobile_action_url }}" class="btn btn-sm btn-outline-primary py-0 px-2">
|
||||
{{ notif.action_label|default:item.mobile_action_url }}
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -11,17 +11,19 @@ MENTION_PATTERN = re.compile(r'@\[([^\]]+)\]\(([ut]):(\d+)\)')
|
|||
|
||||
|
||||
@register.filter
|
||||
def render_mentions(value):
|
||||
def render_mentions(value, is_mine=False):
|
||||
"""
|
||||
Renders @[Name](u:id) / @[Name](t:id) mention markers as styled badge spans,
|
||||
and converts newlines to <br> (like linebreaksbr).
|
||||
|
||||
- u: user mention → primary badge
|
||||
- t: team mention → info badge
|
||||
- u: user mention → badge
|
||||
- t: team mention → badge
|
||||
"""
|
||||
if not value:
|
||||
return ''
|
||||
|
||||
is_mine_bool = bool(is_mine and str(is_mine).lower() not in ('false', '0', ''))
|
||||
|
||||
parts = []
|
||||
last_end = 0
|
||||
|
||||
|
|
@ -32,16 +34,24 @@ def render_mentions(value):
|
|||
name = m.group(1)
|
||||
kind = m.group(2)
|
||||
|
||||
if is_mine_bool:
|
||||
if kind == 'u':
|
||||
css = 'badge bg-primary-subtle text-primary-emphasis border border-primary-subtle fw-semibold'
|
||||
css = 'badge bg-white text-primary shadow-sm fw-semibold d-inline-flex align-items-center align-middle mx-1 my-0.5 px-2 py-1 rounded-pill'
|
||||
icon = 'bi-person-fill text-primary'
|
||||
else:
|
||||
css = 'badge bg-white text-info shadow-sm fw-semibold d-inline-flex align-items-center align-middle mx-1 my-0.5 px-2 py-1 rounded-pill'
|
||||
icon = 'bi-people-fill text-info'
|
||||
else:
|
||||
if kind == 'u':
|
||||
css = 'badge bg-primary bg-opacity-10 text-primary border border-primary-subtle fw-semibold d-inline-flex align-items-center align-middle mx-1 my-0.5 px-2 py-1 rounded-pill'
|
||||
icon = 'bi-person-fill'
|
||||
else:
|
||||
css = 'badge bg-info-subtle text-info-emphasis border border-info-subtle fw-semibold'
|
||||
css = 'badge bg-info bg-opacity-10 text-info border border-info-subtle fw-semibold d-inline-flex align-items-center align-middle mx-1 my-0.5 px-2 py-1 rounded-pill'
|
||||
icon = 'bi-people-fill'
|
||||
|
||||
parts.append(
|
||||
f'<span class="{css}">'
|
||||
f'<i class="bi {icon} me-1" style="font-size:0.75em;"></i>'
|
||||
f'<i class="bi {icon} me-1" style="font-size:0.85em;"></i>'
|
||||
f'@{escape(name)}'
|
||||
f'</span>'
|
||||
)
|
||||
|
|
|
|||
62
streetup/notifications/tests.py
Normal file
62
streetup/notifications/tests.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth import get_user_model
|
||||
from notifications.models import Discussion, Message
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class DiscussionListTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user1 = User.objects.create_user(username='user1', password='pass')
|
||||
self.user2 = User.objects.create_user(username='user2', password='pass')
|
||||
self.user3 = User.objects.create_user(username='user3', password='pass')
|
||||
|
||||
def test_discussion_list_filtering(self):
|
||||
# Discussion 1: created by user1
|
||||
d1 = Discussion.objects.create(title="Discussion 1", created_by=self.user1)
|
||||
d1.participants.add(self.user1, self.user2, self.user3)
|
||||
|
||||
# Discussion 2: created by user2, user1 wrote a message
|
||||
d2 = Discussion.objects.create(title="Discussion 2", created_by=self.user2)
|
||||
d2.participants.add(self.user1, self.user2, self.user3)
|
||||
Message.objects.create(discussion=d2, author=self.user1, content="Hello from user1")
|
||||
|
||||
# Discussion 3: created by user2, user1 is tagged
|
||||
d3 = Discussion.objects.create(title="Discussion 3", created_by=self.user2)
|
||||
d3.participants.add(self.user1, self.user2, self.user3)
|
||||
Message.objects.create(discussion=d3, author=self.user2, content=f"Hello @[User 1](u:{self.user1.pk})")
|
||||
|
||||
# Discussion 4: created by user2, user3 participant but user1 is NOT author and NOT tagged
|
||||
d4 = Discussion.objects.create(title="Discussion 4", created_by=self.user2)
|
||||
d4.participants.add(self.user1, self.user2, self.user3)
|
||||
|
||||
# Discussion 5: auto-created intervention discussion with created_by=user1, but user1 never posted a message and was never tagged
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
ct = ContentType.objects.get_for_model(User)
|
||||
d5 = Discussion.objects.create(title="Discussion 5", created_by=self.user1, content_type=ct, object_id=1)
|
||||
Message.objects.create(discussion=d5, author=self.user2, content="Hello user3")
|
||||
|
||||
self.client.force_login(self.user1)
|
||||
response = self.client.get(reverse('notifications:discussion_list'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
discussions_in_context = [item['discussion'] for item in response.context['items']]
|
||||
self.assertIn(d1, discussions_in_context)
|
||||
self.assertIn(d2, discussions_in_context)
|
||||
self.assertIn(d3, discussions_in_context)
|
||||
self.assertNotIn(d4, discussions_in_context)
|
||||
self.assertNotIn(d5, discussions_in_context)
|
||||
|
||||
def test_discussion_create(self):
|
||||
self.client.force_login(self.user1)
|
||||
response = self.client.post(reverse('notifications:discussion_create'), {
|
||||
'title': 'Sujet test',
|
||||
'content': f'Bonjour @[User 2](u:{self.user2.pk})',
|
||||
})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
discussion = Discussion.objects.get(title='Sujet test')
|
||||
self.assertEqual(discussion.created_by, self.user1)
|
||||
self.assertIn(self.user1, discussion.participants.all())
|
||||
self.assertIn(self.user2, discussion.participants.all())
|
||||
self.assertEqual(discussion.messages.count(), 1)
|
||||
|
|
@ -19,6 +19,7 @@ urlpatterns = [
|
|||
path('teams/<int:pk>/delete/', views.team_delete, name='team_delete'),
|
||||
# Discussions
|
||||
path('discussions/', views.discussion_list, name='discussion_list'),
|
||||
path('discussions/new/', views.discussion_create, name='discussion_create'),
|
||||
path('discussions/<int:pk>/', views.discussion_detail, name='discussion_detail'),
|
||||
# API
|
||||
path('api/unread-count/', views.api_unread_count, name='api_unread_count'),
|
||||
|
|
|
|||
|
|
@ -476,9 +476,28 @@ def team_delete(request, pk):
|
|||
|
||||
@login_required
|
||||
def discussion_list(request):
|
||||
"""Liste les discussions auxquelles l'utilisateur participe."""
|
||||
"""
|
||||
Liste les discussions dont l'utilisateur est l'auteur (créateur de la discussion
|
||||
ou auteur d'un message) ou pour lesquelles il (ou une de ses équipes) a été explicitement taggé.
|
||||
"""
|
||||
user = request.user
|
||||
user_team_ids = list(user.notification_teams.values_list('id', flat=True))
|
||||
|
||||
mention_q = Q(messages__content__icontains=f'(u:{user.pk})')
|
||||
for tid in user_team_ids:
|
||||
mention_q |= Q(messages__content__icontains=f'(t:{tid})')
|
||||
|
||||
discussion_filter = (
|
||||
Q(messages__author=user, messages__is_deleted=False)
|
||||
| Q(notifications__recipients__user=user)
|
||||
| Q(notifications__team_targets__team_id__in=user_team_ids)
|
||||
| (mention_q & Q(messages__is_deleted=False))
|
||||
| (Q(created_by=user) & Q(content_type__isnull=True))
|
||||
)
|
||||
|
||||
discussions = (
|
||||
request.user.discussions
|
||||
Discussion.objects.filter(discussion_filter)
|
||||
.distinct()
|
||||
.prefetch_related('participants', 'messages')
|
||||
.select_related('created_by')
|
||||
.order_by('-updated_at')
|
||||
|
|
@ -536,6 +555,38 @@ def discussion_detail(request, pk):
|
|||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def discussion_create(request):
|
||||
"""
|
||||
Crée une nouvelle discussion indépendante (sans objet lié).
|
||||
Poste le premier message et notifie les personnes/équipes mentionnées.
|
||||
"""
|
||||
if request.method == 'POST':
|
||||
title = request.POST.get('title', '').strip()
|
||||
content = request.POST.get('content', '').strip()
|
||||
files = request.FILES.getlist('attachments')
|
||||
|
||||
if content:
|
||||
discussion = Discussion.objects.create(
|
||||
title=title or f"Discussion de {request.user.get_full_name() or request.user.username}",
|
||||
created_by=request.user,
|
||||
)
|
||||
discussion.participants.add(request.user)
|
||||
|
||||
post_message(
|
||||
discussion=discussion,
|
||||
author=request.user,
|
||||
content=content,
|
||||
files=files or None,
|
||||
)
|
||||
messages.success(request, _("Discussion créée avec succès."))
|
||||
return redirect('notifications:discussion_detail', pk=discussion.pk)
|
||||
else:
|
||||
messages.error(request, _("Le contenu du message ne peut pas être vide."))
|
||||
|
||||
return redirect('notifications:discussion_list')
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Mention search API
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -470,10 +470,21 @@
|
|||
const badge = document.getElementById('navbar-notif-badge');
|
||||
|
||||
function updateBadge(count) {
|
||||
if (!badge) return;
|
||||
if (badge) {
|
||||
if (count > 0) { badge.textContent = count; badge.style.display = ''; }
|
||||
else { badge.style.display = 'none'; }
|
||||
}
|
||||
const homeAlert = document.getElementById('home-unread-notifications-alert');
|
||||
if (homeAlert) {
|
||||
if (count > 0) {
|
||||
const countEls = homeAlert.querySelectorAll('.home-unread-notifications-count');
|
||||
countEls.forEach(el => el.textContent = count);
|
||||
homeAlert.style.display = '';
|
||||
} else {
|
||||
homeAlert.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const TYPE_ICONS = {
|
||||
message: 'bi-chat-dots', reminder: 'bi-alarm',
|
||||
|
|
|
|||
Loading…
Reference in a new issue