Compare commits
3 Commits
8216e0943f
...
5e2add90a8
Author | SHA1 | Date | |
---|---|---|---|
|
5e2add90a8 | ||
|
635606eb13 | ||
|
b828631106 |
@ -3,10 +3,12 @@ FROM python:3.12-alpine
|
|||||||
ENV PYTHONUNBUFFERED 1
|
ENV PYTHONUNBUFFERED 1
|
||||||
ENV DJANGO_ALLOW_ASYNC_UNSAFE 1
|
ENV DJANGO_ALLOW_ASYNC_UNSAFE 1
|
||||||
|
|
||||||
RUN apk add --no-cache gettext nginx gcc git libc-dev libffi-dev libxml2-dev libxslt-dev postgresql-dev libmagic texlive texmf-dist-latexextra
|
RUN apk add --no-cache gettext nginx gcc git libc-dev libffi-dev libxml2-dev libxslt-dev npm postgresql-dev libmagic texlive texmf-dist-latexextra
|
||||||
|
|
||||||
RUN apk add --no-cache bash
|
RUN apk add --no-cache bash
|
||||||
|
|
||||||
|
RUN npm install -g yuglify
|
||||||
|
|
||||||
RUN mkdir /code /code/docs
|
RUN mkdir /code /code/docs
|
||||||
WORKDIR /code
|
WORKDIR /code
|
||||||
COPY requirements.txt /code/requirements.txt
|
COPY requirements.txt /code/requirements.txt
|
||||||
|
@ -8,6 +8,9 @@ from .models import Channel, Message
|
|||||||
|
|
||||||
@admin.register(Channel)
|
@admin.register(Channel)
|
||||||
class ChannelAdmin(admin.ModelAdmin):
|
class ChannelAdmin(admin.ModelAdmin):
|
||||||
|
"""
|
||||||
|
Modèle d'administration des canaux de chat.
|
||||||
|
"""
|
||||||
list_display = ('name', 'category', 'read_access', 'write_access', 'tournament', 'private',)
|
list_display = ('name', 'category', 'read_access', 'write_access', 'tournament', 'private',)
|
||||||
list_filter = ('category', 'read_access', 'write_access', 'tournament', 'private',)
|
list_filter = ('category', 'read_access', 'write_access', 'tournament', 'private',)
|
||||||
search_fields = ('name', 'tournament__name', 'team__name', 'team__trigram',)
|
search_fields = ('name', 'tournament__name', 'team__name', 'team__trigram',)
|
||||||
@ -16,6 +19,9 @@ class ChannelAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(Message)
|
@admin.register(Message)
|
||||||
class MessageAdmin(admin.ModelAdmin):
|
class MessageAdmin(admin.ModelAdmin):
|
||||||
|
"""
|
||||||
|
Modèle d'administration des messages de chat.
|
||||||
|
"""
|
||||||
list_display = ('channel', 'author', 'created_at', 'updated_at', 'content',)
|
list_display = ('channel', 'author', 'created_at', 'updated_at', 'content',)
|
||||||
list_filter = ('channel', 'created_at', 'updated_at',)
|
list_filter = ('channel', 'created_at', 'updated_at',)
|
||||||
search_fields = ('author__username', 'author__first_name', 'author__last_name', 'content',)
|
search_fields = ('author__username', 'author__first_name', 'author__last_name', 'content',)
|
||||||
|
@ -11,76 +11,101 @@ from .models import Channel, Message
|
|||||||
|
|
||||||
class ChatConsumer(AsyncJsonWebsocketConsumer):
|
class ChatConsumer(AsyncJsonWebsocketConsumer):
|
||||||
"""
|
"""
|
||||||
This consumer manages the websocket of the chat interface.
|
Ce consommateur gère les connexions WebSocket pour le chat.
|
||||||
"""
|
"""
|
||||||
async def connect(self) -> None:
|
async def connect(self) -> None:
|
||||||
"""
|
"""
|
||||||
This function is called when a new websocket is trying to connect to the server.
|
Cette fonction est appelée lorsqu'un nouveau websocket tente de se connecter au serveur.
|
||||||
We accept only if this is a user of a team of the associated tournament, or a volunteer
|
On n'accept que si c'est un⋅e utilisateur⋅rice connecté⋅e.
|
||||||
of the tournament.
|
|
||||||
"""
|
"""
|
||||||
if '_fake_user_id' in self.scope['session']:
|
if '_fake_user_id' in self.scope['session']:
|
||||||
|
# Dans le cas d'une impersonification, on charge l'utilisateur⋅rice concerné
|
||||||
self.scope['user'] = await User.objects.aget(pk=self.scope['session']['_fake_user_id'])
|
self.scope['user'] = await User.objects.aget(pk=self.scope['session']['_fake_user_id'])
|
||||||
|
|
||||||
# Fetch the registration of the current user
|
# Récupération de l'utilisateur⋅rice courant⋅e
|
||||||
user = self.scope['user']
|
user = self.scope['user']
|
||||||
if user.is_anonymous:
|
if user.is_anonymous:
|
||||||
# User is not authenticated
|
# L'utilisateur⋅rice n'est pas connecté⋅e
|
||||||
await self.close()
|
await self.close()
|
||||||
return
|
return
|
||||||
|
|
||||||
reg = await Registration.objects.aget(user_id=user.id)
|
reg = await Registration.objects.aget(user_id=user.id)
|
||||||
self.registration = reg
|
self.registration = reg
|
||||||
|
|
||||||
# Accept the connection
|
# Acceptation de la connexion
|
||||||
await self.accept()
|
await self.accept()
|
||||||
|
|
||||||
|
# Récupération des canaux accessibles en lecture et/ou en écriture
|
||||||
self.read_channels = await Channel.get_accessible_channels(user, 'read')
|
self.read_channels = await Channel.get_accessible_channels(user, 'read')
|
||||||
self.write_channels = await Channel.get_accessible_channels(user, 'write')
|
self.write_channels = await Channel.get_accessible_channels(user, 'write')
|
||||||
|
|
||||||
|
# Abonnement aux canaux de diffusion Websocket pour les différents canaux de chat
|
||||||
async for channel in self.read_channels.all():
|
async for channel in self.read_channels.all():
|
||||||
await self.channel_layer.group_add(f"chat-{channel.id}", self.channel_name)
|
await self.channel_layer.group_add(f"chat-{channel.id}", self.channel_name)
|
||||||
|
# Abonnement à un canal de diffusion Websocket personnel, utile pour s'adresser à une unique personne
|
||||||
await self.channel_layer.group_add(f"user-{user.id}", self.channel_name)
|
await self.channel_layer.group_add(f"user-{user.id}", self.channel_name)
|
||||||
|
|
||||||
async def disconnect(self, close_code) -> None:
|
async def disconnect(self, close_code: int) -> None:
|
||||||
"""
|
"""
|
||||||
Called when the websocket got disconnected, for any reason.
|
Cette fonction est appelée lorsqu'un websocket se déconnecte du serveur.
|
||||||
:param close_code: The error code.
|
:param close_code: Le code d'erreur.
|
||||||
"""
|
"""
|
||||||
if self.scope['user'].is_anonymous:
|
if self.scope['user'].is_anonymous:
|
||||||
# User is not authenticated
|
# L'utilisateur⋅rice n'était pas connecté⋅e, on ne fait rien
|
||||||
return
|
return
|
||||||
|
|
||||||
async for channel in self.read_channels.all():
|
async for channel in self.read_channels.all():
|
||||||
|
# Désabonnement des canaux de diffusion Websocket liés aux canaux de chat
|
||||||
await self.channel_layer.group_discard(f"chat-{channel.id}", self.channel_name)
|
await self.channel_layer.group_discard(f"chat-{channel.id}", self.channel_name)
|
||||||
|
# Désabonnement du canal de diffusion Websocket personnel
|
||||||
await self.channel_layer.group_discard(f"user-{self.scope['user'].id}", self.channel_name)
|
await self.channel_layer.group_discard(f"user-{self.scope['user'].id}", self.channel_name)
|
||||||
|
|
||||||
async def receive_json(self, content, **kwargs):
|
async def receive_json(self, content: dict, **kwargs) -> None:
|
||||||
"""
|
"""
|
||||||
Called when the client sends us some data, parsed as JSON.
|
Appelée lorsque le client nous envoie des données, décodées depuis du JSON.
|
||||||
:param content: The sent data, decoded from JSON text. Must content a `type` field.
|
:param content: Les données envoyées par le client, décodées depuis du JSON. Doit contenir un champ 'type'.
|
||||||
"""
|
"""
|
||||||
match content['type']:
|
match content['type']:
|
||||||
case 'fetch_channels':
|
case 'fetch_channels':
|
||||||
|
# Demande de récupération des canaux disponibles
|
||||||
await self.fetch_channels()
|
await self.fetch_channels()
|
||||||
case 'send_message':
|
case 'send_message':
|
||||||
|
# Envoi d'un message dans un canal
|
||||||
await self.receive_message(**content)
|
await self.receive_message(**content)
|
||||||
case 'edit_message':
|
case 'edit_message':
|
||||||
|
# Modification d'un message
|
||||||
await self.edit_message(**content)
|
await self.edit_message(**content)
|
||||||
case 'delete_message':
|
case 'delete_message':
|
||||||
|
# Suppression d'un message
|
||||||
await self.delete_message(**content)
|
await self.delete_message(**content)
|
||||||
case 'fetch_messages':
|
case 'fetch_messages':
|
||||||
|
# Récupération des messages d'un canal (ou d'une partie)
|
||||||
await self.fetch_messages(**content)
|
await self.fetch_messages(**content)
|
||||||
case 'mark_read':
|
case 'mark_read':
|
||||||
|
# Marquage de messages comme lus
|
||||||
await self.mark_read(**content)
|
await self.mark_read(**content)
|
||||||
case 'start_private_chat':
|
case 'start_private_chat':
|
||||||
|
# Démarrage d'une conversation privée avec un⋅e autre utilisateur⋅rice
|
||||||
await self.start_private_chat(**content)
|
await self.start_private_chat(**content)
|
||||||
case unknown:
|
case unknown:
|
||||||
print("Unknown message type:", unknown)
|
# Type inconnu, on soulève une erreur
|
||||||
|
raise ValueError(f"Unknown message type: {unknown}")
|
||||||
|
|
||||||
async def fetch_channels(self) -> None:
|
async def fetch_channels(self) -> None:
|
||||||
|
"""
|
||||||
|
L'utilisateur⋅rice demande à récupérer la liste des canaux disponibles.
|
||||||
|
On lui renvoie alors la liste des canaux qui lui sont accessibles en lecture,
|
||||||
|
en fournissant nom, catégorie, permission de lecture et nombre de messages non lus.
|
||||||
|
"""
|
||||||
user = self.scope['user']
|
user = self.scope['user']
|
||||||
|
|
||||||
|
# Récupération des canaux accessibles en lecture, avec le nombre de messages non lus
|
||||||
|
channels = self.read_channels.prefetch_related('invited') \
|
||||||
|
.annotate(total_messages=Count('messages', distinct=True)) \
|
||||||
|
.annotate(read_messages=Count('messages', filter=Q(messages__users_read=user), distinct=True)) \
|
||||||
|
.annotate(unread_messages=F('total_messages') - F('read_messages')).all()
|
||||||
|
|
||||||
|
# Envoi de la liste des canaux
|
||||||
message = {
|
message = {
|
||||||
'type': 'fetch_channels',
|
'type': 'fetch_channels',
|
||||||
'channels': [
|
'channels': [
|
||||||
@ -92,27 +117,37 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
'write_access': await self.write_channels.acontains(channel),
|
'write_access': await self.write_channels.acontains(channel),
|
||||||
'unread_messages': channel.unread_messages,
|
'unread_messages': channel.unread_messages,
|
||||||
}
|
}
|
||||||
async for channel in self.read_channels.prefetch_related('invited')
|
async for channel in channels
|
||||||
.annotate(total_messages=Count('messages', distinct=True))
|
|
||||||
.annotate(read_messages=Count('messages', filter=Q(messages__users_read=user), distinct=True))
|
|
||||||
.annotate(unread_messages=F('total_messages') - F('read_messages')).all()
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
await self.send_json(message)
|
await self.send_json(message)
|
||||||
|
|
||||||
async def receive_message(self, channel_id: int, content: str, **kwargs) -> None:
|
async def receive_message(self, channel_id: int, content: str, **kwargs) -> None:
|
||||||
|
"""
|
||||||
|
L'utilisateur⋅ice a envoyé un message dans un canal.
|
||||||
|
On vérifie d'abord la permission d'écriture, puis on crée le message et on l'envoie à tou⋅tes les
|
||||||
|
utilisateur⋅ices abonné⋅es au canal.
|
||||||
|
|
||||||
|
:param channel_id: Identifiant du canal où envoyer le message.
|
||||||
|
:param content: Contenu du message.
|
||||||
|
"""
|
||||||
user = self.scope['user']
|
user = self.scope['user']
|
||||||
|
|
||||||
|
# Récupération du canal
|
||||||
channel = await Channel.objects.prefetch_related('tournament__pools__juries', 'pool', 'team', 'invited') \
|
channel = await Channel.objects.prefetch_related('tournament__pools__juries', 'pool', 'team', 'invited') \
|
||||||
.aget(id=channel_id)
|
.aget(id=channel_id)
|
||||||
if not await self.write_channels.acontains(channel):
|
if not await self.write_channels.acontains(channel):
|
||||||
|
# L'utilisateur⋅ice n'a pas la permission d'écrire dans ce canal, on abandonne
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Création du message
|
||||||
message = await Message.objects.acreate(
|
message = await Message.objects.acreate(
|
||||||
author=user,
|
author=user,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
content=content,
|
content=content,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Envoi du message à toutes les personnes connectées sur le canal
|
||||||
await self.channel_layer.group_send(f'chat-{channel.id}', {
|
await self.channel_layer.group_send(f'chat-{channel.id}', {
|
||||||
'type': 'chat.send_message',
|
'type': 'chat.send_message',
|
||||||
'id': message.id,
|
'id': message.id,
|
||||||
@ -124,14 +159,27 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
})
|
})
|
||||||
|
|
||||||
async def edit_message(self, message_id: int, content: str, **kwargs) -> None:
|
async def edit_message(self, message_id: int, content: str, **kwargs) -> None:
|
||||||
message = await Message.objects.aget(id=message_id)
|
"""
|
||||||
|
L'utilisateur⋅ice a modifié un message.
|
||||||
|
On vérifie d'abord que l'utilisateur⋅ice a le droit de modifier le message, puis on modifie le message
|
||||||
|
et on envoie la modification à tou⋅tes les utilisateur⋅ices abonné⋅es au canal.
|
||||||
|
|
||||||
|
:param message_id: Identifiant du message à modifier.
|
||||||
|
:param content: Nouveau contenu du message.
|
||||||
|
"""
|
||||||
user = self.scope['user']
|
user = self.scope['user']
|
||||||
|
|
||||||
|
# Récupération du message
|
||||||
|
message = await Message.objects.aget(id=message_id)
|
||||||
if user.id != message.author_id and not user.is_superuser:
|
if user.id != message.author_id and not user.is_superuser:
|
||||||
|
# Seul⋅e l'auteur⋅ice du message ou un⋅e admin peut modifier un message
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Modification du contenu du message
|
||||||
message.content = content
|
message.content = content
|
||||||
await message.asave()
|
await message.asave()
|
||||||
|
|
||||||
|
# Envoi de la modification à tou⋅tes les personnes connectées sur le canal
|
||||||
await self.channel_layer.group_send(f'chat-{message.channel_id}', {
|
await self.channel_layer.group_send(f'chat-{message.channel_id}', {
|
||||||
'type': 'chat.edit_message',
|
'type': 'chat.edit_message',
|
||||||
'id': message_id,
|
'id': message_id,
|
||||||
@ -140,13 +188,24 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
})
|
})
|
||||||
|
|
||||||
async def delete_message(self, message_id: int, **kwargs) -> None:
|
async def delete_message(self, message_id: int, **kwargs) -> None:
|
||||||
message = await Message.objects.aget(id=message_id)
|
"""
|
||||||
|
L'utilisateur⋅ice a supprimé un message.
|
||||||
|
On vérifie d'abord que l'utilisateur⋅ice a le droit de supprimer le message, puis on supprime le message
|
||||||
|
et on envoie la suppression à tou⋅tes les utilisateur⋅ices abonné⋅es au canal.
|
||||||
|
|
||||||
|
:param message_id: Identifiant du message à supprimer.
|
||||||
|
"""
|
||||||
user = self.scope['user']
|
user = self.scope['user']
|
||||||
|
|
||||||
|
# Récupération du message
|
||||||
|
message = await Message.objects.aget(id=message_id)
|
||||||
if user.id != message.author_id and not user.is_superuser:
|
if user.id != message.author_id and not user.is_superuser:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Suppression effective du message
|
||||||
await message.adelete()
|
await message.adelete()
|
||||||
|
|
||||||
|
# Envoi de la suppression à tou⋅tes les personnes connectées sur le canal
|
||||||
await self.channel_layer.group_send(f'chat-{message.channel_id}', {
|
await self.channel_layer.group_send(f'chat-{message.channel_id}', {
|
||||||
'type': 'chat.delete_message',
|
'type': 'chat.delete_message',
|
||||||
'id': message_id,
|
'id': message_id,
|
||||||
@ -154,16 +213,30 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
})
|
})
|
||||||
|
|
||||||
async def fetch_messages(self, channel_id: int, offset: int = 0, limit: int = 50, **_kwargs) -> None:
|
async def fetch_messages(self, channel_id: int, offset: int = 0, limit: int = 50, **_kwargs) -> None:
|
||||||
|
"""
|
||||||
|
L'utilisateur⋅ice demande à récupérer les messages d'un canal.
|
||||||
|
On vérifie la permission de lecture, puis on renvoie les messages demandés.
|
||||||
|
|
||||||
|
:param channel_id: Identifiant du canal où récupérer les messages.
|
||||||
|
:param offset: Décalage pour la pagination, à partir du dernier message.
|
||||||
|
Par défaut : 0, on commence au dernier message.
|
||||||
|
:param limit: Nombre de messages à récupérer. Par défaut, on récupère 50 messages.
|
||||||
|
"""
|
||||||
|
# Récupération du canal
|
||||||
channel = await Channel.objects.aget(id=channel_id)
|
channel = await Channel.objects.aget(id=channel_id)
|
||||||
if not await self.read_channels.acontains(channel):
|
if not await self.read_channels.acontains(channel):
|
||||||
|
# L'utilisateur⋅rice n'a pas la permission de lire ce canal, on abandonne
|
||||||
return
|
return
|
||||||
|
|
||||||
limit = min(limit, 200) # Fetch only maximum 200 messages at the time
|
limit = min(limit, 200) # On limite le nombre de messages à 200 maximum
|
||||||
|
|
||||||
|
# Récupération des messages, avec un indicateur de lecture pour l'utilisateur⋅ice courant⋅e
|
||||||
messages = Message.objects \
|
messages = Message.objects \
|
||||||
.filter(channel=channel) \
|
.filter(channel=channel) \
|
||||||
.annotate(read=Count('users_read', filter=Q(users_read=self.scope['user']))) \
|
.annotate(read=Count('users_read', filter=Q(users_read=self.scope['user']))) \
|
||||||
.order_by('-created_at')[offset:offset + limit].all()
|
.order_by('-created_at')[offset:offset + limit].all()
|
||||||
|
|
||||||
|
# Envoi de la liste des messages, en les renvoyant dans l'ordre chronologique
|
||||||
await self.send_json({
|
await self.send_json({
|
||||||
'type': 'fetch_messages',
|
'type': 'fetch_messages',
|
||||||
'channel_id': channel_id,
|
'channel_id': channel_id,
|
||||||
@ -181,13 +254,22 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
})
|
})
|
||||||
|
|
||||||
async def mark_read(self, message_ids: list[int], **_kwargs) -> None:
|
async def mark_read(self, message_ids: list[int], **_kwargs) -> None:
|
||||||
|
"""
|
||||||
|
L'utilisateur⋅ice marque des messages comme lus, après les avoir affichés à l'écran.
|
||||||
|
|
||||||
|
:param message_ids: Liste des identifiants des messages qu'il faut marquer comme lus.
|
||||||
|
"""
|
||||||
|
# Récupération des messages à marquer comme lus
|
||||||
messages = Message.objects.filter(id__in=message_ids)
|
messages = Message.objects.filter(id__in=message_ids)
|
||||||
async for message in messages.all():
|
async for message in messages.all():
|
||||||
|
# Ajout de l'utilisateur⋅ice courant⋅e à la liste des personnes ayant lu le message
|
||||||
await message.users_read.aadd(self.scope['user'])
|
await message.users_read.aadd(self.scope['user'])
|
||||||
|
|
||||||
|
# Actualisation du nombre de messages non lus par canal
|
||||||
unread_messages_by_channel = Message.objects.exclude(users_read=self.scope['user']).values('channel_id') \
|
unread_messages_by_channel = Message.objects.exclude(users_read=self.scope['user']).values('channel_id') \
|
||||||
.annotate(unread_messages=Count('channel_id'))
|
.annotate(unread_messages=Count('channel_id'))
|
||||||
|
|
||||||
|
# Envoi des identifiants des messages non lus et du nombre de messages non lus par canal, actualisés
|
||||||
await self.send_json({
|
await self.send_json({
|
||||||
'type': 'mark_read',
|
'type': 'mark_read',
|
||||||
'messages': [{'id': message.id, 'channel_id': message.channel_id} async for message in messages.all()],
|
'messages': [{'id': message.id, 'channel_id': message.channel_id} async for message in messages.all()],
|
||||||
@ -196,10 +278,21 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
})
|
})
|
||||||
|
|
||||||
async def start_private_chat(self, user_id: int, **kwargs) -> None:
|
async def start_private_chat(self, user_id: int, **kwargs) -> None:
|
||||||
|
"""
|
||||||
|
L'utilisateur⋅ice souhaite démarrer une conversation privée avec un⋅e autre utilisateur⋅ice.
|
||||||
|
Pour cela, on récupère le salon privé s'il existe, sinon on en crée un.
|
||||||
|
Dans le cas d'une création, les deux personnes sont transférées immédiatement dans ce nouveau canal.
|
||||||
|
|
||||||
|
:param user_id: L'utilisateur⋅rice avec qui démarrer la conversation privée.
|
||||||
|
"""
|
||||||
user = self.scope['user']
|
user = self.scope['user']
|
||||||
|
# Récupération de l'autre utilisateur⋅ice avec qui démarrer la conversation
|
||||||
other_user = await User.objects.aget(id=user_id)
|
other_user = await User.objects.aget(id=user_id)
|
||||||
|
|
||||||
|
# Vérification de l'existence d'un salon privé entre les deux personnes
|
||||||
channel_qs = Channel.objects.filter(private=True).filter(invited=user).filter(invited=other_user)
|
channel_qs = Channel.objects.filter(private=True).filter(invited=user).filter(invited=other_user)
|
||||||
if not await channel_qs.aexists():
|
if not await channel_qs.aexists():
|
||||||
|
# Le salon privé n'existe pas, on le crée alors
|
||||||
channel = await Channel.objects.acreate(
|
channel = await Channel.objects.acreate(
|
||||||
name=f"{user.first_name} {user.last_name}, {other_user.first_name} {other_user.last_name}",
|
name=f"{user.first_name} {user.last_name}, {other_user.first_name} {other_user.last_name}",
|
||||||
category=Channel.ChannelCategory.PRIVATE,
|
category=Channel.ChannelCategory.PRIVATE,
|
||||||
@ -207,9 +300,11 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
)
|
)
|
||||||
await channel.invited.aset([user, other_user])
|
await channel.invited.aset([user, other_user])
|
||||||
|
|
||||||
|
# On s'ajoute au salon privé
|
||||||
await self.channel_layer.group_add(f"chat-{channel.id}", self.channel_name)
|
await self.channel_layer.group_add(f"chat-{channel.id}", self.channel_name)
|
||||||
|
|
||||||
if user != other_user:
|
if user != other_user:
|
||||||
|
# On transfère l'autre utilisateur⋅ice dans le salon privé
|
||||||
await self.channel_layer.group_send(f"user-{other_user.id}", {
|
await self.channel_layer.group_send(f"user-{other_user.id}", {
|
||||||
'type': 'chat.start_private_chat',
|
'type': 'chat.start_private_chat',
|
||||||
'channel': {
|
'channel': {
|
||||||
@ -221,8 +316,10 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
|
# Récupération dudit salon privé
|
||||||
channel = await channel_qs.afirst()
|
channel = await channel_qs.afirst()
|
||||||
|
|
||||||
|
# Invitation de l'autre utilisateur⋅rice à rejoindre le salon privé
|
||||||
await self.channel_layer.group_send(f"user-{user.id}", {
|
await self.channel_layer.group_send(f"user-{user.id}", {
|
||||||
'type': 'chat.start_private_chat',
|
'type': 'chat.start_private_chat',
|
||||||
'channel': {
|
'channel': {
|
||||||
@ -235,17 +332,39 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
})
|
})
|
||||||
|
|
||||||
async def chat_send_message(self, message) -> None:
|
async def chat_send_message(self, message) -> None:
|
||||||
|
"""
|
||||||
|
Envoi d'un message à tou⋅tes les personnes connectées sur un canal.
|
||||||
|
:param message: Dictionnaire contenant les informations du message à envoyer,
|
||||||
|
contenant l'identifiant du message "id", l'identifiant du canal "channel_id",
|
||||||
|
l'heure de création "timestamp", l'identifiant de l'auteur "author_id",
|
||||||
|
le nom de l'auteur "author" et le contenu du message "content".
|
||||||
|
"""
|
||||||
await self.send_json({'type': 'send_message', 'id': message['id'], 'channel_id': message['channel_id'],
|
await self.send_json({'type': 'send_message', 'id': message['id'], 'channel_id': message['channel_id'],
|
||||||
'timestamp': message['timestamp'], 'author': message['author'],
|
'timestamp': message['timestamp'], 'author': message['author'],
|
||||||
'content': message['content']})
|
'content': message['content']})
|
||||||
|
|
||||||
async def chat_edit_message(self, message) -> None:
|
async def chat_edit_message(self, message) -> None:
|
||||||
|
"""
|
||||||
|
Envoi d'une modification de message à tou⋅tes les personnes connectées sur un canal.
|
||||||
|
:param message: Dictionnaire contenant les informations du message à modifier,
|
||||||
|
contenant l'identifiant du message "id", l'identifiant du canal "channel_id"
|
||||||
|
et le nouveau contenu "content".
|
||||||
|
"""
|
||||||
await self.send_json({'type': 'edit_message', 'id': message['id'], 'channel_id': message['channel_id'],
|
await self.send_json({'type': 'edit_message', 'id': message['id'], 'channel_id': message['channel_id'],
|
||||||
'content': message['content']})
|
'content': message['content']})
|
||||||
|
|
||||||
async def chat_delete_message(self, message) -> None:
|
async def chat_delete_message(self, message) -> None:
|
||||||
|
"""
|
||||||
|
Envoi d'une suppression de message à tou⋅tes les personnes connectées sur un canal.
|
||||||
|
:param message: Dictionnaire contenant les informations du message à supprimer,
|
||||||
|
contenant l'identifiant du message "id" et l'identifiant du canal "channel_id".
|
||||||
|
"""
|
||||||
await self.send_json({'type': 'delete_message', 'id': message['id'], 'channel_id': message['channel_id']})
|
await self.send_json({'type': 'delete_message', 'id': message['id'], 'channel_id': message['channel_id']})
|
||||||
|
|
||||||
async def chat_start_private_chat(self, message) -> None:
|
async def chat_start_private_chat(self, message) -> None:
|
||||||
|
"""
|
||||||
|
Envoi d'un message pour démarrer une conversation privée à une personne connectée.
|
||||||
|
:param message: Dictionnaire contenant les informations du nouveau canal privé.
|
||||||
|
"""
|
||||||
await self.channel_layer.group_add(f"chat-{message['channel']['id']}", self.channel_name)
|
await self.channel_layer.group_add(f"chat-{message['channel']['id']}", self.channel_name)
|
||||||
await self.send_json({'type': 'start_private_chat', 'channel': message['channel']})
|
await self.send_json({'type': 'start_private_chat', 'channel': message['channel']})
|
||||||
|
@ -10,9 +10,18 @@ from ...models import Channel
|
|||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
|
"""
|
||||||
|
Cette commande permet de créer les canaux de chat pour les tournois et les équipes.
|
||||||
|
Différents canaux sont créés pour chaque tournoi, puis pour chaque poule.
|
||||||
|
Enfin, un canal de communication par équipe est créé.
|
||||||
|
"""
|
||||||
|
help = "Create chat channels for tournaments and teams."
|
||||||
|
|
||||||
def handle(self, *args, **kwargs):
|
def handle(self, *args, **kwargs):
|
||||||
activate('fr')
|
activate('fr')
|
||||||
|
|
||||||
|
# Création de canaux généraux, d'annonces, d'aide jurys et orgas, etc.
|
||||||
|
# Le canal d'annonces est accessibles à tous⋅tes, mais seul⋅es les admins peuvent y écrire.
|
||||||
Channel.objects.update_or_create(
|
Channel.objects.update_or_create(
|
||||||
name="Annonces",
|
name="Annonces",
|
||||||
defaults=dict(
|
defaults=dict(
|
||||||
@ -22,6 +31,7 @@ class Command(BaseCommand):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Un canal d'aide pour les bénévoles est dédié.
|
||||||
Channel.objects.update_or_create(
|
Channel.objects.update_or_create(
|
||||||
name="Aide jurys et orgas",
|
name="Aide jurys et orgas",
|
||||||
defaults=dict(
|
defaults=dict(
|
||||||
@ -31,6 +41,7 @@ class Command(BaseCommand):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Un canal de discussion générale en lien avec le tournoi est accessible librement.
|
||||||
Channel.objects.update_or_create(
|
Channel.objects.update_or_create(
|
||||||
name="Général",
|
name="Général",
|
||||||
defaults=dict(
|
defaults=dict(
|
||||||
@ -40,6 +51,8 @@ class Command(BaseCommand):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Un canal de discussion entre participant⋅es est accessible à tous⋅tes,
|
||||||
|
# dont l'objectif est de faciliter la mise en relation entre élèves afin de constituer une équipe.
|
||||||
Channel.objects.update_or_create(
|
Channel.objects.update_or_create(
|
||||||
name="Je cherche une équipe",
|
name="Je cherche une équipe",
|
||||||
defaults=dict(
|
defaults=dict(
|
||||||
@ -49,6 +62,7 @@ class Command(BaseCommand):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Un canal de discussion libre est accessible pour tous⋅tes.
|
||||||
Channel.objects.update_or_create(
|
Channel.objects.update_or_create(
|
||||||
name="Détente",
|
name="Détente",
|
||||||
defaults=dict(
|
defaults=dict(
|
||||||
@ -59,6 +73,10 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
|
|
||||||
for tournament in Tournament.objects.all():
|
for tournament in Tournament.objects.all():
|
||||||
|
# Pour chaque tournoi, on crée un canal d'annonces, un canal général et un de détente,
|
||||||
|
# qui sont comme les canaux généraux du même nom mais réservés aux membres du tournoi concerné.
|
||||||
|
# Les membres d'un tournoi sont les organisateur⋅rices, les juré⋅es d'une poule du tournoi
|
||||||
|
# ainsi que les membres d'une équipe inscrite au tournoi et qui est validée.
|
||||||
Channel.objects.update_or_create(
|
Channel.objects.update_or_create(
|
||||||
name=f"{tournament.name} - Annonces",
|
name=f"{tournament.name} - Annonces",
|
||||||
defaults=dict(
|
defaults=dict(
|
||||||
@ -89,6 +107,7 @@ class Command(BaseCommand):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Un canal réservé à tous⋅tes les juré⋅es du tournoi est créé.
|
||||||
Channel.objects.update_or_create(
|
Channel.objects.update_or_create(
|
||||||
name=f"{tournament.name} - Juré⋅es",
|
name=f"{tournament.name} - Juré⋅es",
|
||||||
defaults=dict(
|
defaults=dict(
|
||||||
@ -100,6 +119,7 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if tournament.remote:
|
if tournament.remote:
|
||||||
|
# Dans le cadre d'un tournoi distanciel, un canal pour les président⋅es de jury est créé.
|
||||||
Channel.objects.update_or_create(
|
Channel.objects.update_or_create(
|
||||||
name=f"{tournament.name} - Président⋅es de jury",
|
name=f"{tournament.name} - Président⋅es de jury",
|
||||||
defaults=dict(
|
defaults=dict(
|
||||||
@ -111,6 +131,8 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
|
|
||||||
for pool in tournament.pools.all():
|
for pool in tournament.pools.all():
|
||||||
|
# Pour chaque poule d'un tournoi distanciel, on crée un canal pour les membres de la poule
|
||||||
|
# (équipes et juré⋅es), et un pour les juré⋅es uniquement.
|
||||||
Channel.objects.update_or_create(
|
Channel.objects.update_or_create(
|
||||||
name=f"{tournament.name} - Poule {pool.short_name}",
|
name=f"{tournament.name} - Poule {pool.short_name}",
|
||||||
defaults=dict(
|
defaults=dict(
|
||||||
@ -132,6 +154,7 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
|
|
||||||
for team in Team.objects.filter(participation__valid=True).all():
|
for team in Team.objects.filter(participation__valid=True).all():
|
||||||
|
# Chaque équipe validée a le droit à son canal de communication.
|
||||||
Channel.objects.update_or_create(
|
Channel.objects.update_or_create(
|
||||||
name=f"Équipe {team.trigram}",
|
name=f"Équipe {team.trigram}",
|
||||||
defaults=dict(
|
defaults=dict(
|
||||||
|
@ -0,0 +1,94 @@
|
|||||||
|
# Generated by Django 5.0.6 on 2024-05-26 20:08
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("chat", "0003_message_users_read"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="channel",
|
||||||
|
name="category",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("general", "General channels"),
|
||||||
|
("tournament", "Tournament channels"),
|
||||||
|
("team", "Team channels"),
|
||||||
|
("private", "Private channels"),
|
||||||
|
],
|
||||||
|
default="general",
|
||||||
|
help_text="Category of the channel, between general channels, tournament-specific channels, team channels or private channels. Will be used to sort channels in the channel list.",
|
||||||
|
max_length=255,
|
||||||
|
verbose_name="category",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="channel",
|
||||||
|
name="name",
|
||||||
|
field=models.CharField(
|
||||||
|
help_text="Visible name of the channel.",
|
||||||
|
max_length=255,
|
||||||
|
verbose_name="name",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="channel",
|
||||||
|
name="read_access",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("anonymous", "Everyone, including anonymous users"),
|
||||||
|
("authenticated", "Authenticated users"),
|
||||||
|
("volunteer", "All volunteers"),
|
||||||
|
("tournament", "All members of a given tournament"),
|
||||||
|
("organizer", "Tournament organizers only"),
|
||||||
|
(
|
||||||
|
"jury_president",
|
||||||
|
"Tournament organizers and jury presidents of the tournament",
|
||||||
|
),
|
||||||
|
("jury", "Jury members of the pool"),
|
||||||
|
("pool", "Jury members and participants of the pool"),
|
||||||
|
(
|
||||||
|
"team",
|
||||||
|
"Members of the team and organizers of concerned tournaments",
|
||||||
|
),
|
||||||
|
("private", "Private, reserved to explicit authorized users"),
|
||||||
|
("admin", "Admin users"),
|
||||||
|
],
|
||||||
|
help_text="Permission type that is required to read the messages of the channels.",
|
||||||
|
max_length=16,
|
||||||
|
verbose_name="read permission",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="channel",
|
||||||
|
name="write_access",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("anonymous", "Everyone, including anonymous users"),
|
||||||
|
("authenticated", "Authenticated users"),
|
||||||
|
("volunteer", "All volunteers"),
|
||||||
|
("tournament", "All members of a given tournament"),
|
||||||
|
("organizer", "Tournament organizers only"),
|
||||||
|
(
|
||||||
|
"jury_president",
|
||||||
|
"Tournament organizers and jury presidents of the tournament",
|
||||||
|
),
|
||||||
|
("jury", "Jury members of the pool"),
|
||||||
|
("pool", "Jury members and participants of the pool"),
|
||||||
|
(
|
||||||
|
"team",
|
||||||
|
"Members of the team and organizers of concerned tournaments",
|
||||||
|
),
|
||||||
|
("private", "Private, reserved to explicit authorized users"),
|
||||||
|
("admin", "Admin users"),
|
||||||
|
],
|
||||||
|
help_text="Permission type that is required to write a message to a channel.",
|
||||||
|
max_length=16,
|
||||||
|
verbose_name="write permission",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -13,6 +13,11 @@ from tfjm.permissions import PermissionType
|
|||||||
|
|
||||||
|
|
||||||
class Channel(models.Model):
|
class Channel(models.Model):
|
||||||
|
"""
|
||||||
|
Ce modèle représente un canal de chat, défini par son nom, sa catégorie, les permissions de lecture et d'écriture
|
||||||
|
requises pour accéder au canal, et éventuellement un tournoi, une poule ou une équipe associée.
|
||||||
|
"""
|
||||||
|
|
||||||
class ChannelCategory(models.TextChoices):
|
class ChannelCategory(models.TextChoices):
|
||||||
GENERAL = 'general', _("General channels")
|
GENERAL = 'general', _("General channels")
|
||||||
TOURNAMENT = 'tournament', _("Tournament channels")
|
TOURNAMENT = 'tournament', _("Tournament channels")
|
||||||
@ -22,6 +27,7 @@ class Channel(models.Model):
|
|||||||
name = models.CharField(
|
name = models.CharField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
verbose_name=_("name"),
|
verbose_name=_("name"),
|
||||||
|
help_text=_("Visible name of the channel."),
|
||||||
)
|
)
|
||||||
|
|
||||||
category = models.CharField(
|
category = models.CharField(
|
||||||
@ -29,18 +35,22 @@ class Channel(models.Model):
|
|||||||
verbose_name=_("category"),
|
verbose_name=_("category"),
|
||||||
choices=ChannelCategory,
|
choices=ChannelCategory,
|
||||||
default=ChannelCategory.GENERAL,
|
default=ChannelCategory.GENERAL,
|
||||||
|
help_text=_("Category of the channel, between general channels, tournament-specific channels, team channels "
|
||||||
|
"or private channels. Will be used to sort channels in the channel list."),
|
||||||
)
|
)
|
||||||
|
|
||||||
read_access = models.CharField(
|
read_access = models.CharField(
|
||||||
max_length=16,
|
max_length=16,
|
||||||
verbose_name=_("read permission"),
|
verbose_name=_("read permission"),
|
||||||
choices=PermissionType,
|
choices=PermissionType,
|
||||||
|
help_text=_("Permission type that is required to read the messages of the channels."),
|
||||||
)
|
)
|
||||||
|
|
||||||
write_access = models.CharField(
|
write_access = models.CharField(
|
||||||
max_length=16,
|
max_length=16,
|
||||||
verbose_name=_("write permission"),
|
verbose_name=_("write permission"),
|
||||||
choices=PermissionType,
|
choices=PermissionType,
|
||||||
|
help_text=_("Permission type that is required to write a message to a channel."),
|
||||||
)
|
)
|
||||||
|
|
||||||
tournament = models.ForeignKey(
|
tournament = models.ForeignKey(
|
||||||
@ -92,10 +102,20 @@ class Channel(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_visible_name(self, user: User) -> str:
|
def get_visible_name(self, user: User) -> str:
|
||||||
|
"""
|
||||||
|
Renvoie le nom du channel tel qu'il est visible pour l'utilisateur⋅rice donné.
|
||||||
|
Dans le cas d'un canal classique, renvoie directement le nom.
|
||||||
|
Dans le cas d'un canal privé, renvoie la liste des personnes membres du canal,
|
||||||
|
à l'exception de la personne connectée, afin de ne pas afficher son propre nom.
|
||||||
|
Dans le cas d'un chat avec uniquement soi-même, on affiche que notre propre nom.
|
||||||
|
"""
|
||||||
if self.private:
|
if self.private:
|
||||||
|
# Le canal est privé, on renvoie la liste des personnes membres du canal
|
||||||
|
# à l'exception de soi-même (sauf si on est la seule personne dans le canal)
|
||||||
users = [f"{u.first_name} {u.last_name}" for u in self.invited.all() if u != user] \
|
users = [f"{u.first_name} {u.last_name}" for u in self.invited.all() if u != user] \
|
||||||
or [f"{user.first_name} {user.last_name}"]
|
or [f"{user.first_name} {user.last_name}"]
|
||||||
return ", ".join(users)
|
return ", ".join(users)
|
||||||
|
# Le canal est public, on renvoie directement le nom
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@ -103,39 +123,77 @@ class Channel(models.Model):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_accessible_channels(user: User, permission_type: str = 'read') -> QuerySet["Channel"]:
|
async def get_accessible_channels(user: User, permission_type: str = 'read') -> QuerySet["Channel"]:
|
||||||
|
"""
|
||||||
|
Renvoie les canaux auxquels l'utilisateur⋅rice donné a accès, en lecture ou en écriture.
|
||||||
|
|
||||||
|
Types de permissions :
|
||||||
|
ANONYMOUS : Tout le monde, y compris les utilisateur⋅rices non connecté⋅es
|
||||||
|
AUTHENTICATED : Toustes les utilisateur⋅rices connecté⋅es
|
||||||
|
VOLUNTEER : Toustes les bénévoles
|
||||||
|
TOURNAMENT_MEMBER : Toustes les membres d'un tournoi donné (orgas, juré⋅es, participant⋅es)
|
||||||
|
TOURNAMENT_ORGANIZER : Les organisateur⋅rices d'un tournoi donné
|
||||||
|
TOURNAMENT_JURY_PRESIDENT : Les organisateur⋅rices et les président⋅es de jury d'un tournoi donné
|
||||||
|
JURY_MEMBER : Les membres du jury d'une poule donnée, ou les organisateur⋅rices du tournoi
|
||||||
|
POOL_MEMBER : Les membres du jury et les participant⋅es d'une poule donnée, ou les organisateur⋅rices du tournoi
|
||||||
|
TEAM_MEMBER : Les membres d'une équipe donnée
|
||||||
|
PRIVATE : Les utilisateur⋅rices explicitement invité⋅es
|
||||||
|
ADMIN : Les utilisateur⋅rices administrateur⋅rices (qui ont accès à tout)
|
||||||
|
|
||||||
|
Les canaux privés sont utilisés pour les messages privés, et ne sont pas affichés aux admins.
|
||||||
|
|
||||||
|
:param user: L'utilisateur⋅rice dont on veut récupérer la liste des canaux.
|
||||||
|
:param permission_type: Le type de permission concerné (read ou write).
|
||||||
|
:return: Le Queryset des canaux autorisés.
|
||||||
|
"""
|
||||||
permission_type = 'write_access' if 'write' in permission_type.lower() else 'read_access'
|
permission_type = 'write_access' if 'write' in permission_type.lower() else 'read_access'
|
||||||
|
|
||||||
qs = Channel.objects.none()
|
qs = Channel.objects.none()
|
||||||
if user.is_anonymous:
|
if user.is_anonymous:
|
||||||
|
# Les utilisateur⋅rices non connecté⋅es ont accès aux canaux publics pour toustes
|
||||||
return Channel.objects.filter(**{permission_type: PermissionType.ANONYMOUS})
|
return Channel.objects.filter(**{permission_type: PermissionType.ANONYMOUS})
|
||||||
|
|
||||||
|
# Les utilisateur⋅rices connecté⋅es ont accès aux canaux publics pour les personnes connectées
|
||||||
qs |= Channel.objects.filter(**{permission_type: PermissionType.AUTHENTICATED})
|
qs |= Channel.objects.filter(**{permission_type: PermissionType.AUTHENTICATED})
|
||||||
registration = await Registration.objects.prefetch_related('user').aget(user_id=user.id)
|
registration = await Registration.objects.prefetch_related('user').aget(user_id=user.id)
|
||||||
|
|
||||||
if registration.is_admin:
|
if registration.is_admin:
|
||||||
|
# Les administrateur⋅rices ont accès à tous les canaux, sauf les canaux privés sont iels ne sont pas membres
|
||||||
return Channel.objects.prefetch_related('invited').exclude(~Q(invited=user) & Q(private=True)).all()
|
return Channel.objects.prefetch_related('invited').exclude(~Q(invited=user) & Q(private=True)).all()
|
||||||
|
|
||||||
if registration.is_volunteer:
|
if registration.is_volunteer:
|
||||||
registration = await VolunteerRegistration.objects \
|
registration = await VolunteerRegistration.objects \
|
||||||
.prefetch_related('jury_in__tournament', 'organized_tournaments').aget(user_id=user.id)
|
.prefetch_related('jury_in__tournament', 'organized_tournaments').aget(user_id=user.id)
|
||||||
|
|
||||||
|
# Les bénévoles ont accès aux canaux pour bénévoles
|
||||||
qs |= Channel.objects.filter(**{permission_type: PermissionType.VOLUNTEER})
|
qs |= Channel.objects.filter(**{permission_type: PermissionType.VOLUNTEER})
|
||||||
|
|
||||||
|
# Iels ont accès aux tournois dont iels sont organisateur⋅rices ou juré⋅es
|
||||||
|
# pour la permission TOURNAMENT_MEMBER
|
||||||
qs |= Channel.objects.filter(Q(tournament__in=registration.interesting_tournaments),
|
qs |= Channel.objects.filter(Q(tournament__in=registration.interesting_tournaments),
|
||||||
**{permission_type: PermissionType.TOURNAMENT_MEMBER})
|
**{permission_type: PermissionType.TOURNAMENT_MEMBER})
|
||||||
|
|
||||||
|
# Iels ont accès aux canaux pour les organisateur⋅rices des tournois dont iels sont organisateur⋅rices
|
||||||
|
# pour la permission TOURNAMENT_ORGANIZER
|
||||||
qs |= Channel.objects.filter(Q(tournament__in=registration.organized_tournaments.all()),
|
qs |= Channel.objects.filter(Q(tournament__in=registration.organized_tournaments.all()),
|
||||||
**{permission_type: PermissionType.TOURNAMENT_ORGANIZER})
|
**{permission_type: PermissionType.TOURNAMENT_ORGANIZER})
|
||||||
|
|
||||||
|
# Iels ont accès aux canaux pour les organisateur⋅rices et président⋅es de jury des tournois dont iels sont
|
||||||
|
# organisateur⋅rices ou juré⋅es pour la permission TOURNAMENT_JURY_PRESIDENT
|
||||||
qs |= Channel.objects.filter(Q(tournament__pools__in=registration.pools_presided.all())
|
qs |= Channel.objects.filter(Q(tournament__pools__in=registration.pools_presided.all())
|
||||||
| Q(tournament__in=registration.organized_tournaments.all()),
|
| Q(tournament__in=registration.organized_tournaments.all()),
|
||||||
**{permission_type: PermissionType.TOURNAMENT_JURY_PRESIDENT})
|
**{permission_type: PermissionType.TOURNAMENT_JURY_PRESIDENT})
|
||||||
|
|
||||||
|
# Iels ont accès aux canaux pour les juré⋅es des poules dont iels sont juré⋅es
|
||||||
|
# ou les organisateur⋅rices des tournois dont iels sont organisateur⋅rices
|
||||||
|
# pour la permission JURY_MEMBER
|
||||||
qs |= Channel.objects.filter(Q(pool__in=registration.jury_in.all())
|
qs |= Channel.objects.filter(Q(pool__in=registration.jury_in.all())
|
||||||
| Q(pool__tournament__in=registration.organized_tournaments.all())
|
| Q(pool__tournament__in=registration.organized_tournaments.all())
|
||||||
| Q(pool__tournament__pools__in=registration.pools_presided.all()),
|
| Q(pool__tournament__pools__in=registration.pools_presided.all()),
|
||||||
**{permission_type: PermissionType.JURY_MEMBER})
|
**{permission_type: PermissionType.JURY_MEMBER})
|
||||||
|
|
||||||
|
# Iels ont accès aux canaux pour les juré⋅es et participant⋅es des poules dont iels sont juré⋅es
|
||||||
|
# ou les organisateur⋅rices des tournois dont iels sont organisateur⋅rices
|
||||||
|
# pour la permission POOL_MEMBER
|
||||||
qs |= Channel.objects.filter(Q(pool__in=registration.jury_in.all())
|
qs |= Channel.objects.filter(Q(pool__in=registration.jury_in.all())
|
||||||
| Q(pool__tournament__in=registration.organized_tournaments.all())
|
| Q(pool__tournament__in=registration.organized_tournaments.all())
|
||||||
| Q(pool__tournament__pools__in=registration.pools_presided.all()),
|
| Q(pool__tournament__pools__in=registration.pools_presided.all()),
|
||||||
@ -151,15 +209,20 @@ class Channel(models.Model):
|
|||||||
if team.participation.final:
|
if team.participation.final:
|
||||||
tournaments.append(await Tournament.objects.aget(final=True))
|
tournaments.append(await Tournament.objects.aget(final=True))
|
||||||
|
|
||||||
|
# Les participant⋅es ont accès aux canaux généraux pour le tournoi dont iels sont membres
|
||||||
|
# Cela comprend la finale s'iels sont finalistes
|
||||||
qs |= Channel.objects.filter(Q(tournament__in=tournaments),
|
qs |= Channel.objects.filter(Q(tournament__in=tournaments),
|
||||||
**{permission_type: PermissionType.TOURNAMENT_MEMBER})
|
**{permission_type: PermissionType.TOURNAMENT_MEMBER})
|
||||||
|
|
||||||
|
# Iels ont accès aux canaux généraux pour les poules dont iels sont participant⋅es
|
||||||
qs |= Channel.objects.filter(Q(pool__in=team.participation.pools.all()),
|
qs |= Channel.objects.filter(Q(pool__in=team.participation.pools.all()),
|
||||||
**{permission_type: PermissionType.POOL_MEMBER})
|
**{permission_type: PermissionType.POOL_MEMBER})
|
||||||
|
|
||||||
|
# Iels ont accès aux canaux propres à leur équipe
|
||||||
qs |= Channel.objects.filter(Q(team=team),
|
qs |= Channel.objects.filter(Q(team=team),
|
||||||
**{permission_type: PermissionType.TEAM_MEMBER})
|
**{permission_type: PermissionType.TEAM_MEMBER})
|
||||||
|
|
||||||
|
# Les utilisateur⋅rices ont de plus accès aux messages privés qui leur sont adressés
|
||||||
qs |= Channel.objects.filter(invited=user).prefetch_related('invited')
|
qs |= Channel.objects.filter(invited=user).prefetch_related('invited')
|
||||||
|
|
||||||
return qs
|
return qs
|
||||||
@ -171,6 +234,12 @@ class Channel(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class Message(models.Model):
|
class Message(models.Model):
|
||||||
|
"""
|
||||||
|
Ce modèle représente un message de chat.
|
||||||
|
Un message appartient à un canal, et est défini par son contenu, son auteur⋅rice, sa date de création et sa date
|
||||||
|
de dernière modification.
|
||||||
|
De plus, on garde en mémoire les utilisateur⋅rices qui ont lu le message.
|
||||||
|
"""
|
||||||
channel = models.ForeignKey(
|
channel = models.ForeignKey(
|
||||||
Channel,
|
Channel,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
@ -208,55 +277,74 @@ class Message(models.Model):
|
|||||||
help_text=_("Users who have read the message."),
|
help_text=_("Users who have read the message."),
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_author_name(self):
|
def get_author_name(self) -> str:
|
||||||
|
"""
|
||||||
|
Renvoie le nom de l'auteur⋅rice du message, en fonction de son rôle dans l'organisation
|
||||||
|
dans le cadre d'un⋅e bénévole, ou de son équipe dans le cadre d'un⋅e participant⋅e.
|
||||||
|
"""
|
||||||
registration = self.author.registration
|
registration = self.author.registration
|
||||||
|
|
||||||
author_name = f"{self.author.first_name} {self.author.last_name}"
|
author_name = f"{self.author.first_name} {self.author.last_name}"
|
||||||
if registration.is_volunteer:
|
if registration.is_volunteer:
|
||||||
if registration.is_admin:
|
if registration.is_admin:
|
||||||
|
# Les administrateur⋅rices ont le suffixe (CNO)
|
||||||
author_name += " (CNO)"
|
author_name += " (CNO)"
|
||||||
|
|
||||||
if self.channel.pool:
|
if self.channel.pool:
|
||||||
if registration == self.channel.pool.jury_president:
|
if registration == self.channel.pool.jury_president:
|
||||||
|
# Læ président⋅e de jury de la poule a le suffixe (P. jury)
|
||||||
author_name += " (P. jury)"
|
author_name += " (P. jury)"
|
||||||
elif registration in self.channel.pool.juries.all():
|
elif registration in self.channel.pool.juries.all():
|
||||||
|
# Les juré⋅es de la poule ont le suffixe (Juré⋅e)
|
||||||
author_name += " (Juré⋅e)"
|
author_name += " (Juré⋅e)"
|
||||||
elif registration in self.channel.pool.tournament.organizers.all():
|
elif registration in self.channel.pool.tournament.organizers.all():
|
||||||
|
# Les organisateur⋅rices du tournoi ont le suffixe (CRO)
|
||||||
author_name += " (CRO)"
|
author_name += " (CRO)"
|
||||||
else:
|
else:
|
||||||
|
# Les éventuel⋅les autres bénévoles ont le suffixe (Bénévole)
|
||||||
author_name += " (Bénévole)"
|
author_name += " (Bénévole)"
|
||||||
elif self.channel.tournament:
|
elif self.channel.tournament:
|
||||||
if registration in self.channel.tournament.organizers.all():
|
if registration in self.channel.tournament.organizers.all():
|
||||||
|
# Les organisateur⋅rices du tournoi ont le suffixe (CRO)
|
||||||
author_name += " (CRO)"
|
author_name += " (CRO)"
|
||||||
elif any([registration.id == pool.jury_president
|
elif any([registration.id == pool.jury_president
|
||||||
for pool in self.channel.tournament.pools.all()]):
|
for pool in self.channel.tournament.pools.all()]):
|
||||||
|
# Les président⋅es de jury des poules ont le suffixe (P. jury)
|
||||||
|
# mentionnant l'ensemble des poules qu'iels président
|
||||||
pools = ", ".join([pool.short_name
|
pools = ", ".join([pool.short_name
|
||||||
for pool in self.channel.tournament.pools.all()
|
for pool in self.channel.tournament.pools.all()
|
||||||
if pool.jury_president == registration])
|
if pool.jury_president == registration])
|
||||||
author_name += f" (P. jury {pools})"
|
author_name += f" (P. jury {pools})"
|
||||||
elif any([pool.juries.contains(registration)
|
elif any([pool.juries.contains(registration)
|
||||||
for pool in self.channel.tournament.pools.all()]):
|
for pool in self.channel.tournament.pools.all()]):
|
||||||
|
# Les juré⋅es des poules ont le suffixe (Juré⋅e)
|
||||||
|
# mentionnant l'ensemble des poules auxquelles iels participent
|
||||||
pools = ", ".join([pool.short_name
|
pools = ", ".join([pool.short_name
|
||||||
for pool in self.channel.tournament.pools.all()
|
for pool in self.channel.tournament.pools.all()
|
||||||
if pool.juries.acontains(registration)])
|
if pool.juries.acontains(registration)])
|
||||||
author_name += f" (Juré⋅e {pools})"
|
author_name += f" (Juré⋅e {pools})"
|
||||||
else:
|
else:
|
||||||
|
# Les éventuel⋅les autres bénévoles ont le suffixe (Bénévole)
|
||||||
author_name += " (Bénévole)"
|
author_name += " (Bénévole)"
|
||||||
else:
|
else:
|
||||||
if registration.organized_tournaments.exists():
|
if registration.organized_tournaments.exists():
|
||||||
|
# Les organisateur⋅rices de tournois ont le suffixe (CRO) mentionnant les tournois organisés
|
||||||
tournaments = ", ".join([tournament.name
|
tournaments = ", ".join([tournament.name
|
||||||
for tournament in registration.organized_tournaments.all()])
|
for tournament in registration.organized_tournaments.all()])
|
||||||
author_name += f" (CRO {tournaments})"
|
author_name += f" (CRO {tournaments})"
|
||||||
if Pool.objects.filter(jury_president=registration).exists():
|
if Pool.objects.filter(jury_president=registration).exists():
|
||||||
|
# Les président⋅es de jury ont le suffixe (P. jury) mentionnant les tournois présidés
|
||||||
tournaments = Tournament.objects.filter(pools__jury_president=registration).distinct()
|
tournaments = Tournament.objects.filter(pools__jury_president=registration).distinct()
|
||||||
tournaments = ", ".join([tournament.name for tournament in tournaments])
|
tournaments = ", ".join([tournament.name for tournament in tournaments])
|
||||||
author_name += f" (P. jury {tournaments})"
|
author_name += f" (P. jury {tournaments})"
|
||||||
elif registration.jury_in.exists():
|
elif registration.jury_in.exists():
|
||||||
|
# Les juré⋅es ont le suffixe (Juré⋅e) mentionnant les tournois auxquels iels participent
|
||||||
tournaments = Tournament.objects.filter(pools__juries=registration).distinct()
|
tournaments = Tournament.objects.filter(pools__juries=registration).distinct()
|
||||||
tournaments = ", ".join([tournament.name for tournament in tournaments])
|
tournaments = ", ".join([tournament.name for tournament in tournaments])
|
||||||
author_name += f" (Juré⋅e {tournaments})"
|
author_name += f" (Juré⋅e {tournaments})"
|
||||||
else:
|
else:
|
||||||
if registration.team_id:
|
if registration.team_id:
|
||||||
|
# Le trigramme de l'équipe de læ participant⋅e est ajouté en suffixe
|
||||||
team = Team.objects.get(id=registration.team_id)
|
team = Team.objects.get(id=registration.team_id)
|
||||||
author_name += f" ({team.trigram})"
|
author_name += f" ({team.trigram})"
|
||||||
else:
|
else:
|
||||||
@ -264,7 +352,11 @@ class Message(models.Model):
|
|||||||
|
|
||||||
return author_name
|
return author_name
|
||||||
|
|
||||||
async def aget_author_name(self):
|
async def aget_author_name(self) -> str:
|
||||||
|
"""
|
||||||
|
Fonction asynchrone pour récupérer le nom de l'auteur⋅rice du message.
|
||||||
|
Voir `get_author_name` pour plus de détails.
|
||||||
|
"""
|
||||||
return await sync_to_async(self.get_author_name)()
|
return await sync_to_async(self.get_author_name)()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -7,8 +7,14 @@ from tfjm.permissions import PermissionType
|
|||||||
|
|
||||||
|
|
||||||
def create_tournament_channels(instance: Tournament, **_kwargs):
|
def create_tournament_channels(instance: Tournament, **_kwargs):
|
||||||
|
"""
|
||||||
|
Lorsqu'un tournoi est créé, on crée les canaux de chat associés.
|
||||||
|
On crée notamment un canal d'annonces (accessible en écriture uniquement aux orgas),
|
||||||
|
un canal général, un de détente, un pour les juré⋅es et un pour les président⋅es de jury.
|
||||||
|
"""
|
||||||
tournament = instance
|
tournament = instance
|
||||||
|
|
||||||
|
# Création du canal « Tournoi - Annonces »
|
||||||
Channel.objects.update_or_create(
|
Channel.objects.update_or_create(
|
||||||
name=f"{tournament.name} - Annonces",
|
name=f"{tournament.name} - Annonces",
|
||||||
defaults=dict(
|
defaults=dict(
|
||||||
@ -19,6 +25,7 @@ def create_tournament_channels(instance: Tournament, **_kwargs):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Création du canal « Tournoi - Général »
|
||||||
Channel.objects.update_or_create(
|
Channel.objects.update_or_create(
|
||||||
name=f"{tournament.name} - Général",
|
name=f"{tournament.name} - Général",
|
||||||
defaults=dict(
|
defaults=dict(
|
||||||
@ -29,6 +36,7 @@ def create_tournament_channels(instance: Tournament, **_kwargs):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Création du canal « Tournoi - Détente »
|
||||||
Channel.objects.update_or_create(
|
Channel.objects.update_or_create(
|
||||||
name=f"{tournament.name} - Détente",
|
name=f"{tournament.name} - Détente",
|
||||||
defaults=dict(
|
defaults=dict(
|
||||||
@ -39,6 +47,7 @@ def create_tournament_channels(instance: Tournament, **_kwargs):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Création du canal « Tournoi - Juré⋅es »
|
||||||
Channel.objects.update_or_create(
|
Channel.objects.update_or_create(
|
||||||
name=f"{tournament.name} - Juré⋅es",
|
name=f"{tournament.name} - Juré⋅es",
|
||||||
defaults=dict(
|
defaults=dict(
|
||||||
@ -50,6 +59,7 @@ def create_tournament_channels(instance: Tournament, **_kwargs):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if tournament.remote:
|
if tournament.remote:
|
||||||
|
# Création du canal « Tournoi - Président⋅es de jury » dans le cas d'un tournoi distanciel
|
||||||
Channel.objects.update_or_create(
|
Channel.objects.update_or_create(
|
||||||
name=f"{tournament.name} - Président⋅es de jury",
|
name=f"{tournament.name} - Président⋅es de jury",
|
||||||
defaults=dict(
|
defaults=dict(
|
||||||
@ -62,10 +72,17 @@ def create_tournament_channels(instance: Tournament, **_kwargs):
|
|||||||
|
|
||||||
|
|
||||||
def create_pool_channels(instance: Pool, **_kwargs):
|
def create_pool_channels(instance: Pool, **_kwargs):
|
||||||
|
"""
|
||||||
|
Lorsqu'une poule est créée, on crée les canaux de chat associés.
|
||||||
|
On crée notamment un canal pour les membres de la poule et un pour les juré⋅es.
|
||||||
|
Cela ne concerne que les tournois distanciels.
|
||||||
|
"""
|
||||||
pool = instance
|
pool = instance
|
||||||
tournament = pool.tournament
|
tournament = pool.tournament
|
||||||
|
|
||||||
if tournament.remote:
|
if tournament.remote:
|
||||||
|
# Dans le cadre d'un tournoi distanciel, on crée un canal pour les membres de la poule
|
||||||
|
# et un pour les juré⋅es de la poule.
|
||||||
Channel.objects.update_or_create(
|
Channel.objects.update_or_create(
|
||||||
name=f"{tournament.name} - Poule {pool.short_name}",
|
name=f"{tournament.name} - Poule {pool.short_name}",
|
||||||
defaults=dict(
|
defaults=dict(
|
||||||
@ -88,6 +105,9 @@ def create_pool_channels(instance: Pool, **_kwargs):
|
|||||||
|
|
||||||
|
|
||||||
def create_team_channel(instance: Participation, **_kwargs):
|
def create_team_channel(instance: Participation, **_kwargs):
|
||||||
|
"""
|
||||||
|
Lorsqu'une équipe est validée, on crée un canal de chat associé.
|
||||||
|
"""
|
||||||
if instance.valid:
|
if instance.valid:
|
||||||
Channel.objects.update_or_create(
|
Channel.objects.update_or_create(
|
||||||
name=f"Équipe {instance.team.trigram}",
|
name=f"Équipe {instance.team.trigram}",
|
||||||
|
@ -1,595 +0,0 @@
|
|||||||
(async () => {
|
|
||||||
// check notification permission
|
|
||||||
// This is useful to alert people that they should do something
|
|
||||||
await Notification.requestPermission()
|
|
||||||
})()
|
|
||||||
|
|
||||||
const MAX_MESSAGES = 50
|
|
||||||
|
|
||||||
const channel_categories = ['general', 'tournament', 'team', 'private']
|
|
||||||
let channels = {}
|
|
||||||
let messages = {}
|
|
||||||
let selected_channel_id = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display a new notification with the given title and the given body.
|
|
||||||
* @param title The title of the notification
|
|
||||||
* @param body The body of the notification
|
|
||||||
* @param timeout The time (in milliseconds) after that the notification automatically closes. 0 to make indefinite. Default to 5000 ms.
|
|
||||||
* @return Notification
|
|
||||||
*/
|
|
||||||
function showNotification(title, body, timeout = 5000) {
|
|
||||||
Notification.requestPermission().then((status) => {
|
|
||||||
if (status === 'granted')
|
|
||||||
new Notification(title, {'body': body, 'icon': "/static/tfjm-192.png"})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectChannel(channel_id) {
|
|
||||||
let channel = channels[channel_id]
|
|
||||||
if (!channel) {
|
|
||||||
console.error('Channel not found:', channel_id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
selected_channel_id = channel_id
|
|
||||||
localStorage.setItem('chat.last-channel-id', channel_id)
|
|
||||||
|
|
||||||
let channelTitle = document.getElementById('channel-title')
|
|
||||||
channelTitle.innerText = channel['name']
|
|
||||||
|
|
||||||
let messageInput = document.getElementById('input-message')
|
|
||||||
messageInput.disabled = !channel['write_access']
|
|
||||||
|
|
||||||
redrawMessages()
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendMessage() {
|
|
||||||
let messageInput = document.getElementById('input-message')
|
|
||||||
let message = messageInput.value
|
|
||||||
messageInput.value = ''
|
|
||||||
|
|
||||||
if (!message) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.send(JSON.stringify({
|
|
||||||
'type': 'send_message',
|
|
||||||
'channel_id': selected_channel_id,
|
|
||||||
'content': message,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function setChannels(new_channels) {
|
|
||||||
channels = {}
|
|
||||||
let categoryLists = {}
|
|
||||||
for (let category of channel_categories) {
|
|
||||||
categoryLists[category] = document.getElementById(`nav-${category}-channels-tab`)
|
|
||||||
categoryLists[category].innerHTML = ''
|
|
||||||
categoryLists[category].parentElement.classList.add('d-none')
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let channel of new_channels)
|
|
||||||
addChannel(channel, categoryLists)
|
|
||||||
|
|
||||||
if (new_channels && (!selected_channel_id || !channels[selected_channel_id])) {
|
|
||||||
let last_channel_id = parseInt(localStorage.getItem('chat.last-channel-id'))
|
|
||||||
if (last_channel_id && channels[last_channel_id])
|
|
||||||
selectChannel(last_channel_id)
|
|
||||||
else
|
|
||||||
selectChannel(Object.keys(channels)[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addChannel(channel, categoryLists) {
|
|
||||||
channels[channel['id']] = channel
|
|
||||||
if (!messages[channel['id']])
|
|
||||||
messages[channel['id']] = new Map()
|
|
||||||
|
|
||||||
let categoryList = categoryLists[channel['category']]
|
|
||||||
categoryList.parentElement.classList.remove('d-none')
|
|
||||||
|
|
||||||
let navItem = document.createElement('li')
|
|
||||||
navItem.classList.add('list-group-item', 'tab-channel')
|
|
||||||
navItem.id = `tab-channel-${channel['id']}`
|
|
||||||
navItem.setAttribute('data-bs-dismiss', 'offcanvas')
|
|
||||||
navItem.onclick = () => selectChannel(channel['id'])
|
|
||||||
categoryList.appendChild(navItem)
|
|
||||||
|
|
||||||
let channelButton = document.createElement('button')
|
|
||||||
channelButton.classList.add('nav-link')
|
|
||||||
channelButton.type = 'button'
|
|
||||||
channelButton.innerText = channel['name']
|
|
||||||
navItem.appendChild(channelButton)
|
|
||||||
|
|
||||||
let unreadBadge = document.createElement('span')
|
|
||||||
unreadBadge.classList.add('badge', 'rounded-pill', 'text-bg-light', 'ms-2')
|
|
||||||
unreadBadge.id = `unread-messages-${channel['id']}`
|
|
||||||
unreadBadge.innerText = channel.unread_messages || 0
|
|
||||||
if (!channel.unread_messages)
|
|
||||||
unreadBadge.classList.add('d-none')
|
|
||||||
channelButton.appendChild(unreadBadge)
|
|
||||||
|
|
||||||
if (document.getElementById('sort-by-unread-switch').checked)
|
|
||||||
navItem.style.order = `${-channel.unread_messages}`
|
|
||||||
|
|
||||||
fetchMessages(channel['id'])
|
|
||||||
}
|
|
||||||
|
|
||||||
function receiveMessage(message) {
|
|
||||||
let scrollableContent = document.getElementById('chat-messages')
|
|
||||||
let isScrolledToBottom = scrollableContent.scrollHeight - scrollableContent.clientHeight <= scrollableContent.scrollTop + 1
|
|
||||||
|
|
||||||
messages[message['channel_id']].set(message['id'], message)
|
|
||||||
redrawMessages()
|
|
||||||
|
|
||||||
// Scroll to bottom if the user was already at the bottom
|
|
||||||
if (isScrolledToBottom)
|
|
||||||
scrollableContent.scrollTop = scrollableContent.scrollHeight - scrollableContent.clientHeight
|
|
||||||
|
|
||||||
if (message['content'].includes("@everyone"))
|
|
||||||
showNotification(channels[message['channel_id']]['name'], `${message['author']} : ${message['content']}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function editMessage(data) {
|
|
||||||
messages[data['channel_id']].get(data['id'])['content'] = data['content']
|
|
||||||
redrawMessages()
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteMessage(data) {
|
|
||||||
messages[data['channel_id']].delete(data['id'])
|
|
||||||
redrawMessages()
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchMessages(channel_id, offset = 0, limit = MAX_MESSAGES) {
|
|
||||||
socket.send(JSON.stringify({
|
|
||||||
'type': 'fetch_messages',
|
|
||||||
'channel_id': channel_id,
|
|
||||||
'offset': offset,
|
|
||||||
'limit': limit,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchPreviousMessages() {
|
|
||||||
let channel_id = selected_channel_id
|
|
||||||
let offset = messages[channel_id].size
|
|
||||||
fetchMessages(channel_id, offset, MAX_MESSAGES)
|
|
||||||
}
|
|
||||||
|
|
||||||
function receiveFetchedMessages(data) {
|
|
||||||
let channel_id = data['channel_id']
|
|
||||||
let new_messages = data['messages']
|
|
||||||
|
|
||||||
if (!messages[channel_id])
|
|
||||||
messages[channel_id] = new Map()
|
|
||||||
|
|
||||||
for (let message of new_messages)
|
|
||||||
messages[channel_id].set(message['id'], message)
|
|
||||||
|
|
||||||
// Sort messages by timestamp
|
|
||||||
messages[channel_id] = new Map([...messages[channel_id].values()]
|
|
||||||
.sort((a, b) => new Date(a['timestamp']) - new Date(b['timestamp']))
|
|
||||||
.map(message => [message['id'], message]))
|
|
||||||
|
|
||||||
redrawMessages()
|
|
||||||
}
|
|
||||||
|
|
||||||
function markMessageAsRead(data) {
|
|
||||||
for (let message of data['messages']) {
|
|
||||||
let stored_message = messages[message['channel_id']].get(message['id'])
|
|
||||||
if (stored_message)
|
|
||||||
stored_message['read'] = true
|
|
||||||
}
|
|
||||||
redrawMessages()
|
|
||||||
updateUnreadBadges(data['unread_messages'])
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateUnreadBadges(unreadMessages) {
|
|
||||||
const sortByUnread = document.getElementById('sort-by-unread-switch').checked
|
|
||||||
|
|
||||||
for (let channel of Object.values(channels)) {
|
|
||||||
let unreadMessagesChannel = unreadMessages[channel['id']] || 0
|
|
||||||
channel.unread_messages = unreadMessagesChannel
|
|
||||||
|
|
||||||
let unreadBadge = document.getElementById(`unread-messages-${channel['id']}`)
|
|
||||||
unreadBadge.innerText = unreadMessagesChannel
|
|
||||||
if (unreadMessagesChannel)
|
|
||||||
unreadBadge.classList.remove('d-none')
|
|
||||||
else
|
|
||||||
unreadBadge.classList.add('d-none')
|
|
||||||
|
|
||||||
if (sortByUnread)
|
|
||||||
document.getElementById(`tab-channel-${channel['id']}`).style.order = `${-unreadMessagesChannel}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startPrivateChat(data) {
|
|
||||||
let channel = data['channel']
|
|
||||||
if (!channel) {
|
|
||||||
console.error('Private chat not found:', data)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!channels[channel['id']]) {
|
|
||||||
channels[channel['id']] = channel
|
|
||||||
messages[channel['id']] = new Map()
|
|
||||||
setChannels(Object.values(channels))
|
|
||||||
}
|
|
||||||
|
|
||||||
selectChannel(channel['id'])
|
|
||||||
}
|
|
||||||
|
|
||||||
function redrawMessages() {
|
|
||||||
let messageList = document.getElementById('message-list')
|
|
||||||
messageList.innerHTML = ''
|
|
||||||
|
|
||||||
let lastMessage = null
|
|
||||||
let lastContentDiv = null
|
|
||||||
|
|
||||||
for (let message of messages[selected_channel_id].values()) {
|
|
||||||
if (lastMessage && lastMessage['author'] === message['author']) {
|
|
||||||
let lastTimestamp = new Date(lastMessage['timestamp'])
|
|
||||||
let newTimestamp = new Date(message['timestamp'])
|
|
||||||
if ((newTimestamp - lastTimestamp) / 1000 < 60 * 10) {
|
|
||||||
let messageContentDiv = document.createElement('div')
|
|
||||||
messageContentDiv.classList.add('message')
|
|
||||||
messageContentDiv.setAttribute('data-message-id', message['id'])
|
|
||||||
lastContentDiv.appendChild(messageContentDiv)
|
|
||||||
let messageContentSpan = document.createElement('span')
|
|
||||||
messageContentSpan.innerHTML = markdownToHTML(message['content'])
|
|
||||||
messageContentDiv.appendChild(messageContentSpan)
|
|
||||||
|
|
||||||
registerMessageContextMenu(message, messageContentDiv, messageContentSpan)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let messageElement = document.createElement('li')
|
|
||||||
messageElement.classList.add('list-group-item')
|
|
||||||
messageList.appendChild(messageElement)
|
|
||||||
|
|
||||||
let authorDiv = document.createElement('div')
|
|
||||||
messageElement.appendChild(authorDiv)
|
|
||||||
|
|
||||||
let authorSpan = document.createElement('span')
|
|
||||||
authorSpan.classList.add('text-muted', 'fw-bold')
|
|
||||||
authorSpan.innerText = message['author']
|
|
||||||
authorDiv.appendChild(authorSpan)
|
|
||||||
|
|
||||||
registerSendPrivateMessageContextMenu(message, authorDiv, authorSpan)
|
|
||||||
|
|
||||||
let dateSpan = document.createElement('span')
|
|
||||||
dateSpan.classList.add('text-muted', 'float-end')
|
|
||||||
dateSpan.innerText = new Date(message['timestamp']).toLocaleString()
|
|
||||||
authorDiv.appendChild(dateSpan)
|
|
||||||
|
|
||||||
let contentDiv = document.createElement('div')
|
|
||||||
messageElement.appendChild(contentDiv)
|
|
||||||
|
|
||||||
let messageContentDiv = document.createElement('div')
|
|
||||||
messageContentDiv.classList.add('message')
|
|
||||||
messageContentDiv.setAttribute('data-message-id', message['id'])
|
|
||||||
contentDiv.appendChild(messageContentDiv)
|
|
||||||
let messageContentSpan = document.createElement('span')
|
|
||||||
messageContentSpan.innerHTML = markdownToHTML(message['content'])
|
|
||||||
messageContentDiv.appendChild(messageContentSpan)
|
|
||||||
|
|
||||||
registerMessageContextMenu(message, messageContentDiv, messageContentSpan)
|
|
||||||
|
|
||||||
lastMessage = message
|
|
||||||
lastContentDiv = contentDiv
|
|
||||||
}
|
|
||||||
|
|
||||||
let fetchMoreButton = document.getElementById('fetch-previous-messages')
|
|
||||||
if (!messages[selected_channel_id].size || messages[selected_channel_id].size % MAX_MESSAGES !== 0)
|
|
||||||
fetchMoreButton.classList.add('d-none')
|
|
||||||
else
|
|
||||||
fetchMoreButton.classList.remove('d-none')
|
|
||||||
|
|
||||||
messageList.dispatchEvent(new CustomEvent('updatemessages'))
|
|
||||||
}
|
|
||||||
|
|
||||||
function markdownToHTML(text) {
|
|
||||||
let safeText = text.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'")
|
|
||||||
let lines = safeText.split('\n')
|
|
||||||
let htmlLines = []
|
|
||||||
for (let line of lines) {
|
|
||||||
let htmlLine = line
|
|
||||||
.replaceAll(/_(.*)_/gim, '<span class="text-decoration-underline">$1</span>') // Underline
|
|
||||||
.replaceAll(/\*\*(.*)\*\*/gim, '<span class="fw-bold">$1</span>') // Bold
|
|
||||||
.replaceAll(/\*(.*)\*/gim, '<span class="fst-italic">$1</span>') // Italic
|
|
||||||
.replaceAll(/`(.*)`/gim, '<pre>$1</pre>') // Code
|
|
||||||
.replaceAll(/(https?:\/\/\S+)/g, '<a href="$1" target="_blank">$1</a>') // Links
|
|
||||||
htmlLines.push(htmlLine)
|
|
||||||
}
|
|
||||||
return htmlLines.join('<br>')
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeAllPopovers() {
|
|
||||||
for (let popover of document.querySelectorAll('*[aria-describedby*="popover"]')) {
|
|
||||||
let instance = bootstrap.Popover.getInstance(popover)
|
|
||||||
if (instance)
|
|
||||||
instance.dispose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerSendPrivateMessageContextMenu(message, div, span) {
|
|
||||||
div.addEventListener('contextmenu', (menu_event) => {
|
|
||||||
menu_event.preventDefault()
|
|
||||||
removeAllPopovers()
|
|
||||||
const popover = bootstrap.Popover.getOrCreateInstance(span, {
|
|
||||||
'title': message['author'],
|
|
||||||
'content': `<a id="send-private-message-link-${message['id']}" class="nav-link" href="#" tabindex="0">Envoyer un message privé</a>`,
|
|
||||||
'html': true,
|
|
||||||
})
|
|
||||||
popover.show()
|
|
||||||
|
|
||||||
document.getElementById('send-private-message-link-' + message['id']).addEventListener('click', event => {
|
|
||||||
event.preventDefault()
|
|
||||||
popover.dispose()
|
|
||||||
socket.send(JSON.stringify({
|
|
||||||
'type': 'start_private_chat',
|
|
||||||
'user_id': message['author_id'],
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerMessageContextMenu(message, div, span) {
|
|
||||||
div.addEventListener('contextmenu', (menu_event) => {
|
|
||||||
menu_event.preventDefault()
|
|
||||||
removeAllPopovers()
|
|
||||||
let content = `<a id="send-private-message-link-msg-${message['id']}" class="nav-link" href="#" tabindex="0">Envoyer un message privé</a>`
|
|
||||||
|
|
||||||
let has_right_to_edit = message['author_id'] === USER_ID || IS_ADMIN
|
|
||||||
if (has_right_to_edit) {
|
|
||||||
content += `<hr class="my-1">`
|
|
||||||
content += `<a id="edit-message-${message['id']}" class="nav-link" href="#" tabindex="0">Modifier</a>`
|
|
||||||
content += `<a id="delete-message-${message['id']}" class="nav-link" href="#" tabindex="0">Supprimer</a>`
|
|
||||||
}
|
|
||||||
|
|
||||||
const popover = bootstrap.Popover.getOrCreateInstance(span, {
|
|
||||||
'content': content,
|
|
||||||
'html': true,
|
|
||||||
'placement': 'bottom',
|
|
||||||
})
|
|
||||||
popover.show()
|
|
||||||
|
|
||||||
document.getElementById('send-private-message-link-msg-' + message['id']).addEventListener('click', event => {
|
|
||||||
event.preventDefault()
|
|
||||||
popover.dispose()
|
|
||||||
socket.send(JSON.stringify({
|
|
||||||
'type': 'start_private_chat',
|
|
||||||
'user_id': message['author_id'],
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
if (has_right_to_edit) {
|
|
||||||
document.getElementById('edit-message-' + message['id']).addEventListener('click', event => {
|
|
||||||
event.preventDefault()
|
|
||||||
popover.dispose()
|
|
||||||
let new_message = prompt("Modifier le message", message['content'])
|
|
||||||
if (new_message) {
|
|
||||||
socket.send(JSON.stringify({
|
|
||||||
'type': 'edit_message',
|
|
||||||
'message_id': message['id'],
|
|
||||||
'content': new_message,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
document.getElementById('delete-message-' + message['id']).addEventListener('click', event => {
|
|
||||||
event.preventDefault()
|
|
||||||
popover.dispose()
|
|
||||||
if (confirm(`Supprimer le message ?\n${message['content']}`)) {
|
|
||||||
socket.send(JSON.stringify({
|
|
||||||
'type': 'delete_message',
|
|
||||||
'message_id': message['id'],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleFullscreen() {
|
|
||||||
let chatContainer = document.getElementById('chat-container')
|
|
||||||
if (!chatContainer.getAttribute('data-fullscreen')) {
|
|
||||||
chatContainer.setAttribute('data-fullscreen', 'true')
|
|
||||||
chatContainer.classList.add('position-absolute', 'top-0', 'start-0', 'vh-100', 'z-3')
|
|
||||||
window.history.replaceState({}, null, `?fullscreen=1`)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
chatContainer.removeAttribute('data-fullscreen')
|
|
||||||
chatContainer.classList.remove('position-absolute', 'top-0', 'start-0', 'vh-100', 'z-3')
|
|
||||||
window.history.replaceState({}, null, `?fullscreen=0`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
document.addEventListener('click', removeAllPopovers)
|
|
||||||
|
|
||||||
document.getElementById('sort-by-unread-switch').addEventListener('change', event => {
|
|
||||||
const sortByUnread = event.target.checked
|
|
||||||
for (let channel of Object.values(channels)) {
|
|
||||||
let item = document.getElementById(`tab-channel-${channel['id']}`)
|
|
||||||
if (sortByUnread)
|
|
||||||
item.style.order = `${-channel.unread_messages}`
|
|
||||||
else
|
|
||||||
item.style.removeProperty('order')
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.setItem('chat.sort-by-unread', sortByUnread)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (localStorage.getItem('chat.sort-by-unread') === 'true') {
|
|
||||||
document.getElementById('sort-by-unread-switch').checked = true
|
|
||||||
document.getElementById('sort-by-unread-switch').dispatchEvent(new Event('change'))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process the received data from the server.
|
|
||||||
* @param data The received message
|
|
||||||
*/
|
|
||||||
function processMessage(data) {
|
|
||||||
switch (data['type']) {
|
|
||||||
case 'fetch_channels':
|
|
||||||
setChannels(data['channels'])
|
|
||||||
break
|
|
||||||
case 'send_message':
|
|
||||||
receiveMessage(data)
|
|
||||||
break
|
|
||||||
case 'edit_message':
|
|
||||||
editMessage(data)
|
|
||||||
break
|
|
||||||
case 'delete_message':
|
|
||||||
deleteMessage(data)
|
|
||||||
break
|
|
||||||
case 'fetch_messages':
|
|
||||||
receiveFetchedMessages(data)
|
|
||||||
break
|
|
||||||
case 'mark_read':
|
|
||||||
markMessageAsRead(data)
|
|
||||||
break
|
|
||||||
case 'start_private_chat':
|
|
||||||
startPrivateChat(data)
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
console.log(data)
|
|
||||||
console.error('Unknown message type:', data['type'])
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupSocket(nextDelay = 1000) {
|
|
||||||
// Open a global websocket
|
|
||||||
socket = new WebSocket(
|
|
||||||
(document.location.protocol === 'https:' ? 'wss' : 'ws') + '://' + window.location.host + '/ws/chat/'
|
|
||||||
)
|
|
||||||
let socketOpen = false
|
|
||||||
|
|
||||||
// Listen on websockets and process messages from the server
|
|
||||||
socket.addEventListener('message', e => {
|
|
||||||
// Parse received data as JSON
|
|
||||||
const data = JSON.parse(e.data)
|
|
||||||
|
|
||||||
processMessage(data)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Manage errors
|
|
||||||
socket.addEventListener('close', e => {
|
|
||||||
console.error('Chat socket closed unexpectedly, restarting…')
|
|
||||||
setTimeout(() => setupSocket(socketOpen ? 1000 : 2 * nextDelay), nextDelay)
|
|
||||||
})
|
|
||||||
|
|
||||||
socket.addEventListener('open', e => {
|
|
||||||
socketOpen = true
|
|
||||||
socket.send(JSON.stringify({
|
|
||||||
'type': 'fetch_channels',
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupSwipeOffscreen() {
|
|
||||||
const offcanvas = new bootstrap.Offcanvas(document.getElementById('channelSelector'))
|
|
||||||
|
|
||||||
let lastX = null
|
|
||||||
document.addEventListener('touchstart', (event) => {
|
|
||||||
if (event.touches.length === 1)
|
|
||||||
lastX = event.touches[0].clientX
|
|
||||||
})
|
|
||||||
document.addEventListener('touchmove', (event) => {
|
|
||||||
if (event.touches.length === 1 && lastX !== null) {
|
|
||||||
const diff = event.touches[0].clientX - lastX
|
|
||||||
if (diff > window.innerWidth / 10 && lastX < window.innerWidth / 4) {
|
|
||||||
offcanvas.show()
|
|
||||||
lastX = null
|
|
||||||
}
|
|
||||||
else if (diff < -window.innerWidth / 10) {
|
|
||||||
offcanvas.hide()
|
|
||||||
lastX = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
document.addEventListener('touchend', () => {
|
|
||||||
lastX = null
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupReadTracker() {
|
|
||||||
const scrollableContent = document.getElementById('chat-messages')
|
|
||||||
const messagesList = document.getElementById('message-list')
|
|
||||||
let markReadBuffer = []
|
|
||||||
let markReadTimeout = null
|
|
||||||
|
|
||||||
scrollableContent.addEventListener('scroll', () => {
|
|
||||||
if (scrollableContent.clientHeight - scrollableContent.scrollTop === scrollableContent.scrollHeight
|
|
||||||
&& !document.getElementById('fetch-previous-messages').classList.contains('d-none')) {
|
|
||||||
// If the user is at the top of the chat, fetch previous messages
|
|
||||||
fetchPreviousMessages()}
|
|
||||||
|
|
||||||
markVisibleMessagesAsRead()
|
|
||||||
})
|
|
||||||
|
|
||||||
messagesList.addEventListener('updatemessages', () => markVisibleMessagesAsRead())
|
|
||||||
|
|
||||||
function markVisibleMessagesAsRead() {
|
|
||||||
let viewport = scrollableContent.getBoundingClientRect()
|
|
||||||
|
|
||||||
for (let item of messagesList.querySelectorAll('.message')) {
|
|
||||||
let message = messages[selected_channel_id].get(parseInt(item.getAttribute('data-message-id')))
|
|
||||||
if (!message.read) {
|
|
||||||
let rect = item.getBoundingClientRect()
|
|
||||||
if (rect.top >= viewport.top && rect.bottom <= viewport.bottom) {
|
|
||||||
message.read = true
|
|
||||||
markReadBuffer.push(message['id'])
|
|
||||||
if (markReadTimeout)
|
|
||||||
clearTimeout(markReadTimeout)
|
|
||||||
markReadTimeout = setTimeout(() => {
|
|
||||||
socket.send(JSON.stringify({
|
|
||||||
'type': 'mark_read',
|
|
||||||
'message_ids': markReadBuffer,
|
|
||||||
}))
|
|
||||||
markReadBuffer = []
|
|
||||||
markReadTimeout = null
|
|
||||||
}, 3000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
markVisibleMessagesAsRead()
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupPWAPrompt() {
|
|
||||||
let deferredPrompt = null
|
|
||||||
|
|
||||||
window.addEventListener("beforeinstallprompt", (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
deferredPrompt = e
|
|
||||||
let btn = document.getElementById('install-app-home-screen')
|
|
||||||
let alert = document.getElementById('alert-download-chat-app')
|
|
||||||
btn.classList.remove('d-none')
|
|
||||||
alert.classList.remove('d-none')
|
|
||||||
btn.onclick = function () {
|
|
||||||
deferredPrompt.prompt()
|
|
||||||
deferredPrompt.userChoice.then((choiceResult) => {
|
|
||||||
if (choiceResult.outcome === 'accepted') {
|
|
||||||
deferredPrompt = null
|
|
||||||
btn.classList.add('d-none')
|
|
||||||
alert.classList.add('d-none')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
setupSocket()
|
|
||||||
setupSwipeOffscreen()
|
|
||||||
setupReadTracker()
|
|
||||||
setupPWAPrompt()
|
|
||||||
})
|
|
@ -4,19 +4,19 @@
|
|||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "tfjm-square.svg",
|
"src": "/static/tfjm/img/tfjm-square.svg",
|
||||||
"sizes": "any",
|
"sizes": "any",
|
||||||
"type": "image/svg+xml",
|
"type": "image/svg+xml",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "tfjm-512.png",
|
"src": "/static/tfjm/img/tfjm-512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "tfjm-192.png",
|
"src": "/static/tfjm/img/tfjm-192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
912
chat/static/tfjm/js/chat.js
Normal file
@ -0,0 +1,912 @@
|
|||||||
|
(async () => {
|
||||||
|
// Vérification de la permission pour envoyer des notifications
|
||||||
|
// C'est utile pour prévenir les utilisateur⋅rices de l'arrivée de nouveaux messages les mentionnant
|
||||||
|
await Notification.requestPermission()
|
||||||
|
})()
|
||||||
|
|
||||||
|
const MAX_MESSAGES = 50 // Nombre maximal de messages à charger à la fois
|
||||||
|
|
||||||
|
const channel_categories = ['general', 'tournament', 'team', 'private'] // Liste des catégories de canaux
|
||||||
|
let channels = {} // Liste des canaux disponibles
|
||||||
|
let messages = {} // Liste des messages reçus par canal
|
||||||
|
let selected_channel_id = null // Canal courant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Affiche une nouvelle notification avec le titre donné et le contenu donné.
|
||||||
|
* @param title Le titre de la notification
|
||||||
|
* @param body Le contenu de la notification
|
||||||
|
* @param timeout La durée (en millisecondes) après laquelle la notification se ferme automatiquement.
|
||||||
|
* Définir à 0 (défaut) pour la rendre infinie.
|
||||||
|
* @return Notification
|
||||||
|
*/
|
||||||
|
function showNotification(title, body, timeout = 0) {
|
||||||
|
Notification.requestPermission().then((status) => {
|
||||||
|
if (status === 'granted') {
|
||||||
|
// On envoie la notification que si la permission a été donnée
|
||||||
|
let notif = new Notification(title, {'body': body, 'icon': "/static/tfjm-192.png"})
|
||||||
|
if (timeout > 0)
|
||||||
|
setTimeout(() => notif.close(), timeout)
|
||||||
|
return notif
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sélectionne le canal courant à afficher sur l'interface de chat.
|
||||||
|
* Va alors définir le canal courant et mettre à jour les messages affichés.
|
||||||
|
* @param channel_id L'identifiant du canal à afficher.
|
||||||
|
*/
|
||||||
|
function selectChannel(channel_id) {
|
||||||
|
let channel = channels[channel_id]
|
||||||
|
if (!channel) {
|
||||||
|
// Le canal n'existe pas
|
||||||
|
console.error('Channel not found:', channel_id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selected_channel_id = channel_id
|
||||||
|
// On stocke dans le stockage local l'identifiant du canal
|
||||||
|
// pour pouvoir rouvrir le dernier canal ouvert dans le futur
|
||||||
|
localStorage.setItem('chat.last-channel-id', channel_id)
|
||||||
|
|
||||||
|
// Définition du titre du contenu
|
||||||
|
let channelTitle = document.getElementById('channel-title')
|
||||||
|
channelTitle.innerText = channel.name
|
||||||
|
|
||||||
|
// Si on a pas le droit d'écrire dans le canal, on désactive l'input de message
|
||||||
|
// On l'active sinon
|
||||||
|
let messageInput = document.getElementById('input-message')
|
||||||
|
messageInput.disabled = !channel.write_access
|
||||||
|
|
||||||
|
// On redessine la liste des messages à partir des messages stockés
|
||||||
|
redrawMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On récupère le message écrit par l'utilisateur⋅rice dans le champ de texte idoine,
|
||||||
|
* et on le transmet ensuite au serveur.
|
||||||
|
* Il ne s'affiche pas instantanément sur l'interface,
|
||||||
|
* mais seulement une fois que le serveur aura validé et retransmis le message.
|
||||||
|
*/
|
||||||
|
function sendMessage() {
|
||||||
|
// Récupération du message à envoyer
|
||||||
|
let messageInput = document.getElementById('input-message')
|
||||||
|
let message = messageInput.value
|
||||||
|
// On efface le champ de texte après avoir récupéré le message
|
||||||
|
messageInput.value = ''
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Envoi du message au serveur
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
'type': 'send_message',
|
||||||
|
'channel_id': selected_channel_id,
|
||||||
|
'content': message,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour la liste des canaux disponibles, à partir de la liste récupérée du serveur.
|
||||||
|
* @param new_channels La liste des canaux à afficher.
|
||||||
|
* Chaque canal doit être un objet avec les clés `id`, `name`, `category`
|
||||||
|
* `read_access`, `write_access` et `unread_messages`, correspondant à l'identifiant du canal,
|
||||||
|
* son nom, sa catégorie, la permission de lecture, d'écriture et le nombre de messages non lus.
|
||||||
|
*/
|
||||||
|
function setChannels(new_channels) {
|
||||||
|
channels = {}
|
||||||
|
for (let category of channel_categories) {
|
||||||
|
// On commence par vider la liste des canaux sélectionnables
|
||||||
|
let categoryList = document.getElementById(`nav-${category}-channels-tab`)
|
||||||
|
categoryList.innerHTML = ''
|
||||||
|
categoryList.parentElement.classList.add('d-none')
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let channel of new_channels)
|
||||||
|
// On ajoute chaque canal à la liste des canaux
|
||||||
|
addChannel(channel)
|
||||||
|
|
||||||
|
if (new_channels && (!selected_channel_id || !channels[selected_channel_id])) {
|
||||||
|
// Si aucun canal n'a encore été sélectionné et qu'il y a des canaux disponibles,
|
||||||
|
// on commence par vérifier si on a stocké un canal précédemment sélectionné et on l'affiche si c'est le cas
|
||||||
|
// Sinon, on affiche le premier canal disponible
|
||||||
|
let last_channel_id = parseInt(localStorage.getItem('chat.last-channel-id'))
|
||||||
|
if (last_channel_id && channels[last_channel_id])
|
||||||
|
selectChannel(last_channel_id)
|
||||||
|
else
|
||||||
|
selectChannel(Object.keys(channels)[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajoute un canal à la liste des canaux disponibles.
|
||||||
|
* @param channel Le canal à ajouter. Doit être un objet avec les clés `id`, `name`, `category`,
|
||||||
|
* `read_access`, `write_access` et `unread_messages`, correspondant à l'identifiant du canal,
|
||||||
|
* son nom, sa catégorie, la permission de lecture, d'écriture et le nombre de messages non lus.
|
||||||
|
*/
|
||||||
|
async function addChannel(channel) {
|
||||||
|
channels[channel.id] = channel
|
||||||
|
if (!messages[channel.id])
|
||||||
|
messages[channel.id] = new Map()
|
||||||
|
|
||||||
|
// On récupère la liste des canaux de la catégorie concernée
|
||||||
|
let categoryList = document.getElementById(`nav-${channel.category}-channels-tab`)
|
||||||
|
// On la rend visible si elle ne l'était pas déjà
|
||||||
|
categoryList.parentElement.classList.remove('d-none')
|
||||||
|
|
||||||
|
// On crée un nouvel élément de liste pour la catégorie concernant le canal
|
||||||
|
let navItem = document.createElement('li')
|
||||||
|
navItem.classList.add('list-group-item', 'tab-channel')
|
||||||
|
navItem.id = `tab-channel-${channel.id}`
|
||||||
|
navItem.setAttribute('data-bs-dismiss', 'offcanvas')
|
||||||
|
navItem.onclick = () => selectChannel(channel.id)
|
||||||
|
categoryList.appendChild(navItem)
|
||||||
|
|
||||||
|
// L'élément est cliquable afin de sélectionner le canal
|
||||||
|
let channelButton = document.createElement('button')
|
||||||
|
channelButton.classList.add('nav-link')
|
||||||
|
channelButton.type = 'button'
|
||||||
|
channelButton.innerText = channel.name
|
||||||
|
navItem.appendChild(channelButton)
|
||||||
|
|
||||||
|
// Affichage du nombre de messages non lus
|
||||||
|
let unreadBadge = document.createElement('span')
|
||||||
|
unreadBadge.classList.add('badge', 'rounded-pill', 'text-bg-light', 'ms-2')
|
||||||
|
unreadBadge.id = `unread-messages-${channel.id}`
|
||||||
|
unreadBadge.innerText = channel.unread_messages || 0
|
||||||
|
if (!channel.unread_messages)
|
||||||
|
unreadBadge.classList.add('d-none')
|
||||||
|
channelButton.appendChild(unreadBadge)
|
||||||
|
|
||||||
|
// Si on veut trier les canaux par nombre décroissant de messages non lus,
|
||||||
|
// on définit l'ordre de l'élément (propriété CSS) en fonction du nombre de messages non lus
|
||||||
|
if (document.getElementById('sort-by-unread-switch').checked)
|
||||||
|
navItem.style.order = `${-channel.unread_messages}`
|
||||||
|
|
||||||
|
// On demande enfin à récupérer les derniers messages du canal en question afin de les stocker / afficher
|
||||||
|
fetchMessages(channel.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un⋅e utilisateur⋅rice a envoyé un message, qui a été retransmis par le serveur.
|
||||||
|
* On le stocke alors et on l'affiche sur l'interface si nécessaire.
|
||||||
|
* On affiche également une notification si le message contient une mention pour tout le monde.
|
||||||
|
* @param message Le message qui a été transmis. Doit être un objet avec
|
||||||
|
* les clés `id`, `channel_id`, `author`, `author_id`, `content` et `timestamp`,
|
||||||
|
* correspondant à l'identifiant du message, du canal, le nom de l'auteur⋅rice et l'heure d'envoi.
|
||||||
|
*/
|
||||||
|
function receiveMessage(message) {
|
||||||
|
// On vérifie si la barre de défilement est tout en bas
|
||||||
|
let scrollableContent = document.getElementById('chat-messages')
|
||||||
|
let isScrolledToBottom = scrollableContent.scrollHeight - scrollableContent.clientHeight <= scrollableContent.scrollTop + 1
|
||||||
|
|
||||||
|
// On stocke le message dans la liste des messages du canal concerné
|
||||||
|
// et on redessine les messages affichés si on est dans le canal concerné
|
||||||
|
messages[message.channel_id].set(message.id, message)
|
||||||
|
if (message.channel_id === selected_channel_id)
|
||||||
|
redrawMessages()
|
||||||
|
|
||||||
|
// Si la barre de défilement était tout en bas, alors on la remet tout en bas après avoir redessiné les messages
|
||||||
|
if (isScrolledToBottom)
|
||||||
|
scrollableContent.scrollTop = scrollableContent.scrollHeight - scrollableContent.clientHeight
|
||||||
|
|
||||||
|
// On ajoute un à la liste des messages non lus du canal (il pourra être lu plus tard)
|
||||||
|
updateUnreadBadge(message.channel_id, channels[message.channel_id].unread_messages + 1)
|
||||||
|
|
||||||
|
// Si le message contient une mention à @everyone, alors on envoie une notification (si la permission est donnée)
|
||||||
|
if (message.content.includes("@everyone"))
|
||||||
|
showNotification(channels[message.channel_id].name, `${message.author} : ${message.content}`)
|
||||||
|
|
||||||
|
// On envoie un événement personnalisé pour indiquer que les messages ont été mis à jour
|
||||||
|
// Permettant entre autres de marquer le message comme lu si c'est le cas
|
||||||
|
document.getElementById('message-list').dispatchEvent(new CustomEvent('updatemessages'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un message a été modifié, et le serveur nous a transmis les nouvelles informations.
|
||||||
|
* @param data Le nouveau message qui a été modifié.
|
||||||
|
*/
|
||||||
|
function editMessage(data) {
|
||||||
|
// On met à jour le contenu du message
|
||||||
|
messages[data.channel_id].get(data.id).content = data.content
|
||||||
|
// Si le message appartient au canal courant, on redessine les messages
|
||||||
|
if (data.channel_id === selected_channel_id)
|
||||||
|
redrawMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un message a été supprimé, et le serveur nous a transmis les informations.
|
||||||
|
* @param data Le message qui a été supprimé.
|
||||||
|
*/
|
||||||
|
function deleteMessage(data) {
|
||||||
|
// On supprime le message de la liste des messages du canal concerné
|
||||||
|
messages[data.channel_id].delete(data.id)
|
||||||
|
// Si le message appartient au canal courant, on redessine les messages
|
||||||
|
if (data.channel_id === selected_channel_id)
|
||||||
|
redrawMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demande au serveur de récupérer les messages du canal donné.
|
||||||
|
* @param channel_id L'identifiant du canal dont on veut récupérer les messages.
|
||||||
|
* @param offset Le décalage à partir duquel on veut récupérer les messages,
|
||||||
|
* correspond au nombre de messages en mémoire.
|
||||||
|
* @param limit Le nombre maximal de messages à récupérer.
|
||||||
|
*/
|
||||||
|
function fetchMessages(channel_id, offset = 0, limit = MAX_MESSAGES) {
|
||||||
|
// Envoi de la requête au serveur avec les différents paramètres
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
'type': 'fetch_messages',
|
||||||
|
'channel_id': channel_id,
|
||||||
|
'offset': offset,
|
||||||
|
'limit': limit,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demande au serveur de récupérer les messages précédents du canal courant.
|
||||||
|
* Par défaut, on récupère `MAX_MESSAGES` messages avant tous ceux qui ont été reçus sur ce canal.
|
||||||
|
*/
|
||||||
|
function fetchPreviousMessages() {
|
||||||
|
let channel_id = selected_channel_id
|
||||||
|
let offset = messages[channel_id].size
|
||||||
|
fetchMessages(channel_id, offset, MAX_MESSAGES)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L'utilisateur⋅rice a demandé à récupérer une partie des messages d'un canal.
|
||||||
|
* Cette fonction est alors appelée lors du retour du serveur.
|
||||||
|
* @param data Dictionnaire contenant l'identifiant du canal concerné, et la liste des messages récupérés.
|
||||||
|
*/
|
||||||
|
function receiveFetchedMessages(data) {
|
||||||
|
// Récupération du canal concerné ainsi que des nouveaux messages à mémoriser
|
||||||
|
let channel_id = data.channel_id
|
||||||
|
let new_messages = data.messages
|
||||||
|
|
||||||
|
if (!messages[channel_id])
|
||||||
|
messages[channel_id] = new Map()
|
||||||
|
|
||||||
|
// Ajout des nouveaux messages à la liste des messages du canal
|
||||||
|
for (let message of new_messages)
|
||||||
|
messages[channel_id].set(message.id, message)
|
||||||
|
|
||||||
|
// On trie les messages reçus par date et heure d'envoi
|
||||||
|
messages[channel_id] = new Map([...messages[channel_id].values()]
|
||||||
|
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp))
|
||||||
|
.map(message => [message.id, message]))
|
||||||
|
|
||||||
|
// Enfin, si le canal concerné est le canal courant, on redessine les messages
|
||||||
|
if (channel_id === selected_channel_id)
|
||||||
|
redrawMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L'utilisateur⋅rice a indiqué au serveur que des messages ont été lus.
|
||||||
|
* Cette fonction est appelée en retour, pour confirmer, et stocke quels messages ont été lus
|
||||||
|
* et combien de messages sont non lus par canal.
|
||||||
|
* @param data Dictionnaire contenant une clé `read`, contenant la liste des identifiants des messages
|
||||||
|
* marqués comme lus avec leur canal respectif, et une clé `unread_messages` contenant le nombre
|
||||||
|
* de messages non lus par canal.
|
||||||
|
*/
|
||||||
|
function markMessageAsRead(data) {
|
||||||
|
for (let message of data.messages) {
|
||||||
|
// Récupération du message à marquer comme lu
|
||||||
|
let stored_message = messages[message.channel_id].get(message.id)
|
||||||
|
// Marquage du message comme lu
|
||||||
|
if (stored_message)
|
||||||
|
stored_message.read = true
|
||||||
|
}
|
||||||
|
// Actualisation des badges contenant le nombre de messages non lus par canal
|
||||||
|
updateUnreadBadges(data.unread_messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mise à jour des badges contenant le nombre de messages non lus par canal.
|
||||||
|
* @param unreadMessages Dictionnaire des nombres de messages non lus par canal (identifiés par leurs identifiants)
|
||||||
|
*/
|
||||||
|
function updateUnreadBadges(unreadMessages) {
|
||||||
|
for (let channel of Object.values(channels)) {
|
||||||
|
// Récupération du nombre de messages non lus pour le canal en question et mise à jour du badge pour ce canal
|
||||||
|
updateUnreadBadge(channel.id, unreadMessages[channel.id] || 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mise à jour du badge du nombre de messages non lus d'un canal.
|
||||||
|
* Actualise sa visibilité.
|
||||||
|
* @param channel_id Identifiant du canal concerné.
|
||||||
|
* @param unreadMessagesCount Nombre de messages non lus du canal.
|
||||||
|
*/
|
||||||
|
function updateUnreadBadge(channel_id, unreadMessagesCount = 0) {
|
||||||
|
// Vaut true si on veut trier les canaux par nombre de messages non lus ou non
|
||||||
|
const sortByUnread = document.getElementById('sort-by-unread-switch').checked
|
||||||
|
|
||||||
|
// Récupération du canal concerné
|
||||||
|
let channel = channels[channel_id]
|
||||||
|
|
||||||
|
// Récupération du nombre de messages non lus pour le canal en question, que l'on stocke
|
||||||
|
channel.unread_messages = unreadMessagesCount
|
||||||
|
|
||||||
|
// On met à jour le badge du canal contenant le nombre de messages non lus
|
||||||
|
let unreadBadge = document.getElementById(`unread-messages-${channel.id}`)
|
||||||
|
unreadBadge.innerText = unreadMessagesCount.toString()
|
||||||
|
|
||||||
|
// Le badge est visible si et seulement si il y a au moins un message non lu
|
||||||
|
if (unreadMessagesCount)
|
||||||
|
unreadBadge.classList.remove('d-none')
|
||||||
|
else
|
||||||
|
unreadBadge.classList.add('d-none')
|
||||||
|
|
||||||
|
// S'il faut trier les canaux par nombre de messages non lus, on ajoute la propriété CSS correspondante
|
||||||
|
if (sortByUnread)
|
||||||
|
document.getElementById(`tab-channel-${channel.id}`).style.order = `${-unreadMessagesCount}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La création d'un canal privé entre deux personnes a été demandée.
|
||||||
|
* Cette fonction est appelée en réponse du serveur.
|
||||||
|
* Le canal est ajouté à la liste s'il est nouveau, et automatiquement sélectionné.
|
||||||
|
* @param data Dictionnaire contenant une unique clé `channel` correspondant aux informations du canal privé.
|
||||||
|
*/
|
||||||
|
function startPrivateChat(data) {
|
||||||
|
// Récupération du canal
|
||||||
|
let channel = data.channel
|
||||||
|
if (!channel) {
|
||||||
|
console.error('Private chat not found:', data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!channels[channel.id]) {
|
||||||
|
// Si le canal n'est pas récupéré, on l'ajoute à la liste
|
||||||
|
channels[channel.id] = channel
|
||||||
|
messages[channel.id] = new Map()
|
||||||
|
addChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sélection immédiate du canal privé
|
||||||
|
selectChannel(channel.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour le composant correspondant à la liste des messages du canal sélectionné.
|
||||||
|
* Le conteneur est d'abord réinitialisé, puis les messages sont affichés un à un à partir de ceux stockés.
|
||||||
|
*/
|
||||||
|
function redrawMessages() {
|
||||||
|
// Récupération du composant HTML <ul> correspondant à la liste des messages affichés
|
||||||
|
let messageList = document.getElementById('message-list')
|
||||||
|
// On commence par le vider
|
||||||
|
messageList.innerHTML = ''
|
||||||
|
|
||||||
|
let lastMessage = null
|
||||||
|
let lastContentDiv = null
|
||||||
|
|
||||||
|
for (let message of messages[selected_channel_id].values()) {
|
||||||
|
if (lastMessage && lastMessage.author === message.author) {
|
||||||
|
// Si le message est écrit par læ même auteur⋅rice que le message précédent,
|
||||||
|
// alors on les groupe ensemble
|
||||||
|
let lastTimestamp = new Date(lastMessage.timestamp)
|
||||||
|
let newTimestamp = new Date(message.timestamp)
|
||||||
|
if ((newTimestamp - lastTimestamp) / 1000 < 60 * 10) {
|
||||||
|
// Les messages sont groupés uniquement s'il y a une différence maximale de 10 minutes
|
||||||
|
// entre le premier message du groupe et celui en étude
|
||||||
|
// On ajoute alors le contenu du message en cours dans le dernier div de message
|
||||||
|
let messageContentDiv = document.createElement('div')
|
||||||
|
messageContentDiv.classList.add('message')
|
||||||
|
messageContentDiv.setAttribute('data-message-id', message.id)
|
||||||
|
lastContentDiv.appendChild(messageContentDiv)
|
||||||
|
let messageContentSpan = document.createElement('span')
|
||||||
|
messageContentSpan.innerHTML = markdownToHTML(message.content)
|
||||||
|
messageContentDiv.appendChild(messageContentSpan)
|
||||||
|
|
||||||
|
// Enregistrement du menu contextuel pour le message permettant la modification, la suppression
|
||||||
|
// et l'envoi de messages privés
|
||||||
|
registerMessageContextMenu(message, messageContentDiv, messageContentSpan)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Création de l'élément <li> pour le bloc de messages
|
||||||
|
let messageElement = document.createElement('li')
|
||||||
|
messageElement.classList.add('list-group-item')
|
||||||
|
messageList.appendChild(messageElement)
|
||||||
|
|
||||||
|
// Ajout d'un div contenant le nom de l'auteur⋅rice du message ainsi que la date et heure d'envoi
|
||||||
|
let authorDiv = document.createElement('div')
|
||||||
|
messageElement.appendChild(authorDiv)
|
||||||
|
|
||||||
|
// Ajout du nom de l'auteur⋅rice du message
|
||||||
|
let authorSpan = document.createElement('span')
|
||||||
|
authorSpan.classList.add('text-muted', 'fw-bold')
|
||||||
|
authorSpan.innerText = message.author
|
||||||
|
authorDiv.appendChild(authorSpan)
|
||||||
|
|
||||||
|
// Ajout de la date du message
|
||||||
|
let dateSpan = document.createElement('span')
|
||||||
|
dateSpan.classList.add('text-muted', 'float-end')
|
||||||
|
dateSpan.innerText = new Date(message.timestamp).toLocaleString()
|
||||||
|
authorDiv.appendChild(dateSpan)
|
||||||
|
|
||||||
|
// Enregistrement du menu contextuel pour le message permettant l'envoi de messages privés à l'auteur⋅rice
|
||||||
|
registerSendPrivateMessageContextMenu(message, authorDiv, authorSpan)
|
||||||
|
|
||||||
|
let contentDiv = document.createElement('div')
|
||||||
|
messageElement.appendChild(contentDiv)
|
||||||
|
|
||||||
|
// Ajout du contenu du message
|
||||||
|
// Le contenu est mis dans un span lui-même inclus dans un div,
|
||||||
|
let messageContentDiv = document.createElement('div')
|
||||||
|
messageContentDiv.classList.add('message')
|
||||||
|
messageContentDiv.setAttribute('data-message-id', message.id)
|
||||||
|
contentDiv.appendChild(messageContentDiv)
|
||||||
|
let messageContentSpan = document.createElement('span')
|
||||||
|
messageContentSpan.innerHTML = markdownToHTML(message.content)
|
||||||
|
messageContentDiv.appendChild(messageContentSpan)
|
||||||
|
|
||||||
|
// Enregistrement du menu contextuel pour le message permettant la modification, la suppression
|
||||||
|
// et l'envoi de messages privés
|
||||||
|
registerMessageContextMenu(message, messageContentDiv, messageContentSpan)
|
||||||
|
|
||||||
|
lastMessage = message
|
||||||
|
lastContentDiv = contentDiv
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le bouton « Afficher les messages précédents » est affiché si et seulement si
|
||||||
|
// il y a des messages à récupérer (c'est-à-dire si le nombre de messages récupérés est un multiple de MAX_MESSAGES)
|
||||||
|
let fetchMoreButton = document.getElementById('fetch-previous-messages')
|
||||||
|
if (!messages[selected_channel_id].size || messages[selected_channel_id].size % MAX_MESSAGES !== 0)
|
||||||
|
fetchMoreButton.classList.add('d-none')
|
||||||
|
else
|
||||||
|
fetchMoreButton.classList.remove('d-none')
|
||||||
|
|
||||||
|
// On envoie un événement personnalisé pour indiquer que les messages ont été mis à jour
|
||||||
|
// Permettant entre autres de marquer les messages visibles comme lus si c'est le cas
|
||||||
|
messageList.dispatchEvent(new CustomEvent('updatemessages'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit un texte écrit en Markdown en HTML.
|
||||||
|
* Les balises Markdown suivantes sont supportées :
|
||||||
|
* - Souligné : `_texte_`
|
||||||
|
* - Gras : `**texte**`
|
||||||
|
* - Italique : `*texte*`
|
||||||
|
* - Code : `` `texte` ``
|
||||||
|
* - Les liens sont automatiquement convertis
|
||||||
|
* - Les esperluettes, guillemets et chevrons sont échappés.
|
||||||
|
* @param text Le texte écrit en Markdown.
|
||||||
|
* @return {string} Le texte converti en HTML.
|
||||||
|
*/
|
||||||
|
function markdownToHTML(text) {
|
||||||
|
// On échape certains caractères spéciaux (esperluettes, chevrons, guillemets)
|
||||||
|
let safeText = text.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
let lines = safeText.split('\n')
|
||||||
|
let htmlLines = []
|
||||||
|
for (let line of lines) {
|
||||||
|
// Pour chaque ligne, on remplace le Markdown par un équivalent HTML (pour ce qui est supporté)
|
||||||
|
let htmlLine = line
|
||||||
|
.replaceAll(/_(.*)_/gim, '<span class="text-decoration-underline">$1</span>') // Souligné
|
||||||
|
.replaceAll(/\*\*(.*)\*\*/gim, '<span class="fw-bold">$1</span>') // Gras
|
||||||
|
.replaceAll(/\*(.*)\*/gim, '<span class="fst-italic">$1</span>') // Italique
|
||||||
|
.replaceAll(/`(.*)`/gim, '<pre>$1</pre>') // Code
|
||||||
|
.replaceAll(/(https?:\/\/\S+)/g, '<a href="$1" target="_blank">$1</a>') // Liens
|
||||||
|
htmlLines.push(htmlLine)
|
||||||
|
}
|
||||||
|
// On joint enfin toutes les lignes par des balises de saut de ligne
|
||||||
|
return htmlLines.join('<br>')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ferme toutes les popovers ouvertes.
|
||||||
|
*/
|
||||||
|
function removeAllPopovers() {
|
||||||
|
for (let popover of document.querySelectorAll('*[aria-describedby*="popover"]')) {
|
||||||
|
let instance = bootstrap.Popover.getInstance(popover)
|
||||||
|
if (instance)
|
||||||
|
instance.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enregistrement du menu contextuel pour un⋅e auteur⋅rice de message,
|
||||||
|
* donnant la possibilité d'envoyer un message privé.
|
||||||
|
* @param message Le message écrit par l'auteur⋅rice du bloc en question.
|
||||||
|
* @param div Le bloc contenant le nom de l'auteur⋅rice et de la date d'envoi du message.
|
||||||
|
* Un clic droit sur lui affichera le menu contextuel.
|
||||||
|
* @param span Le span contenant le nom de l'auteur⋅rice.
|
||||||
|
* Il désignera l'emplacement d'affichage du popover.
|
||||||
|
*/
|
||||||
|
function registerSendPrivateMessageContextMenu(message, div, span) {
|
||||||
|
// Enregistrement de l'écouteur d'événement pour le clic droit
|
||||||
|
div.addEventListener('contextmenu', (menu_event) => {
|
||||||
|
// On empêche le menu traditionnel de s'afficher
|
||||||
|
menu_event.preventDefault()
|
||||||
|
// On retire toutes les popovers déjà ouvertes
|
||||||
|
removeAllPopovers()
|
||||||
|
|
||||||
|
// On crée le popover contenant le lien pour envoyer un message privé, puis on l'affiche
|
||||||
|
const popover = bootstrap.Popover.getOrCreateInstance(span, {
|
||||||
|
'title': message.author,
|
||||||
|
'content': `<a id="send-private-message-link-${message.id}" class="nav-link" href="#" tabindex="0">Envoyer un message privé</a>`,
|
||||||
|
'html': true,
|
||||||
|
})
|
||||||
|
popover.show()
|
||||||
|
|
||||||
|
// Lorsqu'on clique sur le lien, on ferme le popover
|
||||||
|
// et on demande à ouvrir le canal privé avec l'auteur⋅rice du message
|
||||||
|
document.getElementById('send-private-message-link-' + message.id).addEventListener('click', event => {
|
||||||
|
event.preventDefault()
|
||||||
|
popover.dispose()
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
'type': 'start_private_chat',
|
||||||
|
'user_id': message.author_id,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enregistrement du menu contextuel pour un message,
|
||||||
|
* donnant la possibilité de modifier ou de supprimer le message, ou d'envoyer un message privé à l'auteur⋅rice.
|
||||||
|
* @param message Le message en question.
|
||||||
|
* @param div Le bloc contenant le contenu du message.
|
||||||
|
* Un clic droit sur lui affichera le menu contextuel.
|
||||||
|
* @param span Le span contenant le contenu du message.
|
||||||
|
* Il désignera l'emplacement d'affichage du popover.
|
||||||
|
*/
|
||||||
|
function registerMessageContextMenu(message, div, span) {
|
||||||
|
// Enregistrement de l'écouteur d'événement pour le clic droit
|
||||||
|
div.addEventListener('contextmenu', (menu_event) => {
|
||||||
|
// On empêche le menu traditionnel de s'afficher
|
||||||
|
menu_event.preventDefault()
|
||||||
|
// On retire toutes les popovers déjà ouvertes
|
||||||
|
removeAllPopovers()
|
||||||
|
|
||||||
|
// On crée le popover contenant les liens pour modifier, supprimer le message ou envoyer un message privé.
|
||||||
|
let content = `<a id="send-private-message-link-msg-${message.id}" class="nav-link" href="#" tabindex="0">Envoyer un message privé</a>`
|
||||||
|
|
||||||
|
// On ne peut modifier ou supprimer un message que si on est l'auteur⋅rice ou que l'on est administrateur⋅rice.
|
||||||
|
let has_right_to_edit = message.author_id === USER_ID || IS_ADMIN
|
||||||
|
if (has_right_to_edit) {
|
||||||
|
content += `<hr class="my-1">`
|
||||||
|
content += `<a id="edit-message-${message.id}" class="nav-link" href="#" tabindex="0">Modifier</a>`
|
||||||
|
content += `<a id="delete-message-${message.id}" class="nav-link" href="#" tabindex="0">Supprimer</a>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const popover = bootstrap.Popover.getOrCreateInstance(span, {
|
||||||
|
'content': content,
|
||||||
|
'html': true,
|
||||||
|
'placement': 'bottom',
|
||||||
|
})
|
||||||
|
popover.show()
|
||||||
|
|
||||||
|
// Lorsqu'on clique sur le lien d'envoi de message privé, on ferme le popover
|
||||||
|
// et on demande à ouvrir le canal privé avec l'auteur⋅rice du message
|
||||||
|
document.getElementById('send-private-message-link-msg-' + message.id).addEventListener('click', event => {
|
||||||
|
event.preventDefault()
|
||||||
|
popover.dispose()
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
'type': 'start_private_chat',
|
||||||
|
'user_id': message.author_id,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
if (has_right_to_edit) {
|
||||||
|
// Si on a le droit de modifier ou supprimer le message, on enregistre les écouteurs d'événements
|
||||||
|
// Le bouton de modification de message ouvre une boîte de dialogue pour modifier le message
|
||||||
|
document.getElementById('edit-message-' + message.id).addEventListener('click', event => {
|
||||||
|
event.preventDefault()
|
||||||
|
// Fermeture du popover
|
||||||
|
popover.dispose()
|
||||||
|
|
||||||
|
// Ouverture d'une boîte de diaologue afin de modifier le message
|
||||||
|
let new_message = prompt("Modifier le message", message.content)
|
||||||
|
if (new_message) {
|
||||||
|
// Si le message a été modifié, on envoie la demande de modification au serveur
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
'type': 'edit_message',
|
||||||
|
'message_id': message.id,
|
||||||
|
'content': new_message,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Le bouton de suppression de message demande une confirmation avant de supprimer le message
|
||||||
|
document.getElementById('delete-message-' + message.id).addEventListener('click', event => {
|
||||||
|
event.preventDefault()
|
||||||
|
// Fermeture du popover
|
||||||
|
popover.dispose()
|
||||||
|
|
||||||
|
// Demande de confirmation avant de supprimer le message
|
||||||
|
if (confirm(`Supprimer le message ?\n${message.content}`)) {
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
'type': 'delete_message',
|
||||||
|
'message_id': message.id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passe le chat en version plein écran, ou l'inverse si c'est déjà le cas.
|
||||||
|
*/
|
||||||
|
function toggleFullscreen() {
|
||||||
|
let chatContainer = document.getElementById('chat-container')
|
||||||
|
if (!chatContainer.getAttribute('data-fullscreen')) {
|
||||||
|
// Le chat n'est pas en plein écran.
|
||||||
|
// On le passe en plein écran en le plaçant en avant plan en position absolue
|
||||||
|
// prenant toute la hauteur et toute la largeur
|
||||||
|
chatContainer.setAttribute('data-fullscreen', 'true')
|
||||||
|
chatContainer.classList.add('position-absolute', 'top-0', 'start-0', 'vh-100', 'z-3')
|
||||||
|
window.history.replaceState({}, null, `?fullscreen=1`)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Le chat est déjà en plein écran. On retire les tags CSS correspondant au plein écran.
|
||||||
|
chatContainer.removeAttribute('data-fullscreen')
|
||||||
|
chatContainer.classList.remove('position-absolute', 'top-0', 'start-0', 'vh-100', 'z-3')
|
||||||
|
window.history.replaceState({}, null, `?fullscreen=0`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Lorsqu'on effectue le moindre clic, on ferme les éventuelles popovers ouvertes
|
||||||
|
document.addEventListener('click', removeAllPopovers)
|
||||||
|
|
||||||
|
// Lorsqu'on change entre le tri des canaux par ordre alphabétique et par nombre de messages non lus,
|
||||||
|
// on met à jour l'ordre des canaux
|
||||||
|
document.getElementById('sort-by-unread-switch').addEventListener('change', event => {
|
||||||
|
const sortByUnread = event.target.checked
|
||||||
|
for (let channel of Object.values(channels)) {
|
||||||
|
let item = document.getElementById(`tab-channel-${channel.id}`)
|
||||||
|
if (sortByUnread)
|
||||||
|
// Si on trie par nombre de messages non lus,
|
||||||
|
// on définit l'ordre de l'élément en fonction du nombre de messages non lus
|
||||||
|
// à l'aide d'une propriété CSS
|
||||||
|
item.style.order = `${-channel.unread_messages}`
|
||||||
|
else
|
||||||
|
// Sinon, les canaux sont de base triés par ordre alphabétique
|
||||||
|
item.style.removeProperty('order')
|
||||||
|
}
|
||||||
|
|
||||||
|
// On stocke le mode de tri dans le stockage local
|
||||||
|
localStorage.setItem('chat.sort-by-unread', sortByUnread)
|
||||||
|
})
|
||||||
|
|
||||||
|
// On récupère le mode de tri des canaux depuis le stockage local
|
||||||
|
if (localStorage.getItem('chat.sort-by-unread') === 'true') {
|
||||||
|
document.getElementById('sort-by-unread-switch').checked = true
|
||||||
|
document.getElementById('sort-by-unread-switch').dispatchEvent(new Event('change'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Des données sont reçues depuis le serveur. Elles sont traitées dans cette fonction,
|
||||||
|
* qui a pour but de trier et de répartir dans d'autres sous-fonctions.
|
||||||
|
* @param data Le message reçu.
|
||||||
|
*/
|
||||||
|
function processMessage(data) {
|
||||||
|
// On traite le message en fonction de son type
|
||||||
|
switch (data.type) {
|
||||||
|
case 'fetch_channels':
|
||||||
|
setChannels(data.channels)
|
||||||
|
break
|
||||||
|
case 'send_message':
|
||||||
|
receiveMessage(data)
|
||||||
|
break
|
||||||
|
case 'edit_message':
|
||||||
|
editMessage(data)
|
||||||
|
break
|
||||||
|
case 'delete_message':
|
||||||
|
deleteMessage(data)
|
||||||
|
break
|
||||||
|
case 'fetch_messages':
|
||||||
|
receiveFetchedMessages(data)
|
||||||
|
break
|
||||||
|
case 'mark_read':
|
||||||
|
markMessageAsRead(data)
|
||||||
|
break
|
||||||
|
case 'start_private_chat':
|
||||||
|
startPrivateChat(data)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
// Le type de message est inconnu. On affiche une erreur dans la console.
|
||||||
|
console.log(data)
|
||||||
|
console.error('Unknown message type:', data.type)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration du socket de chat, permettant de communiquer avec le serveur.
|
||||||
|
* @param nextDelay Correspond au délai de reconnexion en cas d'erreur.
|
||||||
|
* Augmente exponentiellement en cas d'erreurs répétées,
|
||||||
|
* et se réinitialise à 1s en cas de connexion réussie.
|
||||||
|
*/
|
||||||
|
function setupSocket(nextDelay = 1000) {
|
||||||
|
// Ouverture du socket
|
||||||
|
socket = new WebSocket(
|
||||||
|
(document.location.protocol === 'https:' ? 'wss' : 'ws') + '://' + window.location.host + '/ws/chat/'
|
||||||
|
)
|
||||||
|
let socketOpen = false
|
||||||
|
|
||||||
|
// Écoute des messages reçus depuis le serveur
|
||||||
|
socket.addEventListener('message', e => {
|
||||||
|
// Analyse du message reçu en tant que JSON
|
||||||
|
const data = JSON.parse(e.data)
|
||||||
|
|
||||||
|
// Traite le message reçu
|
||||||
|
processMessage(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
// En cas d'erreur, on affiche un message et on réessaie de se connecter après un certain délai
|
||||||
|
// Ce délai double après chaque erreur répétée, jusqu'à un maximum de 2 minutes
|
||||||
|
socket.addEventListener('close', e => {
|
||||||
|
console.error('Chat socket closed unexpectedly, restarting…')
|
||||||
|
setTimeout(() => setupSocket(Math.max(socketOpen ? 1000 : 2 * nextDelay, 120000)), nextDelay)
|
||||||
|
})
|
||||||
|
|
||||||
|
// En cas de connexion réussie, on demande au serveur les derniers messages pour chaque canal
|
||||||
|
socket.addEventListener('open', e => {
|
||||||
|
socketOpen = true
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
'type': 'fetch_channels',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration du swipe pour ouvrir et fermer le sélecteur de canaux.
|
||||||
|
* Fonctionne a priori uniquement sur les écrans tactiles.
|
||||||
|
* Lorsqu'on swipe de la gauche vers la droite, depuis le côté gauche de l'écran, on ouvre le sélecteur de canaux.
|
||||||
|
* Quand on swipe de la droite vers la gauche, on ferme le sélecteur de canaux.
|
||||||
|
*/
|
||||||
|
function setupSwipeOffscreen() {
|
||||||
|
// Récupération du sélecteur de canaux
|
||||||
|
const offcanvas = new bootstrap.Offcanvas(document.getElementById('channelSelector'))
|
||||||
|
|
||||||
|
// L'écran a été touché. On récupère la coordonnée X de l'emplacement touché.
|
||||||
|
let lastX = null
|
||||||
|
document.addEventListener('touchstart', (event) => {
|
||||||
|
if (event.touches.length === 1)
|
||||||
|
lastX = event.touches[0].clientX
|
||||||
|
})
|
||||||
|
|
||||||
|
// Le doigt a été déplacé. Selon le nouvel emplacement du doigt, on ouvre ou on ferme le sélecteur de canaux.
|
||||||
|
document.addEventListener('touchmove', (event) => {
|
||||||
|
if (event.touches.length === 1 && lastX !== null) {
|
||||||
|
// L'écran a été touché à un seul doigt, et on a déjà récupéré la coordonnée X touchée.
|
||||||
|
const diff = event.touches[0].clientX - lastX
|
||||||
|
if (diff > window.innerWidth / 10 && lastX < window.innerWidth / 4) {
|
||||||
|
// Si le déplacement correspond à au moins 10 % de la largeur de l'écran vers la droite
|
||||||
|
// et que le point de départ se trouve dans le quart gauche de l'écran, alors on ouvre le sélecteur
|
||||||
|
offcanvas.show()
|
||||||
|
lastX = null
|
||||||
|
}
|
||||||
|
else if (diff < -window.innerWidth / 10) {
|
||||||
|
// Si le déplacement correspond à au moins 10 % de la largeur de l'écran vers la gauche,
|
||||||
|
// alors on ferme le sélecteur
|
||||||
|
offcanvas.hide()
|
||||||
|
lastX = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Le doigt a été relâché. On réinitialise la coordonnée X touchée.
|
||||||
|
document.addEventListener('touchend', () => {
|
||||||
|
lastX = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration du suivi de lecture des messages.
|
||||||
|
* Lorsque l'utilisateur⋅rice scrolle dans la fenêtre de chat, on vérifie quels sont les messages qui sont
|
||||||
|
* visibles à l'écran, et on les marque comme lus.
|
||||||
|
*/
|
||||||
|
function setupReadTracker() {
|
||||||
|
// Récupération du conteneur de messages
|
||||||
|
const scrollableContent = document.getElementById('chat-messages')
|
||||||
|
const messagesList = document.getElementById('message-list')
|
||||||
|
let markReadBuffer = []
|
||||||
|
let markReadTimeout = null
|
||||||
|
|
||||||
|
// Lorsqu'on scrolle, on récupère les anciens messages si on est tout en haut,
|
||||||
|
// et on marque les messages visibles comme lus
|
||||||
|
scrollableContent.addEventListener('scroll', () => {
|
||||||
|
if (scrollableContent.clientHeight - scrollableContent.scrollTop === scrollableContent.scrollHeight
|
||||||
|
&& !document.getElementById('fetch-previous-messages').classList.contains('d-none')) {
|
||||||
|
// Si l'utilisateur⋅rice est en haut du chat, on récupère la liste des anciens messages
|
||||||
|
fetchPreviousMessages()}
|
||||||
|
|
||||||
|
// On marque les messages visibles comme lus
|
||||||
|
markVisibleMessagesAsRead()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Lorsque les messages stockés sont mis à jour, on vérifie quels sont les messages visibles à marquer comme lus
|
||||||
|
messagesList.addEventListener('updatemessages', () => markVisibleMessagesAsRead())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marque les messages visibles à l'écran comme lus.
|
||||||
|
* On récupère pour cela les coordonnées du conteneur de messages ainsi que les coordonnées de chaque message
|
||||||
|
* et on vérifie si le message est visible à l'écran. Si c'est le cas, on le marque comme lu.
|
||||||
|
* Après 3 secondes d'attente après qu'aucun message n'ait été lu,
|
||||||
|
* on envoie la liste des messages lus au serveur.
|
||||||
|
*/
|
||||||
|
function markVisibleMessagesAsRead() {
|
||||||
|
// Récupération des coordonnées visibles du conteneur de messages
|
||||||
|
let viewport = scrollableContent.getBoundingClientRect()
|
||||||
|
|
||||||
|
for (let item of messagesList.querySelectorAll('.message')) {
|
||||||
|
let message = messages[selected_channel_id].get(parseInt(item.getAttribute('data-message-id')))
|
||||||
|
if (!message.read) {
|
||||||
|
// Si le message n'a pas déjà été lu, on récupère ses coordonnées
|
||||||
|
let rect = item.getBoundingClientRect()
|
||||||
|
if (rect.top >= viewport.top && rect.bottom <= viewport.bottom) {
|
||||||
|
// Si les coordonnées sont entièrement incluses dans le rectangle visible, on le marque comme lu
|
||||||
|
// et comme étant à envoyer au serveur
|
||||||
|
message.read = true
|
||||||
|
markReadBuffer.push(message.id)
|
||||||
|
if (markReadTimeout)
|
||||||
|
clearTimeout(markReadTimeout)
|
||||||
|
// 3 secondes après qu'aucun nouveau message n'ait été rajouté, on envoie la liste des messages
|
||||||
|
// lus au serveur
|
||||||
|
markReadTimeout = setTimeout(() => {
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
'type': 'mark_read',
|
||||||
|
'message_ids': markReadBuffer,
|
||||||
|
}))
|
||||||
|
markReadBuffer = []
|
||||||
|
markReadTimeout = null
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On considère les messages d'ores-et-déjà visibles comme lus
|
||||||
|
markVisibleMessagesAsRead()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration de la demande d'installation de l'application en tant qu'application web progressive (PWA).
|
||||||
|
* Lorsque l'utilisateur⋅rice arrive sur la page, on lui propose de télécharger l'application
|
||||||
|
* pour l'ajouter à son écran d'accueil.
|
||||||
|
* Fonctionne uniquement sur les navigateurs compatibles.
|
||||||
|
*/
|
||||||
|
function setupPWAPrompt() {
|
||||||
|
let deferredPrompt = null
|
||||||
|
|
||||||
|
window.addEventListener("beforeinstallprompt", (e) => {
|
||||||
|
// Une demande d'installation a été faite. On commence par empêcher l'action par défaut.
|
||||||
|
e.preventDefault()
|
||||||
|
deferredPrompt = e
|
||||||
|
|
||||||
|
// L'installation est possible, on rend visible le bouton de téléchargement
|
||||||
|
// ainsi que le message qui indique c'est possible.
|
||||||
|
let btn = document.getElementById('install-app-home-screen')
|
||||||
|
let alert = document.getElementById('alert-download-chat-app')
|
||||||
|
btn.classList.remove('d-none')
|
||||||
|
alert.classList.remove('d-none')
|
||||||
|
btn.onclick = function () {
|
||||||
|
// Lorsque le bouton de téléchargement est cliqué, on lance l'installation du PWA.
|
||||||
|
deferredPrompt.prompt()
|
||||||
|
deferredPrompt.userChoice.then((choiceResult) => {
|
||||||
|
if (choiceResult.outcome === 'accepted') {
|
||||||
|
// Si l'installation a été acceptée, on masque le bouton de téléchargement.
|
||||||
|
deferredPrompt = null
|
||||||
|
btn.classList.add('d-none')
|
||||||
|
alert.classList.add('d-none')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setupSocket() // Configuration du Websocket
|
||||||
|
setupSwipeOffscreen() // Configuration du swipe sur les écrans tactiles pour le sélecteur de canaux
|
||||||
|
setupReadTracker() // Configuration du suivi de lecture des messages
|
||||||
|
setupPWAPrompt() // Configuration de l'installateur d'application en tant qu'application web progressive
|
||||||
|
})
|
@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
{% load static %}
|
{% load static %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load pipeline %}
|
||||||
|
|
||||||
{% block extracss %}
|
{% block extracss %}
|
||||||
<link rel="manifest" href="{% static "chat.webmanifest" %}">
|
{# Webmanifest PWA permettant l'installation de l'application sur un écran d'accueil, pour navigateurs supportés #}
|
||||||
|
<link rel="manifest" href="{% static "tfjm/chat.webmanifest" %}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content-title %}{% endblock %}
|
{% block content-title %}{% endblock %}
|
||||||
@ -14,6 +16,6 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extrajavascript %}
|
{% block extrajavascript %}
|
||||||
{# This script contains all data for the chat management #}
|
{# Ce script contient toutes les données pour la gestion du chat. #}
|
||||||
<script src="{% static 'chat.js' %}"></script>
|
{% javascript 'chat' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -1,32 +1,40 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
<noscript>
|
<noscript>
|
||||||
|
{# Le chat fonctionne à l'aide d'un script JavaScript, sans JavaScript activé il n'est pas possible d'utiliser le chat. #}
|
||||||
{% trans "JavaScript must be enabled on your browser to access chat." %}
|
{% trans "JavaScript must be enabled on your browser to access chat." %}
|
||||||
</noscript>
|
</noscript>
|
||||||
<div class="offcanvas offcanvas-start" tabindex="-1" id="channelSelector" aria-labelledby="offcanvasTitle">
|
<div class="offcanvas offcanvas-start" tabindex="-1" id="channelSelector" aria-labelledby="offcanvasTitle">
|
||||||
<div class="offcanvas-header">
|
<div class="offcanvas-header">
|
||||||
|
{# Titre du sélecteur de canaux #}
|
||||||
<h3 class="offcanvas-title" id="offcanvasTitle">{% trans "Chat channels" %}</h3>
|
<h3 class="offcanvas-title" id="offcanvasTitle">{% trans "Chat channels" %}</h3>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="offcanvas-body">
|
<div class="offcanvas-body">
|
||||||
|
{# Contenu du sélecteur de canaux #}
|
||||||
<div class="form-switch form-switch">
|
<div class="form-switch form-switch">
|
||||||
<input class="form-check-input" type="checkbox" role="switch" id="sort-by-unread-switch">
|
<input class="form-check-input" type="checkbox" role="switch" id="sort-by-unread-switch">
|
||||||
<label class="form-check-label" for="sort-by-unread-switch">{% trans "Sort by unread messages" %}</label>
|
<label class="form-check-label" for="sort-by-unread-switch">{% trans "Sort by unread messages" %}</label>
|
||||||
</div>
|
</div>
|
||||||
<ul class="list-group list-group-flush" id="nav-channels-tab">
|
<ul class="list-group list-group-flush" id="nav-channels-tab">
|
||||||
|
{# Liste des différentes catégories, avec les canaux par catégorie #}
|
||||||
<li class="list-group-item d-none">
|
<li class="list-group-item d-none">
|
||||||
|
{# Canaux généraux #}
|
||||||
<h4>{% trans "General channels" %}</h4>
|
<h4>{% trans "General channels" %}</h4>
|
||||||
<ul class="list-group list-group-flush" id="nav-general-channels-tab"></ul>
|
<ul class="list-group list-group-flush" id="nav-general-channels-tab"></ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="list-group-item d-none">
|
<li class="list-group-item d-none">
|
||||||
|
{# Canaux liés à un tournoi #}
|
||||||
<h4>{% trans "Tournament channels" %}</h4>
|
<h4>{% trans "Tournament channels" %}</h4>
|
||||||
<ul class="list-group list-group-flush" id="nav-tournament-channels-tab"></ul>
|
<ul class="list-group list-group-flush" id="nav-tournament-channels-tab"></ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="list-group-item d-none">
|
<li class="list-group-item d-none">
|
||||||
|
{# Canaux d'équipes #}
|
||||||
<h4>{% trans "Team channels" %}</h4>
|
<h4>{% trans "Team channels" %}</h4>
|
||||||
<ul class="list-group list-group-flush" id="nav-team-channels-tab"></ul>
|
<ul class="list-group list-group-flush" id="nav-team-channels-tab"></ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="list-group-item d-none">
|
<li class="list-group-item d-none">
|
||||||
|
{# Échanges privés #}
|
||||||
<h4>{% trans "Private channels" %}</h4>
|
<h4>{% trans "Private channels" %}</h4>
|
||||||
<ul class="list-group list-group-flush" id="nav-private-channels-tab"></ul>
|
<ul class="list-group list-group-flush" id="nav-private-channels-tab"></ul>
|
||||||
</li>
|
</li>
|
||||||
@ -35,32 +43,41 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="alert alert-info d-none" id="alert-download-chat-app">
|
<div class="alert alert-info d-none" id="alert-download-chat-app">
|
||||||
|
{# Lorsque l'application du chat est installable (par exemple sur un Chrome sur Android), on affiche le message qui indique que c'est bien possible. #}
|
||||||
{% trans "You can install a shortcut to the chat on your home screen using the download button on the header." %}
|
{% trans "You can install a shortcut to the chat on your home screen using the download button on the header." %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Conteneur principal du chat. #}
|
||||||
|
{# Lorsque le chat est en plein écran, on le place en coordonnées absolues, occupant tout l'espace de l'écran. #}
|
||||||
<div class="card tab-content w-100 mh-100{% if request.GET.fullscreen == '1' or fullscreen %} position-absolute top-0 start-0 vh-100 z-3{% endif %}"
|
<div class="card tab-content w-100 mh-100{% if request.GET.fullscreen == '1' or fullscreen %} position-absolute top-0 start-0 vh-100 z-3{% endif %}"
|
||||||
style="height: 95vh" id="chat-container">
|
style="height: 95vh" id="chat-container">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3>
|
<h3>
|
||||||
{% if fullscreen %}
|
{% if fullscreen %}
|
||||||
{# Logout button must be present in a form. The form must includes the whole line. #}
|
{# Lorsque le chat est en plein écran, on affiche le bouton de déconnexion. #}
|
||||||
|
{# Le bouton de déconnexion doit être présent dans un formulaire. Le formulaire doit inclure toute la ligne. #}
|
||||||
<form action="{% url 'chat:logout' %}" method="post">
|
<form action="{% url 'chat:logout' %}" method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{# Bouton qui permet d'ouvrir le sélecteur de canaux #}
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="offcanvas" data-bs-target="#channelSelector"
|
<button class="navbar-toggler" type="button" data-bs-toggle="offcanvas" data-bs-target="#channelSelector"
|
||||||
aria-controls="channelSelector" aria-expanded="false" aria-label="Toggle channel selector">
|
aria-controls="channelSelector" aria-expanded="false" aria-label="Toggle channel selector">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
<span id="channel-title"></span>
|
|
||||||
|
<span id="channel-title"></span> {# Titre du canal sélectionné #}
|
||||||
{% if not fullscreen %}
|
{% if not fullscreen %}
|
||||||
|
{# Dans le cas où on est pas uniquement en plein écran (cas de l'application), on affiche les boutons pour passer en ou quitter le mode plein écran. #}
|
||||||
<button class="btn float-end" type="button" onclick="toggleFullscreen()" title="{% trans "Toggle fullscreen mode" %}">
|
<button class="btn float-end" type="button" onclick="toggleFullscreen()" title="{% trans "Toggle fullscreen mode" %}">
|
||||||
<i class="fas fa-expand"></i>
|
<i class="fas fa-expand"></i>
|
||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
{# Le bouton de déconnexion n'est affiché que sur l'application. #}
|
||||||
<button class="btn float-end" title="{% trans "Log out" %}">
|
<button class="btn float-end" title="{% trans "Log out" %}">
|
||||||
<i class="fas fa-sign-out-alt"></i>
|
<i class="fas fa-sign-out-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{# On affiche le bouton d'installation uniquement dans le cas où l'application est installable sur l'écran d'accueil. #}
|
||||||
<button class="btn float-end d-none" type="button" id="install-app-home-screen" title="{% trans "Install app on home screen" %}">
|
<button class="btn float-end d-none" type="button" id="install-app-home-screen" title="{% trans "Install app on home screen" %}">
|
||||||
<i class="fas fa-download"></i>
|
<i class="fas fa-download"></i>
|
||||||
</button>
|
</button>
|
||||||
@ -69,8 +86,12 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Contenu de la carte, contenant la liste des messages. La liste des messages est affichée à l'envers pour avoir un scroll plus cohérent. #}
|
||||||
<div class="card-body d-flex flex-column-reverse flex-grow-0 overflow-y-scroll" id="chat-messages">
|
<div class="card-body d-flex flex-column-reverse flex-grow-0 overflow-y-scroll" id="chat-messages">
|
||||||
|
{# Correspond à la liste des messages à afficher. #}
|
||||||
<ul class="list-group list-group-flush" id="message-list"></ul>
|
<ul class="list-group list-group-flush" id="message-list"></ul>
|
||||||
|
{# S'il y a des messages à récupérer, on affiche un lien qui permet de récupérer les anciens messages. #}
|
||||||
<div class="text-center d-none" id="fetch-previous-messages">
|
<div class="text-center d-none" id="fetch-previous-messages">
|
||||||
<a href="#" class="nav-link" onclick="event.preventDefault(); fetchPreviousMessages()">
|
<a href="#" class="nav-link" onclick="event.preventDefault(); fetchPreviousMessages()">
|
||||||
{% trans "Fetch previous messages…" %}
|
{% trans "Fetch previous messages…" %}
|
||||||
@ -78,12 +99,16 @@
|
|||||||
<hr>
|
<hr>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Pied de la carte, contenant le formulaire pour envoyer un message. #}
|
||||||
<div class="card-footer mt-auto">
|
<div class="card-footer mt-auto">
|
||||||
|
{# Lorsqu'on souhaite envoyer un message, on empêche le formulaire de s'envoyer et on envoie le message par websocket. #}
|
||||||
<form onsubmit="event.preventDefault(); sendMessage()">
|
<form onsubmit="event.preventDefault(); sendMessage()">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label for="input-message" class="input-group-text">
|
<label for="input-message" class="input-group-text">
|
||||||
<i class="fas fa-comment"></i>
|
<i class="fas fa-comment"></i>
|
||||||
</label>
|
</label>
|
||||||
|
{# Affichage du contrôleur de texte pour rédiger le message à envoyer. #}
|
||||||
<input type="text" class="form-control" id="input-message" placeholder="{% trans "Send message…" %}" autofocus autocomplete="off">
|
<input type="text" class="form-control" id="input-message" placeholder="{% trans "Send message…" %}" autofocus autocomplete="off">
|
||||||
<button class="input-group-text btn btn-success" type="submit">
|
<button class="input-group-text btn btn-success" type="submit">
|
||||||
<i class="fas fa-paper-plane"></i>
|
<i class="fas fa-paper-plane"></i>
|
||||||
@ -94,6 +119,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
{# Récupération de l'utilisateur⋅rice courant⋅e afin de pouvoir effectuer des tests plus tard. #}
|
||||||
const USER_ID = {{ request.user.id }}
|
const USER_ID = {{ request.user.id }}
|
||||||
|
{# Récupération du statut administrateur⋅rice de l'utilisateur⋅rice connecté⋅e afin de pouvoir effectuer des tests plus tard. #}
|
||||||
const IS_ADMIN = {{ request.user.registration.is_admin|yesno:"true,false" }}
|
const IS_ADMIN = {{ request.user.registration.is_admin|yesno:"true,false" }}
|
||||||
</script>
|
</script>
|
@ -1,4 +1,4 @@
|
|||||||
{% load i18n static %}
|
{% load i18n pipeline static %}
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
{% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %}
|
{% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %}
|
||||||
@ -7,28 +7,29 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
<title>
|
<title>
|
||||||
Chat du TFJM²
|
{% trans "TFJM² Chat" %}
|
||||||
</title>
|
</title>
|
||||||
<meta name="description" content="Chat du TFJM²">
|
<meta name="description" content="{% trans "TFJM² Chat" %}">
|
||||||
|
|
||||||
{# Favicon #}
|
{# Favicon #}
|
||||||
<link rel="shortcut icon" href="{% static "favicon.ico" %}">
|
<link rel="shortcut icon" href="{% static "favicon.ico" %}">
|
||||||
<meta name="theme-color" content="#ffffff">
|
<meta name="theme-color" content="#ffffff">
|
||||||
|
|
||||||
{# Bootstrap CSS #}
|
{# Bootstrap + Font Awesome CSS #}
|
||||||
<link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
|
{% stylesheet 'bootstrap_fontawesome' %}
|
||||||
<link rel="stylesheet" href="{% static 'fontawesome/css/all.css' %}">
|
|
||||||
<link rel="stylesheet" href="{% static 'fontawesome/css/v4-shims.css' %}">
|
|
||||||
|
|
||||||
{# Bootstrap JavaScript #}
|
{# Bootstrap JavaScript #}
|
||||||
<script src="{% static 'bootstrap/js/bootstrap.bundle.min.js' %}"></script>
|
{% javascript 'bootstrap' %}
|
||||||
|
|
||||||
<link rel="manifest" href="{% static "chat.webmanifest" %}">
|
{# Webmanifest PWA permettant l'installation de l'application sur un écran d'accueil, pour navigateurs supportés #}
|
||||||
|
<link rel="manifest" href="{% static "tfjm/chat.webmanifest" %}">
|
||||||
</head>
|
</head>
|
||||||
<body class="d-flex w-100 h-100 flex-column">
|
<body class="d-flex w-100 h-100 flex-column">
|
||||||
{% include "chat/content.html" with fullscreen=True %}
|
{% include "chat/content.html" with fullscreen=True %}
|
||||||
|
|
||||||
<script src="{% static 'theme.js' %}"></script>
|
{# Inclusion du script permettant de gérer le thème sombre et le thème clair #}
|
||||||
<script src="{% static 'chat.js' %}"></script>
|
{% javascript 'theme' %}
|
||||||
|
{# Inclusion du script gérant le chat #}
|
||||||
|
{% javascript 'chat' %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
{% load i18n static %}
|
{% load i18n pipeline static %}
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
{% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %}
|
{% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %}
|
||||||
@ -7,23 +7,22 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
<title>
|
<title>
|
||||||
Chat du TFJM² - {% trans "Log in" %}
|
{% trans "TFJM² Chat" %} - {% trans "Log in" %}
|
||||||
</title>
|
</title>
|
||||||
<meta name="description" content="Chat du TFJM²">
|
<meta name="description" content="{% trans "TFJM² Chat" %}">
|
||||||
|
|
||||||
{# Favicon #}
|
{# Favicon #}
|
||||||
<link rel="shortcut icon" href="{% static "favicon.ico" %}">
|
<link rel="shortcut icon" href="{% static "favicon.ico" %}">
|
||||||
<meta name="theme-color" content="#ffffff">
|
<meta name="theme-color" content="#ffffff">
|
||||||
|
|
||||||
{# Bootstrap CSS #}
|
{# Bootstrap CSS #}
|
||||||
<link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
|
{% stylesheet 'bootstrap_fontawesome' %}
|
||||||
<link rel="stylesheet" href="{% static 'fontawesome/css/all.css' %}">
|
|
||||||
<link rel="stylesheet" href="{% static 'fontawesome/css/v4-shims.css' %}">
|
|
||||||
|
|
||||||
{# Bootstrap JavaScript #}
|
{# Bootstrap JavaScript #}
|
||||||
<script src="{% static 'bootstrap/js/bootstrap.bundle.min.js' %}"></script>
|
{% javascript 'bootstrap' %}
|
||||||
|
|
||||||
<link rel="manifest" href="{% static "chat.webmanifest" %}">
|
{# Webmanifest PWA permettant l'installation de l'application sur un écran d'accueil, pour navigateurs supportés #}
|
||||||
|
<link rel="manifest" href="{% static "tfjm/chat.webmanifest" %}">
|
||||||
</head>
|
</head>
|
||||||
<body class="d-flex w-100 h-100 flex-column">
|
<body class="d-flex w-100 h-100 flex-column">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@ -31,6 +30,7 @@
|
|||||||
{% include "registration/includes/login.html" %}
|
{% include "registration/includes/login.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="{% static 'theme.js' %}"></script>
|
{# Inclusion du script permettant de gérer le thème sombre et le thème clair #}
|
||||||
|
{% javascript 'theme' %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -60,7 +60,7 @@ Dans le fichier ``docker-compose.yml``, configurer :
|
|||||||
networks:
|
networks:
|
||||||
- tfjm
|
- tfjm
|
||||||
labels:
|
labels:
|
||||||
- "traefik.http.routers.inscription-tfjm2.rule=Host(`inscription.tfjm.org`, `plateforme.tfjm.org`)"
|
- "traefik.http.routers.inscription-tfjm2.rule=Host(`inscription.tfjm.org`, `inscriptions.tfjm.org`, `plateforme.tfjm.org`)"
|
||||||
- "traefik.http.routers.inscription-tfjm2.entrypoints=websecure"
|
- "traefik.http.routers.inscription-tfjm2.entrypoints=websecure"
|
||||||
- "traefik.http.routers.inscription-tfjm2.tls.certresolver=mytlschallenge"
|
- "traefik.http.routers.inscription-tfjm2.tls.certresolver=mytlschallenge"
|
||||||
|
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
{% load static %}
|
{% load static %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load pipeline %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{# The navbar to select the tournament #}
|
{# The navbar to select the tournament #}
|
||||||
@ -40,5 +41,5 @@
|
|||||||
{{ problems|length|json_script:'problems_count' }}
|
{{ problems|length|json_script:'problems_count' }}
|
||||||
|
|
||||||
{# This script contains all data for the draw management #}
|
{# This script contains all data for the draw management #}
|
||||||
<script src="{% static 'draw.js' %}"></script>
|
{% javascript 'draw' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -7,7 +7,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: TFJM\n"
|
"Project-Id-Version: TFJM\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2024-04-28 23:37+0200\n"
|
"POT-Creation-Date: 2024-05-26 22:06+0200\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: Emmy D'Anello <emmy.danello@animath.fr>\n"
|
"Last-Translator: Emmy D'Anello <emmy.danello@animath.fr>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@ -21,40 +21,62 @@ msgstr ""
|
|||||||
msgid "API"
|
msgid "API"
|
||||||
msgstr "API"
|
msgstr "API"
|
||||||
|
|
||||||
#: chat/models.py:17 chat/templates/chat/content.html:18
|
#: chat/models.py:22 chat/templates/chat/content.html:23
|
||||||
msgid "General channels"
|
msgid "General channels"
|
||||||
msgstr "Canaux généraux"
|
msgstr "Canaux généraux"
|
||||||
|
|
||||||
#: chat/models.py:18 chat/templates/chat/content.html:22
|
#: chat/models.py:23 chat/templates/chat/content.html:28
|
||||||
msgid "Tournament channels"
|
msgid "Tournament channels"
|
||||||
msgstr "Canaux de tournois"
|
msgstr "Canaux de tournois"
|
||||||
|
|
||||||
#: chat/models.py:19 chat/templates/chat/content.html:26
|
#: chat/models.py:24 chat/templates/chat/content.html:33
|
||||||
msgid "Team channels"
|
msgid "Team channels"
|
||||||
msgstr "Canaux d'équipes"
|
msgstr "Canaux d'équipes"
|
||||||
|
|
||||||
#: chat/models.py:20 chat/templates/chat/content.html:30
|
#: chat/models.py:25 chat/templates/chat/content.html:38
|
||||||
msgid "Private channels"
|
msgid "Private channels"
|
||||||
msgstr "Messages privés"
|
msgstr "Messages privés"
|
||||||
|
|
||||||
#: chat/models.py:24 participation/models.py:35 participation/models.py:263
|
#: chat/models.py:29 participation/models.py:35 participation/models.py:263
|
||||||
#: participation/tables.py:18 participation/tables.py:34
|
#: participation/tables.py:18 participation/tables.py:34
|
||||||
msgid "name"
|
msgid "name"
|
||||||
msgstr "nom"
|
msgstr "nom"
|
||||||
|
|
||||||
#: chat/models.py:29
|
#: chat/models.py:30
|
||||||
|
msgid "Visible name of the channel."
|
||||||
|
msgstr "Nom visible du canal."
|
||||||
|
|
||||||
|
#: chat/models.py:35
|
||||||
msgid "category"
|
msgid "category"
|
||||||
msgstr "catégorie"
|
msgstr "catégorie"
|
||||||
|
|
||||||
#: chat/models.py:36
|
#: chat/models.py:38
|
||||||
|
msgid ""
|
||||||
|
"Category of the channel, between general channels, tournament-specific "
|
||||||
|
"channels, team channels or private channels. Will be used to sort channels "
|
||||||
|
"in the channel list."
|
||||||
|
msgstr ""
|
||||||
|
"Catégorie du canal, entre canaux généraux, canaux spécifiques à un tournoi, "
|
||||||
|
"canaux d'équipe ou messages privés. Sera utilisé pour trier les canaux dans "
|
||||||
|
"la liste des canaux."
|
||||||
|
|
||||||
|
#: chat/models.py:44
|
||||||
msgid "read permission"
|
msgid "read permission"
|
||||||
msgstr "permission de lecture"
|
msgstr "permission de lecture"
|
||||||
|
|
||||||
#: chat/models.py:42
|
#: chat/models.py:46
|
||||||
|
msgid "Permission type that is required to read the messages of the channels."
|
||||||
|
msgstr "Type de permission nécessaire pour lire les messages des canaux."
|
||||||
|
|
||||||
|
#: chat/models.py:51
|
||||||
msgid "write permission"
|
msgid "write permission"
|
||||||
msgstr "permission d'écriture"
|
msgstr "permission d'écriture"
|
||||||
|
|
||||||
#: chat/models.py:52 draw/admin.py:53 draw/admin.py:71 draw/admin.py:88
|
#: chat/models.py:53
|
||||||
|
msgid "Permission type that is required to write a message to a channel."
|
||||||
|
msgstr "Type de permission nécessaire pour écrire un message dans un canal."
|
||||||
|
|
||||||
|
#: chat/models.py:62 draw/admin.py:53 draw/admin.py:71 draw/admin.py:88
|
||||||
#: draw/models.py:26 participation/admin.py:79 participation/admin.py:140
|
#: draw/models.py:26 participation/admin.py:79 participation/admin.py:140
|
||||||
#: participation/admin.py:171 participation/models.py:693
|
#: participation/admin.py:171 participation/models.py:693
|
||||||
#: participation/models.py:717 participation/models.py:935
|
#: participation/models.py:717 participation/models.py:935
|
||||||
@ -63,7 +85,7 @@ msgstr "permission d'écriture"
|
|||||||
msgid "tournament"
|
msgid "tournament"
|
||||||
msgstr "tournoi"
|
msgstr "tournoi"
|
||||||
|
|
||||||
#: chat/models.py:54
|
#: chat/models.py:64
|
||||||
msgid ""
|
msgid ""
|
||||||
"For a permission that concerns a tournament, indicates what is the concerned "
|
"For a permission that concerns a tournament, indicates what is the concerned "
|
||||||
"tournament."
|
"tournament."
|
||||||
@ -71,21 +93,21 @@ msgstr ""
|
|||||||
"Pour une permission qui concerne un tournoi, indique quel est le tournoi "
|
"Pour une permission qui concerne un tournoi, indique quel est le tournoi "
|
||||||
"concerné."
|
"concerné."
|
||||||
|
|
||||||
#: chat/models.py:63 draw/models.py:429 draw/models.py:456
|
#: chat/models.py:73 draw/models.py:429 draw/models.py:456
|
||||||
#: participation/admin.py:136 participation/admin.py:155
|
#: participation/admin.py:136 participation/admin.py:155
|
||||||
#: participation/models.py:1434 participation/models.py:1443
|
#: participation/models.py:1434 participation/models.py:1443
|
||||||
#: participation/tables.py:84
|
#: participation/tables.py:84
|
||||||
msgid "pool"
|
msgid "pool"
|
||||||
msgstr "poule"
|
msgstr "poule"
|
||||||
|
|
||||||
#: chat/models.py:65
|
#: chat/models.py:75
|
||||||
msgid ""
|
msgid ""
|
||||||
"For a permission that concerns a pool, indicates what is the concerned pool."
|
"For a permission that concerns a pool, indicates what is the concerned pool."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Pour une permission qui concerne une poule, indique quelle est la poule "
|
"Pour une permission qui concerne une poule, indique quelle est la poule "
|
||||||
"concernée."
|
"concernée."
|
||||||
|
|
||||||
#: chat/models.py:74 draw/templates/draw/tournament_content.html:277
|
#: chat/models.py:84 draw/templates/draw/tournament_content.html:277
|
||||||
#: participation/admin.py:167 participation/models.py:252
|
#: participation/admin.py:167 participation/models.py:252
|
||||||
#: participation/models.py:708
|
#: participation/models.py:708
|
||||||
#: participation/templates/participation/tournament_harmonize.html:15
|
#: participation/templates/participation/tournament_harmonize.html:15
|
||||||
@ -95,18 +117,18 @@ msgstr ""
|
|||||||
msgid "team"
|
msgid "team"
|
||||||
msgstr "équipe"
|
msgstr "équipe"
|
||||||
|
|
||||||
#: chat/models.py:76
|
#: chat/models.py:86
|
||||||
msgid ""
|
msgid ""
|
||||||
"For a permission that concerns a team, indicates what is the concerned team."
|
"For a permission that concerns a team, indicates what is the concerned team."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Pour une permission qui concerne une équipe, indique quelle est l'équipe "
|
"Pour une permission qui concerne une équipe, indique quelle est l'équipe "
|
||||||
"concernée."
|
"concernée."
|
||||||
|
|
||||||
#: chat/models.py:80
|
#: chat/models.py:90
|
||||||
msgid "private"
|
msgid "private"
|
||||||
msgstr "privé"
|
msgstr "privé"
|
||||||
|
|
||||||
#: chat/models.py:82
|
#: chat/models.py:92
|
||||||
msgid ""
|
msgid ""
|
||||||
"If checked, only users who have been explicitly added to the channel will be "
|
"If checked, only users who have been explicitly added to the channel will be "
|
||||||
"able to access it."
|
"able to access it."
|
||||||
@ -114,11 +136,11 @@ msgstr ""
|
|||||||
"Si sélectionné, seul⋅es les utilisateur⋅rices qui ont été explicitement "
|
"Si sélectionné, seul⋅es les utilisateur⋅rices qui ont été explicitement "
|
||||||
"ajouté⋅es au canal pourront y accéder."
|
"ajouté⋅es au canal pourront y accéder."
|
||||||
|
|
||||||
#: chat/models.py:87
|
#: chat/models.py:97
|
||||||
msgid "invited users"
|
msgid "invited users"
|
||||||
msgstr "Utilisateur⋅rices invité"
|
msgstr "Utilisateur⋅rices invité"
|
||||||
|
|
||||||
#: chat/models.py:90
|
#: chat/models.py:100
|
||||||
msgid ""
|
msgid ""
|
||||||
"Extra users who have been invited to the channel, in addition to the "
|
"Extra users who have been invited to the channel, in addition to the "
|
||||||
"permitted group of the channel."
|
"permitted group of the channel."
|
||||||
@ -126,64 +148,64 @@ msgstr ""
|
|||||||
"Utilisateur⋅rices supplémentaires qui ont été invité⋅es au canal, en plus du "
|
"Utilisateur⋅rices supplémentaires qui ont été invité⋅es au canal, en plus du "
|
||||||
"groupe autorisé du canal."
|
"groupe autorisé du canal."
|
||||||
|
|
||||||
#: chat/models.py:102
|
#: chat/models.py:122
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Channel {name}"
|
msgid "Channel {name}"
|
||||||
msgstr "Canal {name}"
|
msgstr "Canal {name}"
|
||||||
|
|
||||||
#: chat/models.py:168 chat/models.py:177
|
#: chat/models.py:231 chat/models.py:246
|
||||||
msgid "channel"
|
msgid "channel"
|
||||||
msgstr "canal"
|
msgstr "canal"
|
||||||
|
|
||||||
#: chat/models.py:169
|
#: chat/models.py:232
|
||||||
msgid "channels"
|
msgid "channels"
|
||||||
msgstr "canaux"
|
msgstr "canaux"
|
||||||
|
|
||||||
#: chat/models.py:183
|
#: chat/models.py:252
|
||||||
msgid "author"
|
msgid "author"
|
||||||
msgstr "auteur⋅rice"
|
msgstr "auteur⋅rice"
|
||||||
|
|
||||||
#: chat/models.py:190
|
#: chat/models.py:259
|
||||||
msgid "created at"
|
msgid "created at"
|
||||||
msgstr "créé le"
|
msgstr "créé le"
|
||||||
|
|
||||||
#: chat/models.py:195
|
#: chat/models.py:264
|
||||||
msgid "updated at"
|
msgid "updated at"
|
||||||
msgstr "modifié le"
|
msgstr "modifié le"
|
||||||
|
|
||||||
#: chat/models.py:200
|
#: chat/models.py:269
|
||||||
msgid "content"
|
msgid "content"
|
||||||
msgstr "contenu"
|
msgstr "contenu"
|
||||||
|
|
||||||
#: chat/models.py:205
|
#: chat/models.py:274
|
||||||
msgid "users read"
|
msgid "users read"
|
||||||
msgstr "utilisateur⋅rices ayant lu"
|
msgstr "utilisateur⋅rices ayant lu"
|
||||||
|
|
||||||
#: chat/models.py:208
|
#: chat/models.py:277
|
||||||
msgid "Users who have read the message."
|
msgid "Users who have read the message."
|
||||||
msgstr "Utilisateur⋅rices qui ont lu le message."
|
msgstr "Utilisateur⋅rices qui ont lu le message."
|
||||||
|
|
||||||
#: chat/models.py:271
|
#: chat/models.py:363
|
||||||
msgid "message"
|
msgid "message"
|
||||||
msgstr "message"
|
msgstr "message"
|
||||||
|
|
||||||
#: chat/models.py:272
|
#: chat/models.py:364
|
||||||
msgid "messages"
|
msgid "messages"
|
||||||
msgstr "messages"
|
msgstr "messages"
|
||||||
|
|
||||||
#: chat/templates/chat/content.html:4
|
#: chat/templates/chat/content.html:5
|
||||||
msgid "JavaScript must be enabled on your browser to access chat."
|
msgid "JavaScript must be enabled on your browser to access chat."
|
||||||
msgstr "JavaScript doit être activé sur votre navigateur pour accéder au chat."
|
msgstr "JavaScript doit être activé sur votre navigateur pour accéder au chat."
|
||||||
|
|
||||||
#: chat/templates/chat/content.html:8
|
#: chat/templates/chat/content.html:10
|
||||||
msgid "Chat channels"
|
msgid "Chat channels"
|
||||||
msgstr "Canaux de chat"
|
msgstr "Canaux de chat"
|
||||||
|
|
||||||
#: chat/templates/chat/content.html:14
|
#: chat/templates/chat/content.html:17
|
||||||
msgid "Sort by unread messages"
|
msgid "Sort by unread messages"
|
||||||
msgstr "Trier par messages non lus"
|
msgstr "Trier par messages non lus"
|
||||||
|
|
||||||
#: chat/templates/chat/content.html:38
|
#: chat/templates/chat/content.html:47
|
||||||
msgid ""
|
msgid ""
|
||||||
"You can install a shortcut to the chat on your home screen using the "
|
"You can install a shortcut to the chat on your home screen using the "
|
||||||
"download button on the header."
|
"download button on the header."
|
||||||
@ -191,27 +213,33 @@ msgstr ""
|
|||||||
"Vous pouvez installer un raccourci vers le chat sur votre écran d'accueil en "
|
"Vous pouvez installer un raccourci vers le chat sur votre écran d'accueil en "
|
||||||
"utilisant le bouton de téléchargement dans l'en-tête."
|
"utilisant le bouton de téléchargement dans l'en-tête."
|
||||||
|
|
||||||
#: chat/templates/chat/content.html:56
|
#: chat/templates/chat/content.html:71
|
||||||
msgid "Toggle fullscreen mode"
|
msgid "Toggle fullscreen mode"
|
||||||
msgstr "Inverse le mode plein écran"
|
msgstr "Inverse le mode plein écran"
|
||||||
|
|
||||||
#: chat/templates/chat/content.html:60 tfjm/templates/navbar.html:117
|
#: chat/templates/chat/content.html:76 tfjm/templates/navbar.html:117
|
||||||
msgid "Log out"
|
msgid "Log out"
|
||||||
msgstr "Déconnexion"
|
msgstr "Déconnexion"
|
||||||
|
|
||||||
#: chat/templates/chat/content.html:64
|
#: chat/templates/chat/content.html:81
|
||||||
msgid "Install app on home screen"
|
msgid "Install app on home screen"
|
||||||
msgstr "Installer l'application sur l'écran d'accueil"
|
msgstr "Installer l'application sur l'écran d'accueil"
|
||||||
|
|
||||||
#: chat/templates/chat/content.html:76
|
#: chat/templates/chat/content.html:97
|
||||||
msgid "Fetch previous messages…"
|
msgid "Fetch previous messages…"
|
||||||
msgstr "Récupérer les messages précédents…"
|
msgstr "Récupérer les messages précédents…"
|
||||||
|
|
||||||
#: chat/templates/chat/content.html:87
|
#: chat/templates/chat/content.html:112
|
||||||
msgid "Send message…"
|
msgid "Send message…"
|
||||||
msgstr "Envoyer un message…"
|
msgstr "Envoyer un message…"
|
||||||
|
|
||||||
#: chat/templates/chat/login.html:10 chat/templates/chat/login.html:30
|
#: chat/templates/chat/fullscreen.html:10
|
||||||
|
#: chat/templates/chat/fullscreen.html:12 chat/templates/chat/login.html:10
|
||||||
|
#: chat/templates/chat/login.html:12
|
||||||
|
msgid "TFJM² Chat"
|
||||||
|
msgstr "Chat du TFJM²"
|
||||||
|
|
||||||
|
#: chat/templates/chat/login.html:10 chat/templates/chat/login.html:31
|
||||||
#: registration/templates/registration/password_reset_complete.html:10
|
#: registration/templates/registration/password_reset_complete.html:10
|
||||||
#: tfjm/templates/base.html:84 tfjm/templates/base.html:85
|
#: tfjm/templates/base.html:84 tfjm/templates/base.html:85
|
||||||
#: tfjm/templates/navbar.html:98
|
#: tfjm/templates/navbar.html:98
|
||||||
@ -575,8 +603,8 @@ msgstr "Êtes-vous sûr·e de vouloir annuler le tirage au sort ?"
|
|||||||
msgid "Close"
|
msgid "Close"
|
||||||
msgstr "Fermer"
|
msgstr "Fermer"
|
||||||
|
|
||||||
#: draw/views.py:31 participation/views.py:162 participation/views.py:501
|
#: draw/views.py:31 participation/views.py:162 participation/views.py:504
|
||||||
#: participation/views.py:532
|
#: participation/views.py:535
|
||||||
msgid "You are not in a team."
|
msgid "You are not in a team."
|
||||||
msgstr "Vous n'êtes pas dans une équipe."
|
msgstr "Vous n'êtes pas dans une équipe."
|
||||||
|
|
||||||
@ -688,7 +716,7 @@ msgstr "Ce trigramme est déjà utilisé."
|
|||||||
msgid "No team was found with this access code."
|
msgid "No team was found with this access code."
|
||||||
msgstr "Aucune équipe n'a été trouvée avec ce code d'accès."
|
msgstr "Aucune équipe n'a été trouvée avec ce code d'accès."
|
||||||
|
|
||||||
#: participation/forms.py:58 participation/views.py:503
|
#: participation/forms.py:58 participation/views.py:506
|
||||||
msgid "The team is already validated or the validation is pending."
|
msgid "The team is already validated or the validation is pending."
|
||||||
msgstr "La validation de l'équipe est déjà faite ou en cours."
|
msgstr "La validation de l'équipe est déjà faite ou en cours."
|
||||||
|
|
||||||
@ -1868,7 +1896,7 @@ msgid "Invalidate"
|
|||||||
msgstr "Invalider"
|
msgstr "Invalider"
|
||||||
|
|
||||||
#: participation/templates/participation/team_detail.html:237
|
#: participation/templates/participation/team_detail.html:237
|
||||||
#: participation/views.py:333
|
#: participation/views.py:336
|
||||||
msgid "Upload motivation letter"
|
msgid "Upload motivation letter"
|
||||||
msgstr "Envoyer la lettre de motivation"
|
msgstr "Envoyer la lettre de motivation"
|
||||||
|
|
||||||
@ -1877,7 +1905,7 @@ msgid "Update team"
|
|||||||
msgstr "Modifier l'équipe"
|
msgstr "Modifier l'équipe"
|
||||||
|
|
||||||
#: participation/templates/participation/team_detail.html:247
|
#: participation/templates/participation/team_detail.html:247
|
||||||
#: participation/views.py:495
|
#: participation/views.py:498
|
||||||
msgid "Leave team"
|
msgid "Leave team"
|
||||||
msgstr "Quitter l'équipe"
|
msgstr "Quitter l'équipe"
|
||||||
|
|
||||||
@ -2073,7 +2101,7 @@ msgstr "Vous êtes déjà dans une équipe."
|
|||||||
msgid "Join team"
|
msgid "Join team"
|
||||||
msgstr "Rejoindre une équipe"
|
msgstr "Rejoindre une équipe"
|
||||||
|
|
||||||
#: participation/views.py:163 participation/views.py:533
|
#: participation/views.py:163 participation/views.py:536
|
||||||
msgid "You don't participate, so you don't have any team."
|
msgid "You don't participate, so you don't have any team."
|
||||||
msgstr "Vous ne participez pas, vous n'avez donc pas d'équipe."
|
msgstr "Vous ne participez pas, vous n'avez donc pas d'équipe."
|
||||||
|
|
||||||
@ -2109,169 +2137,169 @@ msgstr "Vous n'êtes pas un⋅e organisateur⋅rice du tournoi."
|
|||||||
msgid "This team has no pending validation."
|
msgid "This team has no pending validation."
|
||||||
msgstr "L'équipe n'a pas de validation en attente."
|
msgstr "L'équipe n'a pas de validation en attente."
|
||||||
|
|
||||||
#: participation/views.py:276
|
#: participation/views.py:279
|
||||||
msgid "You must specify if you validate the registration or not."
|
msgid "You must specify if you validate the registration or not."
|
||||||
msgstr "Vous devez spécifier si vous validez l'inscription ou non."
|
msgstr "Vous devez spécifier si vous validez l'inscription ou non."
|
||||||
|
|
||||||
#: participation/views.py:311
|
#: participation/views.py:314
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Update team {trigram}"
|
msgid "Update team {trigram}"
|
||||||
msgstr "Mise à jour de l'équipe {trigram}"
|
msgstr "Mise à jour de l'équipe {trigram}"
|
||||||
|
|
||||||
#: participation/views.py:372 participation/views.py:480
|
#: participation/views.py:375 participation/views.py:483
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Motivation letter of {team}.{ext}"
|
msgid "Motivation letter of {team}.{ext}"
|
||||||
msgstr "Lettre de motivation de {team}.{ext}"
|
msgstr "Lettre de motivation de {team}.{ext}"
|
||||||
|
|
||||||
#: participation/views.py:405
|
#: participation/views.py:408
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Authorizations of team {trigram}.zip"
|
msgid "Authorizations of team {trigram}.zip"
|
||||||
msgstr "Autorisations de l'équipe {trigram}.zip"
|
msgstr "Autorisations de l'équipe {trigram}.zip"
|
||||||
|
|
||||||
#: participation/views.py:409
|
#: participation/views.py:412
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Authorizations of {tournament}.zip"
|
msgid "Authorizations of {tournament}.zip"
|
||||||
msgstr "Autorisations du tournoi {tournament}.zip"
|
msgstr "Autorisations du tournoi {tournament}.zip"
|
||||||
|
|
||||||
#: participation/views.py:428
|
#: participation/views.py:431
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Photo authorization of {participant}.{ext}"
|
msgid "Photo authorization of {participant}.{ext}"
|
||||||
msgstr "Autorisation de droit à l'image de {participant}.{ext}"
|
msgstr "Autorisation de droit à l'image de {participant}.{ext}"
|
||||||
|
|
||||||
#: participation/views.py:437
|
#: participation/views.py:440
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Parental authorization of {participant}.{ext}"
|
msgid "Parental authorization of {participant}.{ext}"
|
||||||
msgstr "Autorisation parentale de {participant}.{ext}"
|
msgstr "Autorisation parentale de {participant}.{ext}"
|
||||||
|
|
||||||
#: participation/views.py:445
|
#: participation/views.py:448
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Health sheet of {participant}.{ext}"
|
msgid "Health sheet of {participant}.{ext}"
|
||||||
msgstr "Fiche sanitaire de {participant}.{ext}"
|
msgstr "Fiche sanitaire de {participant}.{ext}"
|
||||||
|
|
||||||
#: participation/views.py:453
|
#: participation/views.py:456
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Vaccine sheet of {participant}.{ext}"
|
msgid "Vaccine sheet of {participant}.{ext}"
|
||||||
msgstr "Carnet de vaccination de {participant}.{ext}"
|
msgstr "Carnet de vaccination de {participant}.{ext}"
|
||||||
|
|
||||||
#: participation/views.py:464
|
#: participation/views.py:467
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Photo authorization of {participant} (final).{ext}"
|
msgid "Photo authorization of {participant} (final).{ext}"
|
||||||
msgstr "Autorisation de droit à l'image de {participant} (finale).{ext}"
|
msgstr "Autorisation de droit à l'image de {participant} (finale).{ext}"
|
||||||
|
|
||||||
#: participation/views.py:473
|
#: participation/views.py:476
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Parental authorization of {participant} (final).{ext}"
|
msgid "Parental authorization of {participant} (final).{ext}"
|
||||||
msgstr "Autorisation parentale de {participant} (finale).{ext}"
|
msgstr "Autorisation parentale de {participant} (finale).{ext}"
|
||||||
|
|
||||||
#: participation/views.py:547
|
#: participation/views.py:550
|
||||||
msgid "The team is not validated yet."
|
msgid "The team is not validated yet."
|
||||||
msgstr "L'équipe n'est pas encore validée."
|
msgstr "L'équipe n'est pas encore validée."
|
||||||
|
|
||||||
#: participation/views.py:561
|
#: participation/views.py:564
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Participation of team {trigram}"
|
msgid "Participation of team {trigram}"
|
||||||
msgstr "Participation de l'équipe {trigram}"
|
msgstr "Participation de l'équipe {trigram}"
|
||||||
|
|
||||||
#: participation/views.py:649
|
#: participation/views.py:652
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Payments of {tournament}"
|
msgid "Payments of {tournament}"
|
||||||
msgstr "Paiements de {tournament}"
|
msgstr "Paiements de {tournament}"
|
||||||
|
|
||||||
#: participation/views.py:748
|
#: participation/views.py:751
|
||||||
msgid "Notes published!"
|
msgid "Notes published!"
|
||||||
msgstr "Notes publiées !"
|
msgstr "Notes publiées !"
|
||||||
|
|
||||||
#: participation/views.py:750
|
#: participation/views.py:753
|
||||||
msgid "Notes hidden!"
|
msgid "Notes hidden!"
|
||||||
msgstr "Notes dissimulées !"
|
msgstr "Notes dissimulées !"
|
||||||
|
|
||||||
#: participation/views.py:781
|
#: participation/views.py:784
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Harmonize notes of {tournament} - Day {round}"
|
msgid "Harmonize notes of {tournament} - Day {round}"
|
||||||
msgstr "Harmoniser les notes de {tournament} - Jour {round}"
|
msgstr "Harmoniser les notes de {tournament} - Jour {round}"
|
||||||
|
|
||||||
#: participation/views.py:894
|
#: participation/views.py:897
|
||||||
msgid "You can't upload a solution after the deadline."
|
msgid "You can't upload a solution after the deadline."
|
||||||
msgstr "Vous ne pouvez pas envoyer de solution après la date limite."
|
msgstr "Vous ne pouvez pas envoyer de solution après la date limite."
|
||||||
|
|
||||||
#: participation/views.py:1014
|
#: participation/views.py:1017
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Solutions of team {trigram}.zip"
|
msgid "Solutions of team {trigram}.zip"
|
||||||
msgstr "Solutions de l'équipe {trigram}.zip"
|
msgstr "Solutions de l'équipe {trigram}.zip"
|
||||||
|
|
||||||
#: participation/views.py:1014
|
#: participation/views.py:1017
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Syntheses of team {trigram}.zip"
|
msgid "Syntheses of team {trigram}.zip"
|
||||||
msgstr "Notes de synthèse de l'équipe {trigram}.zip"
|
msgstr "Notes de synthèse de l'équipe {trigram}.zip"
|
||||||
|
|
||||||
#: participation/views.py:1031 participation/views.py:1046
|
#: participation/views.py:1034 participation/views.py:1049
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Solutions of {tournament}.zip"
|
msgid "Solutions of {tournament}.zip"
|
||||||
msgstr "Solutions de {tournament}.zip"
|
msgstr "Solutions de {tournament}.zip"
|
||||||
|
|
||||||
#: participation/views.py:1031 participation/views.py:1046
|
#: participation/views.py:1034 participation/views.py:1049
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Syntheses of {tournament}.zip"
|
msgid "Syntheses of {tournament}.zip"
|
||||||
msgstr "Notes de synthèse de {tournament}.zip"
|
msgstr "Notes de synthèse de {tournament}.zip"
|
||||||
|
|
||||||
#: participation/views.py:1055
|
#: participation/views.py:1058
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Solutions for pool {pool} of tournament {tournament}.zip"
|
msgid "Solutions for pool {pool} of tournament {tournament}.zip"
|
||||||
msgstr "Solutions pour la poule {pool} du tournoi {tournament}.zip"
|
msgstr "Solutions pour la poule {pool} du tournoi {tournament}.zip"
|
||||||
|
|
||||||
#: participation/views.py:1056
|
#: participation/views.py:1059
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Syntheses for pool {pool} of tournament {tournament}.zip"
|
msgid "Syntheses for pool {pool} of tournament {tournament}.zip"
|
||||||
msgstr "Notes de synthèses pour la poule {pool} du tournoi {tournament}.zip"
|
msgstr "Notes de synthèses pour la poule {pool} du tournoi {tournament}.zip"
|
||||||
|
|
||||||
#: participation/views.py:1098
|
#: participation/views.py:1101
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Jury of pool {pool} for {tournament} with teams {teams}"
|
msgid "Jury of pool {pool} for {tournament} with teams {teams}"
|
||||||
msgstr "Jury de la poule {pool} pour {tournament} avec les équipes {teams}"
|
msgstr "Jury de la poule {pool} pour {tournament} avec les équipes {teams}"
|
||||||
|
|
||||||
#: participation/views.py:1114
|
#: participation/views.py:1117
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "The jury {name} is already in the pool!"
|
msgid "The jury {name} is already in the pool!"
|
||||||
msgstr "{name} est déjà dans la poule !"
|
msgstr "{name} est déjà dans la poule !"
|
||||||
|
|
||||||
#: participation/views.py:1134
|
#: participation/views.py:1137
|
||||||
msgid "New TFJM² jury account"
|
msgid "New TFJM² jury account"
|
||||||
msgstr "Nouveau compte de juré⋅e pour le TFJM²"
|
msgstr "Nouveau compte de juré⋅e pour le TFJM²"
|
||||||
|
|
||||||
#: participation/views.py:1155
|
#: participation/views.py:1158
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "The jury {name} has been successfully added!"
|
msgid "The jury {name} has been successfully added!"
|
||||||
msgstr "{name} a été ajouté⋅e avec succès en tant que juré⋅e !"
|
msgstr "{name} a été ajouté⋅e avec succès en tant que juré⋅e !"
|
||||||
|
|
||||||
#: participation/views.py:1191
|
#: participation/views.py:1194
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "The jury {name} has been successfully removed!"
|
msgid "The jury {name} has been successfully removed!"
|
||||||
msgstr "{name} a été retiré⋅e avec succès du jury !"
|
msgstr "{name} a été retiré⋅e avec succès du jury !"
|
||||||
|
|
||||||
#: participation/views.py:1217
|
#: participation/views.py:1220
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "The jury {name} has been successfully promoted president!"
|
msgid "The jury {name} has been successfully promoted president!"
|
||||||
msgstr "{name} a été nommé⋅e président⋅e du jury !"
|
msgstr "{name} a été nommé⋅e président⋅e du jury !"
|
||||||
|
|
||||||
#: participation/views.py:1245
|
#: participation/views.py:1248
|
||||||
msgid "The following user is not registered as a jury:"
|
msgid "The following user is not registered as a jury:"
|
||||||
msgstr "L'utilisateur⋅rice suivant n'est pas inscrit⋅e en tant que juré⋅e :"
|
msgstr "L'utilisateur⋅rice suivant n'est pas inscrit⋅e en tant que juré⋅e :"
|
||||||
|
|
||||||
#: participation/views.py:1261
|
#: participation/views.py:1264
|
||||||
msgid "Notes were successfully uploaded."
|
msgid "Notes were successfully uploaded."
|
||||||
msgstr "Les notes ont bien été envoyées."
|
msgstr "Les notes ont bien été envoyées."
|
||||||
|
|
||||||
#: participation/views.py:1839
|
#: participation/views.py:1842
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Notation sheets of pool {pool} of {tournament}.zip"
|
msgid "Notation sheets of pool {pool} of {tournament}.zip"
|
||||||
msgstr "Feuilles de notations pour la poule {pool} du tournoi {tournament}.zip"
|
msgstr "Feuilles de notations pour la poule {pool} du tournoi {tournament}.zip"
|
||||||
|
|
||||||
#: participation/views.py:1844
|
#: participation/views.py:1847
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Notation sheets of {tournament}.zip"
|
msgid "Notation sheets of {tournament}.zip"
|
||||||
msgstr "Feuilles de notation de {tournament}.zip"
|
msgstr "Feuilles de notation de {tournament}.zip"
|
||||||
|
|
||||||
#: participation/views.py:2009
|
#: participation/views.py:2012
|
||||||
msgid "You can't upload a synthesis after the deadline."
|
msgid "You can't upload a synthesis after the deadline."
|
||||||
msgstr "Vous ne pouvez pas envoyer de note de synthèse après la date limite."
|
msgstr "Vous ne pouvez pas envoyer de note de synthèse après la date limite."
|
||||||
|
|
||||||
|
@ -7,10 +7,10 @@
|
|||||||
<div id="form-content">
|
<div id="form-content">
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
{% trans "Templates:" %}
|
{% trans "Templates:" %}
|
||||||
<a class="alert-link" href="{% static "Fiche_synthèse.pdf" %}"> PDF</a> —
|
<a class="alert-link" href="{% static "tfjm/Fiche_synthèse.pdf" %}"> PDF</a> —
|
||||||
<a class="alert-link" href="{% static "Fiche_synthèse.tex" %}"> TEX</a> —
|
<a class="alert-link" href="{% static "tfjm/Fiche_synthèse.tex" %}"> TEX</a> —
|
||||||
<a class="alert-link" href="{% static "Fiche_synthèse.odt" %}"> ODT</a> —
|
<a class="alert-link" href="{% static "tfjm/Fiche_synthèse.odt" %}"> ODT</a> —
|
||||||
<a class="alert-link" href="{% static "Fiche_synthèse.docx" %}" title="{% trans "Warning: non-free format" %}"> DOCX</a>
|
<a class="alert-link" href="{% static "tfjm/Fiche_synthèse.docx" %}" title="{% trans "Warning: non-free format" %}"> DOCX</a>
|
||||||
</div>
|
</div>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form|crispy }}
|
{{ form|crispy }}
|
||||||
|
@ -9,7 +9,7 @@
|
|||||||
<div id="form-content">
|
<div id="form-content">
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
{% trans "Health sheet template:" %}
|
{% trans "Health sheet template:" %}
|
||||||
<a class="alert-link" href="{% static "Fiche_sanitaire.pdf" %}">{% trans "Download" %}</a>
|
<a class="alert-link" href="{% static "tfjm/Fiche_sanitaire.pdf" %}">{% trans "Download" %}</a>
|
||||||
</div>
|
</div>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form|crispy }}
|
{{ form|crispy }}
|
||||||
|
@ -5,14 +5,15 @@ Django>=5.0.3,<6.0
|
|||||||
django-crispy-forms~=2.1
|
django-crispy-forms~=2.1
|
||||||
django-extensions~=3.2.3
|
django-extensions~=3.2.3
|
||||||
django-filter~=23.5
|
django-filter~=23.5
|
||||||
elasticsearch~=7.17.9
|
git+https://github.com/django-haystack/django-haystack.git#v3.3b2
|
||||||
git+https://github.com/django-haystack/django-haystack.git#v3.3b1
|
|
||||||
django-mailer~=2.3.1
|
django-mailer~=2.3.1
|
||||||
django-phonenumber-field~=7.3.0
|
django-phonenumber-field~=7.3.0
|
||||||
|
django-pipeline~=3.1.0
|
||||||
django-polymorphic~=3.1.0
|
django-polymorphic~=3.1.0
|
||||||
django-tables2~=2.7.0
|
django-tables2~=2.7.0
|
||||||
djangorestframework~=3.14.0
|
djangorestframework~=3.14.0
|
||||||
django-rest-polymorphic~=0.1.10
|
django-rest-polymorphic~=0.1.10
|
||||||
|
elasticsearch~=7.17.9
|
||||||
gspread~=6.1.0
|
gspread~=6.1.0
|
||||||
gunicorn~=21.2.0
|
gunicorn~=21.2.0
|
||||||
odfpy~=1.4.1
|
odfpy~=1.4.1
|
||||||
|
@ -7,10 +7,10 @@ Django settings for tfjm project.
|
|||||||
Generated by 'django-admin startproject' using Django 3.0.5.
|
Generated by 'django-admin startproject' using Django 3.0.5.
|
||||||
|
|
||||||
For more information on this file, see
|
For more information on this file, see
|
||||||
https://docs.djangoproject.com/en/3.0/topics/settings/
|
https://docs.djangoproject.com/en/5.0/topics/settings/
|
||||||
|
|
||||||
For the full list of settings and their values, see
|
For the full list of settings and their values, see
|
||||||
https://docs.djangoproject.com/en/3.0/ref/settings/
|
https://docs.djangoproject.com/en/5.0/ref/settings/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@ -25,7 +25,7 @@ PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
|
|||||||
ADMINS = [("Emmy D'Anello", "emmy.danello@animath.fr")]
|
ADMINS = [("Emmy D'Anello", "emmy.danello@animath.fr")]
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
# Quick-start development settings - unsuitable for production
|
||||||
# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'CHANGE_ME_IN_ENV_SETTINGS')
|
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'CHANGE_ME_IN_ENV_SETTINGS')
|
||||||
@ -63,6 +63,7 @@ INSTALLED_APPS = [
|
|||||||
'haystack',
|
'haystack',
|
||||||
'logs',
|
'logs',
|
||||||
'phonenumber_field',
|
'phonenumber_field',
|
||||||
|
'pipeline',
|
||||||
'polymorphic',
|
'polymorphic',
|
||||||
'rest_framework',
|
'rest_framework',
|
||||||
'rest_framework.authtoken',
|
'rest_framework.authtoken',
|
||||||
@ -95,6 +96,8 @@ MIDDLEWARE = [
|
|||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
'django.middleware.locale.LocaleMiddleware',
|
'django.middleware.locale.LocaleMiddleware',
|
||||||
'django.contrib.sites.middleware.CurrentSiteMiddleware',
|
'django.contrib.sites.middleware.CurrentSiteMiddleware',
|
||||||
|
'django.middleware.gzip.GZipMiddleware',
|
||||||
|
'pipeline.middleware.MinifyHTMLMiddleware',
|
||||||
'tfjm.middlewares.SessionMiddleware',
|
'tfjm.middlewares.SessionMiddleware',
|
||||||
'tfjm.middlewares.FetchMiddleware',
|
'tfjm.middlewares.FetchMiddleware',
|
||||||
]
|
]
|
||||||
@ -126,7 +129,7 @@ ASGI_APPLICATION = 'tfjm.asgi.application'
|
|||||||
WSGI_APPLICATION = 'tfjm.wsgi.application'
|
WSGI_APPLICATION = 'tfjm.wsgi.application'
|
||||||
|
|
||||||
# Password validation
|
# Password validation
|
||||||
# https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators
|
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
{
|
{
|
||||||
@ -161,7 +164,7 @@ REST_FRAMEWORK = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Internationalization
|
# Internationalization
|
||||||
# https://docs.djangoproject.com/en/3.0/topics/i18n/
|
# https://docs.djangoproject.com/en/5.0/topics/i18n/
|
||||||
|
|
||||||
LANGUAGE_CODE = 'en'
|
LANGUAGE_CODE = 'en'
|
||||||
|
|
||||||
@ -181,7 +184,7 @@ USE_TZ = True
|
|||||||
LOCALE_PATHS = [os.path.join(BASE_DIR, "locale")]
|
LOCALE_PATHS = [os.path.join(BASE_DIR, "locale")]
|
||||||
|
|
||||||
# Static files (CSS, JavaScript, Images)
|
# Static files (CSS, JavaScript, Images)
|
||||||
# https://docs.djangoproject.com/en/3.0/howto/static-files/
|
# https://docs.djangoproject.com/en/5.0/howto/static-files/
|
||||||
|
|
||||||
STATIC_URL = '/static/'
|
STATIC_URL = '/static/'
|
||||||
|
|
||||||
@ -191,6 +194,70 @@ STATICFILES_DIRS = [
|
|||||||
|
|
||||||
STATIC_ROOT = os.path.join(BASE_DIR, "static")
|
STATIC_ROOT = os.path.join(BASE_DIR, "static")
|
||||||
|
|
||||||
|
STATICFILES_STORAGE = 'pipeline.storage.PipelineStorage'
|
||||||
|
|
||||||
|
STATICFILES_FINDERS = (
|
||||||
|
'django.contrib.staticfiles.finders.FileSystemFinder',
|
||||||
|
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
|
||||||
|
'pipeline.finders.PipelineFinder',
|
||||||
|
)
|
||||||
|
|
||||||
|
PIPELINE = {
|
||||||
|
'DISABLE_WRAPPER': True,
|
||||||
|
'JAVASCRIPT': {
|
||||||
|
'bootstrap': {
|
||||||
|
'source_filenames': {
|
||||||
|
'bootstrap/js/bootstrap.bundle.min.js',
|
||||||
|
},
|
||||||
|
'output_filename': 'tfjm/js/bootstrap.bundle.min.js',
|
||||||
|
},
|
||||||
|
'bootstrap_select': {
|
||||||
|
'source_filenames': {
|
||||||
|
'jquery/jquery.min.js',
|
||||||
|
'bootstrap-select/js/bootstrap-select.min.js',
|
||||||
|
'bootstrap-select/js/defaults-fr_FR.min.js',
|
||||||
|
},
|
||||||
|
'output_filename': 'tfjm/js/bootstrap-select-jquery.min.js',
|
||||||
|
},
|
||||||
|
'main': {
|
||||||
|
'source_filenames': (
|
||||||
|
'tfjm/js/main.js',
|
||||||
|
'tfjm/js/theme.js',
|
||||||
|
),
|
||||||
|
'output_filename': 'tfjm/js/main.min.js',
|
||||||
|
},
|
||||||
|
'theme': {
|
||||||
|
'source_filenames': (
|
||||||
|
'tfjm/js/theme.js',
|
||||||
|
),
|
||||||
|
'output_filename': 'tfjm/js/theme.min.js',
|
||||||
|
},
|
||||||
|
'chat': {
|
||||||
|
'source_filenames': (
|
||||||
|
'tfjm/js/chat.js',
|
||||||
|
),
|
||||||
|
'output_filename': 'tfjm/js/chat.min.js',
|
||||||
|
},
|
||||||
|
'draw': {
|
||||||
|
'source_filenames': (
|
||||||
|
'tfjm/js/draw.js',
|
||||||
|
),
|
||||||
|
'output_filename': 'tfjm/js/draw.min.js',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'STYLESHEETS': {
|
||||||
|
'bootstrap_fontawesome': {
|
||||||
|
'source_filenames': (
|
||||||
|
'bootstrap/css/bootstrap.min.css',
|
||||||
|
'fontawesome/css/all.css',
|
||||||
|
'fontawesome/css/v4-shims.css',
|
||||||
|
'bootstrap-select/css/bootstrap-select.min.css',
|
||||||
|
),
|
||||||
|
'output_filename': 'tfjm/css/bootstrap_fontawesome.min.css',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
MEDIA_URL = '/media/'
|
MEDIA_URL = '/media/'
|
||||||
|
|
||||||
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
|
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
|
||||||
|
@ -7,7 +7,7 @@ import os
|
|||||||
DEBUG = False
|
DEBUG = False
|
||||||
|
|
||||||
# Mandatory !
|
# Mandatory !
|
||||||
ALLOWED_HOSTS = ['inscription.tfjm.org', 'plateforme.tfjm.org']
|
ALLOWED_HOSTS = ['inscription.tfjm.org', 'inscriptions.tfjm.org', 'plateforme.tfjm.org']
|
||||||
|
|
||||||
# Emails
|
# Emails
|
||||||
EMAIL_BACKEND = 'mailer.backend.DbBackend'
|
EMAIL_BACKEND = 'mailer.backend.DbBackend'
|
||||||
|
Before Width: | Height: | Size: 428 KiB After Width: | Height: | Size: 428 KiB |
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 7.1 KiB |
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.2 KiB |
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
@ -1,4 +1,6 @@
|
|||||||
{% load i18n static %}
|
{% load i18n %}
|
||||||
|
{% load pipeline %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
{% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %}
|
{% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %}
|
||||||
@ -16,19 +18,12 @@
|
|||||||
<meta name="theme-color" content="#ffffff">
|
<meta name="theme-color" content="#ffffff">
|
||||||
|
|
||||||
{# Bootstrap CSS #}
|
{# Bootstrap CSS #}
|
||||||
<link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
|
{% stylesheet 'bootstrap_fontawesome' %}
|
||||||
<link rel="stylesheet" href="{% static 'fontawesome/css/all.css' %}">
|
|
||||||
<link rel="stylesheet" href="{% static 'fontawesome/css/v4-shims.css' %}">
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="{% static 'bootstrap-select/css/bootstrap-select.min.css' %}">
|
|
||||||
|
|
||||||
{# Bootstrap JavaScript #}
|
{# Bootstrap JavaScript #}
|
||||||
<script src="{% static 'bootstrap/js/bootstrap.bundle.min.js' %}"></script>
|
{% javascript 'bootstrap' %}
|
||||||
|
|
||||||
{# bootstrap-select for beautiful selects and JQuery dependency #}
|
{# bootstrap-select for beautiful selects and JQuery dependency #}
|
||||||
<script src="{% static 'jquery/jquery.min.js' %}"></script>
|
{% javascript 'bootstrap_select' %}
|
||||||
<script src="{% static 'bootstrap-select/js/bootstrap-select.min.js' %}"></script>
|
|
||||||
<script src="{% static 'bootstrap-select/js/defaults-fr_FR.min.js' %}"></script>
|
|
||||||
|
|
||||||
{# Si un formulaire requiert des données supplémentaires (notamment JS), les données sont chargées #}
|
{# Si un formulaire requiert des données supplémentaires (notamment JS), les données sont chargées #}
|
||||||
{% if form.media %}
|
{% if form.media %}
|
||||||
@ -87,8 +82,7 @@
|
|||||||
{% include "base_modal.html" with modal_id="login" %}
|
{% include "base_modal.html" with modal_id="login" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<script src="{% static 'main.js' %}"></script>
|
{% javascript 'main' %}
|
||||||
<script src="{% static 'theme.js' %}"></script>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
CSRF_TOKEN = "{{ csrf_token }}";
|
CSRF_TOKEN = "{{ csrf_token }}";
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
<nav class="navbar navbar-expand-lg fixed-navbar shadow-sm">
|
<nav class="navbar navbar-expand-lg fixed-navbar shadow-sm">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<a class="navbar-brand" href="https://tfjm.org/">
|
<a class="navbar-brand" href="https://tfjm.org/">
|
||||||
<img src="{% static "tfjm.svg" %}" style="height: 2em;" alt="Logo TFJM²" id="navbar-logo">
|
<img src="{% static "tfjm/img/tfjm.svg" %}" style="height: 2em;" alt="Logo TFJM²" id="navbar-logo">
|
||||||
</a>
|
</a>
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
|
||||||
data-bs-target="#navbarNavDropdown"
|
data-bs-target="#navbarNavDropdown"
|
||||||
|