Compare commits

...

3 Commits

Author SHA1 Message Date
Flatlogic Bot
4cac267951 Autosave: 20260408-203927 2026-04-08 20:39:27 +00:00
Flatlogic Bot
3b03cdb4a1 Autosave: 20260405-222200 2026-04-05 22:22:00 +00:00
Flatlogic Bot
db8313c88e V2 2026-04-05 20:23:53 +00:00
71 changed files with 6158 additions and 166 deletions

View File

@ -0,0 +1,31 @@
RJLResaka - version finale Java JEE
===================================
1) Ouvrir MySQL Workbench puis exécuter le script: database/rjlresaka.sql
2) Dans Eclipse EE, importer le dossier RJLResaka comme Dynamic Web Project
3) Ajouter dans WEB-INF/lib les JAR suivants:
- mysql-connector-j-8.x.x.jar
- jstl-1.2.jar
- standard-1.1.2.jar (si votre distribution JSTL l'exige)
- jbcrypt-0.4.jar
4) Vérifier WebContent/WEB-INF/web.xml:
- db.url
- db.user
- db.password
5) Déployer sur Tomcat 9
6) Ouvrir /RJLResaka/home
Fonctionnalités livrées:
- Inscription / connexion
- Mot de passe oublié / réinitialisation par token
- Liste des utilisateurs
- Chat privé
- Modifier / supprimer un message
- Upload image / fichier
- Téléchargement des pièces jointes
- Réactions emoji simples
- Interface claire type Facebook / Messenger
Remarque logo:
- Le fichier assets/img/logo_resaka.png est un logo provisoire généré dans le projet.
- Si vous avez votre vrai logo, remplacez simplement ce fichier par votre version.

View File

@ -0,0 +1,37 @@
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mot de passe oublié | RJLResaka</title>
<meta name="description" content="Générez un lien de réinitialisation du mot de passe pour votre compte RJLResaka.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="${pageContext.request.contextPath}/assets/css/app.css">
</head>
<body class="auth-body">
<main class="auth-shell branded">
<section class="auth-panel glass">
<div class="auth-brand">
<img src="${pageContext.request.contextPath}/assets/img/logo_resaka.png" alt="Logo RJLResaka" width="56" height="56">
<div><strong>RJLResaka</strong><span>Réinitialisation</span></div>
</div>
<a class="ghost-link" href="${pageContext.request.contextPath}/login">← Retour</a>
<span class="hero-badge">Réinitialisation</span>
<h1>Mot de passe oublié</h1>
<p class="auth-copy">Entrez votre email. Pour l'instant, le lien est affiché à l'écran; ensuite vous pourrez brancher JavaMail.</p>
<% if (request.getAttribute("error") != null) { %><div class="alert error"><%= request.getAttribute("error") %></div><% } %>
<% if (request.getAttribute("success") != null) { %><div class="alert success"><%= request.getAttribute("success") %></div><% } %>
<form action="${pageContext.request.contextPath}/forgot-password" method="post" class="stack-form">
<label><span>Email</span><input type="email" name="email" required></label>
<button class="button primary" type="submit">Générer le lien</button>
</form>
<% if (request.getAttribute("resetLink") != null) { %><div class="token-box"><strong>Lien généré :</strong><br><span><%= request.getAttribute("resetLink") %></span><br><br><strong>Token :</strong> <%= request.getAttribute("generatedToken") %></div><% } %>
<% if (request.getAttribute("debugMessage") != null) { %><p class="debug-note">Détail technique: <%= request.getAttribute("debugMessage") %></p><% } %>
</section>
</main>
<script src="${pageContext.request.contextPath}/assets/js/app.js"></script>
</body>
</html>

View File

@ -0,0 +1,41 @@
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Connexion | RJLResaka</title>
<meta name="description" content="Connectez-vous à RJLResaka pour accéder à vos conversations privées.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="${pageContext.request.contextPath}/assets/css/app.css">
</head>
<body class="auth-body">
<main class="auth-shell branded">
<section class="auth-panel glass">
<div class="auth-brand">
<img src="${pageContext.request.contextPath}/assets/img/logo_resaka.png" alt="Logo RJLResaka" width="56" height="56">
<div>
<strong>RJLResaka</strong>
<span>Connexion sécurisée</span>
</div>
</div>
<a class="ghost-link" href="${pageContext.request.contextPath}/home">← Retour</a>
<span class="hero-badge">Connexion</span>
<h1>Bon retour sur RJLResaka</h1>
<p class="auth-copy">Utilisez votre email ou votre nom d'utilisateur pour continuer.</p>
<% if (request.getAttribute("error") != null) { %><div class="alert error"><%= request.getAttribute("error") %></div><% } %>
<% if (request.getAttribute("success") != null) { %><div class="alert success"><%= request.getAttribute("success") %></div><% } %>
<form action="${pageContext.request.contextPath}/login" method="post" class="stack-form">
<label><span>Email ou username</span><input type="text" name="identity" placeholder="ex: demo@rjlresaka.app" required></label>
<label><span>Mot de passe</span><input type="password" name="password" placeholder="••••••••" required></label>
<button class="button primary" type="submit">Se connecter</button>
</form>
<div class="auth-links"><a href="${pageContext.request.contextPath}/forgot-password">Mot de passe oublié ?</a><a href="${pageContext.request.contextPath}/register">Créer un compte</a></div>
<% if (request.getAttribute("debugMessage") != null) { %><p class="debug-note">Détail technique: <%= request.getAttribute("debugMessage") %></p><% } %>
</section>
</main>
<script src="${pageContext.request.contextPath}/assets/js/app.js"></script>
</body>
</html>

View File

@ -0,0 +1,40 @@
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Inscription | RJLResaka</title>
<meta name="description" content="Créez votre compte RJLResaka pour discuter avec les autres utilisateurs.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="${pageContext.request.contextPath}/assets/css/app.css">
</head>
<body class="auth-body">
<main class="auth-shell branded">
<section class="auth-panel wide glass">
<div class="auth-brand">
<img src="${pageContext.request.contextPath}/assets/img/logo_resaka.png" alt="Logo RJLResaka" width="56" height="56">
<div><strong>RJLResaka</strong><span>Créer un compte</span></div>
</div>
<a class="ghost-link" href="${pageContext.request.contextPath}/home">← Retour</a>
<span class="hero-badge">Inscription</span>
<h1>Créez votre compte</h1>
<p class="auth-copy">Une seule inscription suffit pour commencer vos discussions privées.</p>
<% if (request.getAttribute("error") != null) { %><div class="alert error"><%= request.getAttribute("error") %></div><% } %>
<form action="${pageContext.request.contextPath}/register" method="post" class="stack-form two-col">
<label><span>Nom complet</span><input type="text" name="fullName" required></label>
<label><span>Nom d'utilisateur</span><input type="text" name="username" required></label>
<label><span>Email</span><input type="email" name="email" required></label>
<label><span>Mot de passe</span><input type="password" name="password" required></label>
<label><span>Confirmer le mot de passe</span><input type="password" name="confirmPassword" required></label>
<div class="button-row"><button class="button primary" type="submit">Créer le compte</button></div>
</form>
<div class="auth-links left"><a href="${pageContext.request.contextPath}/login">J'ai déjà un compte</a></div>
<% if (request.getAttribute("debugMessage") != null) { %><p class="debug-note">Détail technique: <%= request.getAttribute("debugMessage") %></p><% } %>
</section>
</main>
<script src="${pageContext.request.contextPath}/assets/js/app.js"></script>
</body>
</html>

View File

@ -0,0 +1,38 @@
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<% String token = request.getParameter("token"); if (request.getAttribute("token") != null) { token = (String) request.getAttribute("token"); } %>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nouveau mot de passe | RJLResaka</title>
<meta name="description" content="Choisissez un nouveau mot de passe pour votre compte RJLResaka.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="${pageContext.request.contextPath}/assets/css/app.css">
</head>
<body class="auth-body">
<main class="auth-shell branded">
<section class="auth-panel glass">
<div class="auth-brand">
<img src="${pageContext.request.contextPath}/assets/img/logo_resaka.png" alt="Logo RJLResaka" width="56" height="56">
<div><strong>RJLResaka</strong><span>Nouveau mot de passe</span></div>
</div>
<a class="ghost-link" href="${pageContext.request.contextPath}/login">← Retour</a>
<span class="hero-badge">Nouveau mot de passe</span>
<h1>Réinitialisez votre mot de passe</h1>
<p class="auth-copy">Collez le token reçu ou ouvrez directement le lien généré.</p>
<% if (request.getAttribute("error") != null) { %><div class="alert error"><%= request.getAttribute("error") %></div><% } %>
<form action="${pageContext.request.contextPath}/reset-password" method="post" class="stack-form">
<label><span>Token</span><input type="text" name="token" value="<%= token == null ? "" : token %>" required></label>
<label><span>Nouveau mot de passe</span><input type="password" name="password" required></label>
<label><span>Confirmer le mot de passe</span><input type="password" name="confirmPassword" required></label>
<button class="button primary" type="submit">Mettre à jour</button>
</form>
<% if (request.getAttribute("debugMessage") != null) { %><p class="debug-note">Détail technique: <%= request.getAttribute("debugMessage") %></p><% } %>
</section>
</main>
<script src="${pageContext.request.contextPath}/assets/js/app.js"></script>
</body>
</html>

View File

@ -0,0 +1,375 @@
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>
<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Messenger Facebook Style | RJLResaka</title>
<meta name="description" content="RJLResaka : discussions privées, groupes, demandes d'amis, réactions et fichiers avec un design clair inspiré de Facebook Messenger.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="${pageContext.request.contextPath}/assets/css/app.css">
</head>
<body class="dashboard-page fb-body">
<main class="fb-dashboard-layout">
<aside class="fb-left-column">
<section class="fb-card profile-card facebook-card">
<div class="fb-profile-row">
<div class="avatar xl" style="background:${sessionScope.authUser.avatarColor}">${sessionScope.authUser.initials}</div>
<div>
<p class="eyebrow">Compte connecté</p>
<h1>${sessionScope.authUser.fullName}</h1>
<p class="muted">@${sessionScope.authUser.username}</p>
<small>${sessionScope.authUser.email}</small>
</div>
</div>
<div class="stats-row">
<div>
<strong>${friendCount}</strong>
<span>Amis</span>
</div>
<div>
<strong>${requestCount}</strong>
<span>Demandes</span>
</div>
<div>
<strong>${fn:length(conversations)}</strong>
<span>Discussions</span>
</div>
</div>
<a class="ghost-link strong" href="${pageContext.request.contextPath}/logout">Déconnexion</a>
</section>
<section class="fb-card facebook-card">
<div class="section-heading">
<div>
<p class="eyebrow">Créer un groupe</p>
<h2>Style Messenger</h2>
</div>
<span class="pill soft">${groupCandidateCount} ami(s)</span>
</div>
<form action="${pageContext.request.contextPath}/app/groups/create" method="post" class="stack-form compact-form">
<label>
<span>Nom du groupe</span>
<input type="text" name="groupName" placeholder="Ex: Projet final GL">
</label>
<div class="checkbox-grid">
<c:forEach items="${friends}" var="friend">
<label class="check-chip">
<input type="checkbox" name="memberIds" value="${friend.id}">
<span>${friend.fullName}</span>
</label>
</c:forEach>
<c:if test="${empty friends}">
<p class="empty-mini">Ajoutez d'abord des amis pour créer un groupe.</p>
</c:if>
</div>
<button class="button primary full" type="submit">Créer le groupe</button>
</form>
</section>
<section class="fb-card facebook-card">
<div class="section-heading">
<div>
<p class="eyebrow">Demandes reçues</p>
<h2>Amis</h2>
</div>
</div>
<div class="request-list">
<c:forEach items="${pendingRequests}" var="invite">
<article class="friend-row">
<div class="avatar large" style="background:${invite.senderAvatarColor}">${invite.senderInitials}</div>
<div class="friend-copy">
<strong>${invite.senderName}</strong>
<small>@${invite.senderUsername}</small>
</div>
<div class="friend-actions split">
<form action="${pageContext.request.contextPath}/app/friends/respond" method="post">
<input type="hidden" name="requestId" value="${invite.id}">
<input type="hidden" name="action" value="accept">
<button class="button tiny primary" type="submit">Accepter</button>
</form>
<form action="${pageContext.request.contextPath}/app/friends/respond" method="post">
<input type="hidden" name="requestId" value="${invite.id}">
<input type="hidden" name="action" value="decline">
<button class="button tiny secondary" type="submit">Refuser</button>
</form>
</div>
</article>
</c:forEach>
<c:if test="${empty pendingRequests}">
<p class="empty-mini">Aucune demande pour le moment.</p>
</c:if>
</div>
</section>
</aside>
<section class="fb-center-column">
<c:if test="${not empty success}">
<div class="alert success floating">${success}</div>
</c:if>
<c:if test="${not empty error}">
<div class="alert error floating">${error}</div>
</c:if>
<c:choose>
<c:when test="${not empty activeConversation}">
<section class="fb-card messenger-shell-card">
<header class="chat-topbar fb-chat-topbar">
<div class="chat-partner">
<div class="avatar xl" style="background:${activeConversation.avatarColor}">${activeConversation.initials}</div>
<div>
<div class="chat-title-row">
<h2>${activeConversation.title}</h2>
<c:if test="${activeConversation.group}">
<span class="pill">Groupe</span>
</c:if>
<c:if test="${not activeConversation.group}">
<span class="pill soft">Privé</span>
</c:if>
</div>
<p>${activeConversation.subtitle}</p>
</div>
</div>
<div class="chat-top-actions">
<span class="pill soft">Thème clair Facebook</span>
<span class="pill soft">Fichiers + réactions</span>
</div>
</header>
<section class="messages-stream fb-stream" id="messageStream">
<c:if test="${empty messages}">
<article class="empty-chat large-empty">
<div class="empty-3d-shape"></div>
<h3>Conversation prête</h3>
<p>Envoyez votre premier message dans ${activeConversation.title}.</p>
</article>
</c:if>
<c:forEach items="${messages}" var="message">
<article class="message-row ${message.mine ? 'mine' : 'other'}">
<c:if test="${not message.mine}">
<div class="avatar small" style="background:${message.senderAvatarColor}">${message.senderInitials}</div>
</c:if>
<div class="message-stack">
<div class="message-meta-top">
<c:if test="${activeConversation.group && not message.mine}">
<strong>${message.senderName}</strong>
</c:if>
<small><fmt:formatDate value="${message.createdAt}" pattern="dd/MM HH:mm"/></small>
</div>
<div class="message-bubble ${message.mine ? 'mine' : 'other'} ${message.deleted ? 'deleted' : ''}">
<c:choose>
<c:when test="${message.deleted}">
<p class="deleted-copy">Ce message a été supprimé.</p>
</c:when>
<c:otherwise>
<c:if test="${not empty message.body}">
<p class="message-text">${message.body}</p>
</c:if>
<c:if test="${not empty message.attachmentName}">
<a class="attachment-card" href="${pageContext.request.contextPath}/app/files/download?messageId=${message.id}">
<span>📎 ${message.attachmentName}</span>
<small>Télécharger</small>
</a>
</c:if>
<c:if test="${message.edited}">
<small class="edited-note">Modifié</small>
</c:if>
</c:otherwise>
</c:choose>
</div>
<div class="message-toolbar">
<div class="reactions-row">
<c:forEach items="${message.reactions}" var="reaction">
<form action="${pageContext.request.contextPath}/app/messages/react" method="post" class="inline-form">
<input type="hidden" name="conversationId" value="${activeConversation.id}">
<input type="hidden" name="messageId" value="${message.id}">
<input type="hidden" name="emoji" value="${reaction.emoji}">
<button class="reaction-pill ${reaction.reactedByCurrentUser ? 'active' : ''}" type="submit">${reaction.emoji} ${reaction.count}</button>
</form>
</c:forEach>
<form action="${pageContext.request.contextPath}/app/messages/react" method="post" class="inline-form">
<input type="hidden" name="conversationId" value="${activeConversation.id}">
<input type="hidden" name="messageId" value="${message.id}">
<input type="hidden" name="emoji" value="👍">
<button class="emoji-trigger" type="submit">👍</button>
</form>
<form action="${pageContext.request.contextPath}/app/messages/react" method="post" class="inline-form">
<input type="hidden" name="conversationId" value="${activeConversation.id}">
<input type="hidden" name="messageId" value="${message.id}">
<input type="hidden" name="emoji" value="❤️">
<button class="emoji-trigger" type="submit">❤️</button>
</form>
<form action="${pageContext.request.contextPath}/app/messages/react" method="post" class="inline-form">
<input type="hidden" name="conversationId" value="${activeConversation.id}">
<input type="hidden" name="messageId" value="${message.id}">
<input type="hidden" name="emoji" value="😂">
<button class="emoji-trigger" type="submit">😂</button>
</form>
</div>
<c:if test="${message.mine && not message.deleted}">
<div class="message-actions">
<button class="ghost-link mini edit-toggle" type="button" data-target="edit-${message.id}">Modifier</button>
<form action="${pageContext.request.contextPath}/app/messages/delete" method="post" class="inline-form">
<input type="hidden" name="conversationId" value="${activeConversation.id}">
<input type="hidden" name="messageId" value="${message.id}">
<button class="ghost-link danger mini" type="submit">Supprimer</button>
</form>
</div>
</c:if>
</div>
<c:if test="${message.mine && not message.deleted}">
<form id="edit-${message.id}" action="${pageContext.request.contextPath}/app/messages/update" method="post" class="edit-form hidden">
<input type="hidden" name="conversationId" value="${activeConversation.id}">
<input type="hidden" name="messageId" value="${message.id}">
<textarea name="body" rows="3" required>${message.body}</textarea>
<div class="edit-actions">
<button class="button tiny primary" type="submit">Enregistrer</button>
<button class="button tiny secondary edit-cancel" type="button" data-target="edit-${message.id}">Annuler</button>
</div>
</form>
</c:if>
</div>
</article>
</c:forEach>
</section>
<form action="${pageContext.request.contextPath}/app/messages/send" method="post" enctype="multipart/form-data" class="composer fb-composer">
<input type="hidden" name="conversationId" value="${activeConversation.id}">
<textarea name="body" rows="3" placeholder="Écrivez un message comme sur Messenger..."></textarea>
<div class="composer-actions">
<label class="file-label">
<input type="file" name="attachment" accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip">
<span>Ajouter image ou fichier</span>
</label>
<button class="button primary" type="submit">Envoyer</button>
</div>
</form>
</section>
</c:when>
<c:otherwise>
<section class="fb-card empty-chat large-empty messenger-shell-card">
<div class="empty-3d-shape"></div>
<h2>Bienvenue sur votre Messenger</h2>
<p>Ajoutez des amis, acceptez des demandes ou créez un groupe pour commencer.</p>
</section>
</c:otherwise>
</c:choose>
<c:if test="${not empty debugMessage}">
<p class="debug-note">Détail technique: ${debugMessage}</p>
</c:if>
</section>
<aside class="fb-right-column">
<section class="fb-card facebook-card">
<div class="section-heading">
<div>
<p class="eyebrow">Discussions</p>
<h2>Conversations récentes</h2>
</div>
</div>
<div class="conversation-list">
<c:forEach items="${conversations}" var="conversation">
<a class="conversation-item ${conversation.active ? 'active' : ''}" href="${pageContext.request.contextPath}/app/dashboard?conversation=${conversation.id}">
<div class="avatar large" style="background:${conversation.avatarColor}">${conversation.initials}</div>
<div class="conversation-copy">
<div class="conversation-topline">
<strong>${conversation.title}</strong>
<c:if test="${conversation.lastMessageAt != null}">
<small><fmt:formatDate value="${conversation.lastMessageAt}" pattern="HH:mm"/></small>
</c:if>
</div>
<p>${conversation.lastMessagePreview}</p>
</div>
<c:if test="${conversation.unreadCount > 0}">
<span class="unread-badge">${conversation.unreadCount}</span>
</c:if>
</a>
</c:forEach>
<c:if test="${empty conversations}">
<p class="empty-mini">Aucune conversation pour le moment.</p>
</c:if>
</div>
</section>
<section class="fb-card facebook-card">
<div class="section-heading">
<div>
<p class="eyebrow">Découvrir</p>
<h2>Envoyer des demandes</h2>
</div>
</div>
<div class="request-list">
<c:forEach items="${people}" var="person">
<article class="friend-row stacked-mobile">
<div class="avatar large" style="background:${person.avatarColor}">${person.initials}</div>
<div class="friend-copy grow">
<strong>${person.fullName}</strong>
<small>@${person.username}</small>
<c:if test="${not empty person.bio}">
<p>${person.bio}</p>
</c:if>
</div>
<div class="friend-actions vertical">
<c:choose>
<c:when test="${person.requestReceived}">
<span class="status-chip waiting">Vous a envoyé une demande</span>
</c:when>
<c:when test="${person.requestSent}">
<span class="status-chip sent">Demande envoyée</span>
</c:when>
<c:otherwise>
<form action="${pageContext.request.contextPath}/app/friends/request" method="post">
<input type="hidden" name="receiverId" value="${person.id}">
<button class="button tiny secondary" type="submit">Ajouter</button>
</form>
</c:otherwise>
</c:choose>
</div>
</article>
</c:forEach>
<c:if test="${empty people}">
<p class="empty-mini">Tous les autres comptes sont déjà vos amis ou en attente.</p>
</c:if>
</div>
</section>
<section class="fb-card facebook-card">
<div class="section-heading">
<div>
<p class="eyebrow">Amis</p>
<h2>Lancer une discussion</h2>
</div>
</div>
<div class="request-list">
<c:forEach items="${friends}" var="friend">
<article class="friend-row">
<div class="avatar large" style="background:${friend.avatarColor}">${friend.initials}</div>
<div class="friend-copy">
<strong>${friend.fullName}</strong>
<small>@${friend.username}</small>
</div>
<a class="button tiny primary quick-open-form" href="${pageContext.request.contextPath}/app/dashboard?user=${friend.id}">Ouvrir</a>
</article>
</c:forEach>
<c:if test="${empty friends}">
<p class="empty-mini">Acceptez une demande ou ajoutez un ami pour ouvrir un chat privé.</p>
</c:if>
</div>
</section>
</aside>
</main>
<script src="${pageContext.request.contextPath}/assets/js/app.js"></script>
</body>
</html>

View File

@ -0,0 +1,73 @@
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RJLResaka | Chat privé et groupes style Facebook</title>
<meta name="description" content="RJLResaka est une application de discussion Java JEE inspirée de Facebook Messenger avec demandes d'amis, groupes, pièces jointes et réactions.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="${pageContext.request.contextPath}/assets/css/app.css">
</head>
<body>
<main class="landing-shell">
<section class="landing-topbar">
<a class="brand" href="${pageContext.request.contextPath}/home">
<img src="${pageContext.request.contextPath}/assets/img/logo_resaka.png" alt="Logo RJLResaka" width="56" height="56">
<div>
<strong>RJLResaka</strong>
<span>Java JEE • Tomcat 9 • MySQL Workbench</span>
</div>
</a>
<div class="hero-actions compact">
<a class="button secondary" href="${pageContext.request.contextPath}/login">Connexion</a>
<a class="button primary" href="${pageContext.request.contextPath}/register">Créer un compte</a>
</div>
</section>
<section class="hero-card hero-grid">
<div>
<span class="hero-badge">Version finale Facebook-like</span>
<h1 class="hero-title">Une messagerie claire comme Facebook, avec amis, groupes et chat en temps réel côté UI.</h1>
<p class="hero-copy">
Votre projet final RJLResaka comprend maintenant l'inscription, la connexion, le mot de passe oublié,
les conversations privées, les groupes de discussion, les demandes d'amis, les réactions,
l'envoi de pièces jointes et une interface blanche inspirée de Facebook Messenger.
</p>
<div class="hero-actions">
<a class="button primary" href="${pageContext.request.contextPath}/register">Commencer</a>
<a class="button secondary" href="${pageContext.request.contextPath}/login">J'ai déjà un compte</a>
</div>
<ul class="hero-list">
<li>Demandes d'amis et acceptation/refus</li>
<li>Création de groupes avec vos amis</li>
<li>Design clair moderne type Facebook/Messenger</li>
</ul>
</div>
<aside class="preview-card messenger-preview">
<div class="preview-window fb-window">
<div class="preview-topbar">
<span></span><span></span><span></span>
</div>
<div class="preview-chat advanced">
<div class="preview-sidebar">
<div class="preview-contact active">AM</div>
<div class="preview-contact">JR</div>
<div class="preview-contact">GR</div>
</div>
<div class="preview-conversation">
<div class="preview-message incoming">Salut, tu as vu la demande d'ami ?</div>
<div class="preview-message outgoing">Oui 👍 et j'ai créé le groupe du projet.</div>
<div class="preview-attachment">📎 cahier-des-charges.pdf</div>
<div class="preview-reactions">❤️ 3&nbsp;&nbsp;😂 1</div>
</div>
</div>
</div>
</aside>
</section>
</main>
<script src="${pageContext.request.contextPath}/assets/js/app.js"></script>
</body>
</html>

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0">
<display-name>RJLResaka</display-name>
<context-param>
<param-name>db.driver</param-name>
<param-value>com.mysql.cj.jdbc.Driver</param-value>
</context-param>
<context-param>
<param-name>db.url</param-name>
<param-value>jdbc:mysql://127.0.0.1:3306/rjlresaka?useSSL=false&amp;allowPublicKeyRetrieval=true&amp;serverTimezone=UTC</param-value>
</context-param>
<context-param>
<param-name>db.user</param-name>
<param-value>root</param-value>
</context-param>
<context-param>
<param-name>db.password</param-name>
<param-value></param-value>
</context-param>
<context-param>
<param-name>upload.dir</param-name>
<param-value>uploads</param-value>
</context-param>
<welcome-file-list>
<welcome-file>index.jsp</welcome-file>
</welcome-file-list>
<session-config>
<session-timeout>60</session-timeout>
</session-config>
</web-app>

View File

@ -0,0 +1,878 @@
:root {
--fb-blue: #1877f2;
--fb-blue-dark: #1664d9;
--fb-bg: #f0f2f5;
--fb-surface: #ffffff;
--fb-border: #d9dde3;
--fb-text: #1c1e21;
--fb-muted: #65676b;
--fb-soft: #eef3ff;
--fb-green: #42b72a;
--fb-danger: #fa3e3e;
--shadow-lg: 0 16px 40px rgba(15, 23, 42, 0.08);
--shadow-md: 0 10px 24px rgba(15, 23, 42, 0.08);
--radius-xl: 24px;
--radius-lg: 18px;
--radius-md: 14px;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
font-family: 'Inter', Arial, sans-serif;
color: var(--fb-text);
background: radial-gradient(circle at top left, #ffffff 0%, #f7f9fd 38%, #eef2f8 100%);
}
body {
line-height: 1.5;
}
a {
color: inherit;
text-decoration: none;
}
img {
max-width: 100%;
display: block;
}
button,
input,
textarea,
select {
font: inherit;
}
textarea,
input[type="text"],
input[type="email"],
input[type="password"] {
width: 100%;
border: 1px solid var(--fb-border);
border-radius: 14px;
padding: 14px 16px;
background: #fff;
color: var(--fb-text);
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
textarea:focus,
input[type="text"]:focus,
input[type="email"]:focus,
input[type="password"]:focus {
border-color: rgba(24, 119, 242, 0.45);
box-shadow: 0 0 0 4px rgba(24, 119, 242, 0.12);
}
textarea {
resize: vertical;
}
.button {
border: none;
border-radius: 999px;
padding: 12px 22px;
font-weight: 700;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.button:hover {
transform: translateY(-1px);
}
.button.primary {
background: linear-gradient(180deg, #2a82f3 0%, var(--fb-blue) 100%);
color: #fff;
box-shadow: 0 12px 24px rgba(24, 119, 242, 0.2);
}
.button.secondary {
background: #e4e6eb;
color: var(--fb-text);
}
.button.tiny {
padding: 9px 16px;
font-size: 0.9rem;
}
.button.full {
width: 100%;
}
.ghost-link {
color: var(--fb-blue);
font-weight: 600;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
}
.ghost-link.strong {
display: inline-block;
margin-top: 8px;
}
.ghost-link.danger {
color: var(--fb-danger);
}
.ghost-link.mini {
font-size: 0.88rem;
}
.hero-badge,
.pill,
.status-chip,
.reaction-pill,
.emoji-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border-radius: 999px;
font-weight: 700;
}
.hero-badge,
.pill {
padding: 7px 12px;
background: var(--fb-soft);
color: var(--fb-blue);
font-size: 0.82rem;
}
.pill.soft {
color: var(--fb-muted);
background: #f4f5f7;
}
.alert {
border-radius: 16px;
padding: 14px 16px;
margin-bottom: 16px;
font-weight: 600;
}
.alert.success {
background: rgba(66, 183, 42, 0.14);
color: #1d6a10;
}
.alert.error {
background: rgba(250, 62, 62, 0.12);
color: #982626;
}
.alert.floating {
box-shadow: var(--shadow-md);
}
.debug-note {
color: #7d8592;
font-size: 0.9rem;
}
.hidden {
display: none !important;
}
.avatar {
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-weight: 800;
flex-shrink: 0;
box-shadow: inset 0 -8px 18px rgba(0, 0, 0, 0.12);
}
.avatar.small { width: 34px; height: 34px; font-size: 0.78rem; }
.avatar.large { width: 48px; height: 48px; font-size: 1rem; }
.avatar.xl { width: 62px; height: 62px; font-size: 1.2rem; }
.glass,
.glass-soft,
.facebook-card,
.hero-card,
.preview-card,
.auth-panel,
.fb-card {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(255, 255, 255, 0.65);
box-shadow: var(--shadow-lg);
backdrop-filter: blur(16px);
}
.brand {
display: flex;
align-items: center;
gap: 14px;
font-weight: 800;
}
.brand span,
.muted,
small,
.conversation-copy p,
.friend-copy p,
.friend-copy small,
.message-meta-top small,
.edited-note {
color: var(--fb-muted);
}
.eyebrow {
margin: 0 0 4px;
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.73rem;
color: var(--fb-blue);
font-weight: 800;
}
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.section-heading h2,
.profile-card h1,
.chat-partner h2 {
margin: 0;
}
.stack-form {
display: grid;
gap: 14px;
}
.stack-form label span {
display: block;
margin-bottom: 8px;
font-weight: 600;
}
.stack-form.two-col {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.compact-form {
gap: 12px;
}
.button-row {
grid-column: 1 / -1;
}
.auth-body {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background: linear-gradient(180deg, #f5f7fb 0%, #eef2f8 100%);
}
.auth-shell {
width: min(100%, 540px);
}
.auth-panel {
border-radius: 30px;
padding: 32px;
}
.auth-panel.wide {
width: min(100%, 760px);
}
.auth-brand,
.fb-profile-row,
.chat-partner,
.friend-row,
.conversation-item,
.conversation-topline,
.sidebar-header,
.landing-topbar,
.hero-actions,
.preview-topbar,
.stats-row,
.message-toolbar,
.message-actions,
.edit-actions,
.composer-actions,
.chat-title-row {
display: flex;
align-items: center;
}
.auth-brand,
.fb-profile-row,
.friend-row,
.chat-partner,
.brand {
gap: 14px;
}
.auth-copy,
.hero-copy {
color: var(--fb-muted);
}
.auth-links {
display: flex;
justify-content: space-between;
gap: 16px;
margin-top: 12px;
}
.auth-links.left {
justify-content: flex-start;
}
.landing-shell {
padding: 28px;
}
.landing-topbar,
.hero-grid {
max-width: 1240px;
margin: 0 auto;
}
.landing-topbar {
justify-content: space-between;
gap: 18px;
margin-bottom: 22px;
}
.hero-card {
border-radius: 32px;
padding: 36px;
}
.hero-grid {
display: grid;
grid-template-columns: 1.15fr 0.85fr;
gap: 28px;
}
.hero-title {
font-size: clamp(2.2rem, 3.8vw, 4rem);
line-height: 1.02;
margin: 16px 0;
}
.hero-list {
margin: 24px 0 0;
padding-left: 18px;
color: var(--fb-muted);
}
.hero-actions {
gap: 14px;
flex-wrap: wrap;
}
.preview-card {
border-radius: 28px;
padding: 18px;
}
.preview-window {
background: linear-gradient(180deg, #f8fbff 0%, #eef2f8 100%);
border-radius: 24px;
padding: 16px;
}
.preview-topbar {
gap: 8px;
margin-bottom: 14px;
}
.preview-topbar span {
width: 10px;
height: 10px;
border-radius: 50%;
background: #d0d7e4;
}
.preview-chat.advanced {
display: grid;
grid-template-columns: 76px 1fr;
gap: 16px;
}
.preview-sidebar {
display: grid;
gap: 12px;
}
.preview-contact,
.preview-message,
.preview-attachment,
.preview-reactions {
border-radius: 18px;
padding: 14px;
}
.preview-contact {
display: grid;
place-items: center;
background: #dfe9ff;
color: var(--fb-blue);
font-weight: 800;
}
.preview-contact.active,
.preview-message.outgoing {
background: var(--fb-blue);
color: #fff;
}
.preview-conversation {
display: grid;
gap: 12px;
}
.preview-message.incoming,
.preview-attachment,
.preview-reactions {
background: #fff;
}
.dashboard-page,
.fb-body {
background: var(--fb-bg);
}
.fb-dashboard-layout {
max-width: 1500px;
margin: 0 auto;
padding: 22px;
display: grid;
grid-template-columns: 320px minmax(0, 1fr) 360px;
gap: 22px;
min-height: 100vh;
}
.fb-card,
.messenger-shell-card {
border-radius: 26px;
padding: 20px;
}
.fb-left-column,
.fb-right-column,
.fb-center-column {
display: grid;
gap: 18px;
align-content: start;
}
.profile-card {
padding: 22px;
}
.stats-row {
justify-content: space-between;
margin: 18px 0 8px;
padding-top: 16px;
border-top: 1px solid #edf0f4;
}
.stats-row div {
display: grid;
gap: 4px;
text-align: center;
}
.stats-row strong {
font-size: 1.2rem;
}
.checkbox-grid,
.request-list,
.conversation-list {
display: grid;
gap: 12px;
}
.check-chip {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-radius: 16px;
background: #f7f9fc;
border: 1px solid #edf0f4;
}
.check-chip input {
width: auto;
}
.friend-row,
.conversation-item {
padding: 12px;
border-radius: 18px;
background: #f8fafc;
border: 1px solid #edf0f4;
}
.friend-copy,
.conversation-copy,
.message-stack {
min-width: 0;
}
.friend-copy strong,
.conversation-copy strong {
display: block;
}
.friend-copy p,
.conversation-copy p {
margin: 4px 0 0;
font-size: 0.92rem;
}
.friend-copy.grow {
flex: 1;
}
.friend-actions {
margin-left: auto;
}
.friend-actions.vertical {
display: grid;
gap: 8px;
}
.friend-actions.split {
gap: 8px;
}
.status-chip {
padding: 8px 12px;
font-size: 0.82rem;
}
.status-chip.waiting {
background: #fff2d8;
color: #8b5a00;
}
.status-chip.sent {
background: #e8f0fe;
color: var(--fb-blue);
}
.conversation-item {
gap: 12px;
transition: transform 0.15s ease, background 0.2s ease, border-color 0.2s ease;
}
.conversation-item:hover {
transform: translateY(-1px);
background: #fff;
}
.conversation-item.active {
background: #e7f3ff;
border-color: rgba(24, 119, 242, 0.16);
}
.conversation-item .conversation-copy {
flex: 1;
}
.conversation-topline {
justify-content: space-between;
gap: 12px;
}
.unread-badge {
min-width: 24px;
height: 24px;
padding: 0 8px;
border-radius: 999px;
background: var(--fb-blue);
color: #fff;
font-size: 0.8rem;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 800;
}
.fb-chat-topbar {
justify-content: space-between;
gap: 16px;
padding-bottom: 18px;
border-bottom: 1px solid #edf0f4;
}
.chat-title-row {
gap: 10px;
margin-bottom: 4px;
}
.chat-top-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.fb-stream {
display: grid;
gap: 14px;
padding: 18px 2px;
max-height: calc(100vh - 360px);
overflow-y: auto;
}
.message-row {
display: flex;
gap: 10px;
}
.message-row.mine {
justify-content: flex-end;
}
.message-stack {
max-width: min(78%, 680px);
display: grid;
gap: 8px;
}
.message-meta-top {
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
padding: 0 6px;
}
.message-bubble {
border-radius: 22px;
padding: 14px 16px;
box-shadow: var(--shadow-md);
overflow-wrap: anywhere;
}
.message-bubble.mine {
background: linear-gradient(180deg, #2a82f3 0%, var(--fb-blue) 100%);
color: #fff;
border-bottom-right-radius: 8px;
}
.message-bubble.other {
background: #fff;
border-bottom-left-radius: 8px;
}
.message-bubble.deleted {
background: #f4f5f7;
color: var(--fb-muted);
box-shadow: none;
}
.message-text,
.deleted-copy {
margin: 0;
white-space: pre-wrap;
}
.attachment-card {
margin-top: 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 14px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.25);
border: 1px solid rgba(255, 255, 255, 0.32);
}
.message-bubble.other .attachment-card {
background: #f3f5f8;
border-color: #e4e8ee;
}
.edited-note {
display: inline-block;
margin-top: 10px;
}
.message-toolbar {
justify-content: space-between;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.reactions-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.reaction-pill,
.emoji-trigger {
border: none;
padding: 7px 12px;
cursor: pointer;
background: #edf2ff;
color: var(--fb-text);
}
.reaction-pill.active {
background: rgba(24, 119, 242, 0.16);
color: var(--fb-blue);
}
.emoji-trigger {
width: 38px;
height: 38px;
border-radius: 50%;
background: #f3f5f8;
}
.edit-form {
display: grid;
gap: 10px;
padding: 12px;
border-radius: 18px;
background: #f7f9fc;
border: 1px solid #edf0f4;
}
.edit-actions,
.message-actions,
.composer-actions {
gap: 10px;
}
.fb-composer {
margin-top: 14px;
border-radius: 22px;
padding: 16px;
background: #f8fafc;
border: 1px solid #edf0f4;
}
.file-label {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
border-radius: 999px;
padding: 12px 16px;
background: #e9efff;
color: var(--fb-blue);
font-weight: 700;
cursor: pointer;
}
.file-label input[type="file"] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.empty-chat,
.empty-mini {
text-align: center;
color: var(--fb-muted);
}
.large-empty {
min-height: 320px;
display: grid;
place-items: center;
align-content: center;
gap: 10px;
}
.empty-3d-shape {
width: 94px;
height: 94px;
border-radius: 28px;
background: linear-gradient(145deg, #dce9ff 0%, #ffffff 48%, #cfe0ff 100%);
box-shadow: 0 22px 40px rgba(24, 119, 242, 0.16);
transform: rotate(18deg);
}
.quick-open-form {
margin-left: auto;
}
.inline-form {
display: inline-flex;
}
@media (max-width: 1180px) {
.fb-dashboard-layout {
grid-template-columns: 1fr;
}
.fb-left-column,
.fb-center-column,
.fb-right-column {
order: unset;
}
.hero-grid,
.stack-form.two-col {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.landing-shell,
.fb-dashboard-layout,
.auth-body {
padding: 16px;
}
.hero-card,
.fb-card,
.auth-panel {
padding: 18px;
border-radius: 22px;
}
.landing-topbar,
.auth-links,
.fb-chat-topbar,
.message-toolbar,
.composer-actions,
.friend-row.stacked-mobile {
flex-direction: column;
align-items: stretch;
}
.message-stack {
max-width: 100%;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -0,0 +1,36 @@
document.addEventListener('DOMContentLoaded', function () {
var messageStream = document.getElementById('messageStream');
if (messageStream) {
messageStream.scrollTop = messageStream.scrollHeight;
}
var toggles = document.querySelectorAll('.edit-toggle, .edit-cancel');
toggles.forEach(function (button) {
button.addEventListener('click', function () {
var targetId = button.getAttribute('data-target');
var target = document.getElementById(targetId);
if (target) {
target.classList.toggle('hidden');
}
});
});
var fileInputs = document.querySelectorAll('.file-label input[type="file"]');
fileInputs.forEach(function (input) {
input.addEventListener('change', function () {
var label = input.parentElement.querySelector('span');
if (label && input.files && input.files.length > 0) {
label.textContent = input.files[0].name;
}
});
});
var floatingAlerts = document.querySelectorAll('.alert.floating');
floatingAlerts.forEach(function (alert) {
window.setTimeout(function () {
alert.style.opacity = '0';
alert.style.transform = 'translateY(-6px)';
alert.style.transition = 'opacity 250ms ease, transform 250ms ease';
}, 3500);
});
});

View File

@ -0,0 +1,4 @@
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%
response.sendRedirect(request.getContextPath() + "/home");
%>

View File

@ -0,0 +1,197 @@
-- RJLResaka MySQL schema
-- Compatible with MySQL Workbench / MariaDB
CREATE DATABASE IF NOT EXISTS rjlresaka
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE rjlresaka;
CREATE TABLE IF NOT EXISTS users (
id INT PRIMARY KEY AUTO_INCREMENT,
full_name VARCHAR(120) NOT NULL,
username VARCHAR(60) NOT NULL UNIQUE,
email VARCHAR(120) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
avatar_color VARCHAR(20) NOT NULL DEFAULT '#1877f2',
bio VARCHAR(255) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
token VARCHAR(120) NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
used TINYINT(1) NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_password_reset_user
FOREIGN KEY (user_id) REFERENCES users(id)
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS conversations (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(120) NULL,
avatar_color VARCHAR(20) NOT NULL DEFAULT '#E7F3FF',
is_group TINYINT(1) NOT NULL DEFAULT 0,
created_by INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_conversation_creator
FOREIGN KEY (created_by) REFERENCES users(id)
ON DELETE SET NULL
);
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS name VARCHAR(120) NULL;
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS avatar_color VARCHAR(20) NOT NULL DEFAULT '#E7F3FF';
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS is_group TINYINT(1) NOT NULL DEFAULT 0;
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS created_by INT NULL;
CREATE TABLE IF NOT EXISTS conversation_participants (
id INT PRIMARY KEY AUTO_INCREMENT,
conversation_id INT NOT NULL,
user_id INT NOT NULL,
role VARCHAR(30) NOT NULL DEFAULT 'member',
joined_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_conv_part_conversation
FOREIGN KEY (conversation_id) REFERENCES conversations(id)
ON DELETE CASCADE,
CONSTRAINT fk_conv_part_user
FOREIGN KEY (user_id) REFERENCES users(id)
ON DELETE CASCADE,
CONSTRAINT uq_conv_part UNIQUE (conversation_id, user_id)
);
ALTER TABLE conversation_participants ADD COLUMN IF NOT EXISTS role VARCHAR(30) NOT NULL DEFAULT 'member';
CREATE TABLE IF NOT EXISTS messages (
id INT PRIMARY KEY AUTO_INCREMENT,
conversation_id INT NOT NULL,
sender_id INT NOT NULL,
body TEXT NULL,
attachment_name VARCHAR(255) NULL,
attachment_path VARCHAR(255) NULL,
attachment_type VARCHAR(120) NULL,
attachment_size BIGINT NULL,
is_edited TINYINT(1) NOT NULL DEFAULT 0,
is_deleted TINYINT(1) NOT NULL DEFAULT 0,
seen_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_message_conversation
FOREIGN KEY (conversation_id) REFERENCES conversations(id)
ON DELETE CASCADE,
CONSTRAINT fk_message_sender
FOREIGN KEY (sender_id) REFERENCES users(id)
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS message_reactions (
id INT PRIMARY KEY AUTO_INCREMENT,
message_id INT NOT NULL,
user_id INT NOT NULL,
emoji VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_reaction_message
FOREIGN KEY (message_id) REFERENCES messages(id)
ON DELETE CASCADE,
CONSTRAINT fk_reaction_user
FOREIGN KEY (user_id) REFERENCES users(id)
ON DELETE CASCADE,
CONSTRAINT uq_message_reaction UNIQUE (message_id, user_id, emoji)
);
CREATE TABLE IF NOT EXISTS friend_requests (
id INT PRIMARY KEY AUTO_INCREMENT,
sender_id INT NOT NULL,
receiver_id INT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
responded_at DATETIME NULL,
CONSTRAINT fk_friend_request_sender
FOREIGN KEY (sender_id) REFERENCES users(id)
ON DELETE CASCADE,
CONSTRAINT fk_friend_request_receiver
FOREIGN KEY (receiver_id) REFERENCES users(id)
ON DELETE CASCADE,
CONSTRAINT uq_friend_request UNIQUE (sender_id, receiver_id)
);
CREATE TABLE IF NOT EXISTS friends (
id INT PRIMARY KEY AUTO_INCREMENT,
user_one_id INT NOT NULL,
user_two_id INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_friends_user_one
FOREIGN KEY (user_one_id) REFERENCES users(id)
ON DELETE CASCADE,
CONSTRAINT fk_friends_user_two
FOREIGN KEY (user_two_id) REFERENCES users(id)
ON DELETE CASCADE,
CONSTRAINT uq_friends_pair UNIQUE (user_one_id, user_two_id)
);
INSERT INTO users (full_name, username, email, password_hash, avatar_color, bio)
SELECT 'Demo User', 'demo', 'demo@rjlresaka.app', '$2a$10$u6N8G6s8wWC4b7A9iI5L8e2ZfQFlA95zT4zWS3TzFmpXQxwCLWv0W', '#1877f2', 'Compte de démonstration'
WHERE NOT EXISTS (SELECT 1 FROM users WHERE email = 'demo@rjlresaka.app');
INSERT INTO users (full_name, username, email, password_hash, avatar_color, bio)
SELECT 'Alice Miora', 'alice', 'alice@rjlresaka.app', '$2a$10$u6N8G6s8wWC4b7A9iI5L8e2ZfQFlA95zT4zWS3TzFmpXQxwCLWv0W', '#42b72a', 'Étudiante L3 GL'
WHERE NOT EXISTS (SELECT 1 FROM users WHERE email = 'alice@rjlresaka.app');
INSERT INTO users (full_name, username, email, password_hash, avatar_color, bio)
SELECT 'Junior Ranaivo', 'junior', 'junior@rjlresaka.app', '$2a$10$u6N8G6s8wWC4b7A9iI5L8e2ZfQFlA95zT4zWS3TzFmpXQxwCLWv0W', '#ff8a00', 'Compte de test pour la messagerie'
WHERE NOT EXISTS (SELECT 1 FROM users WHERE email = 'junior@rjlresaka.app');
INSERT INTO friends (user_one_id, user_two_id)
SELECT LEAST(u1.id, u2.id), GREATEST(u1.id, u2.id)
FROM users u1, users u2
WHERE u1.email = 'demo@rjlresaka.app'
AND u2.email = 'alice@rjlresaka.app'
AND NOT EXISTS (
SELECT 1 FROM friends f
WHERE f.user_one_id = LEAST(u1.id, u2.id)
AND f.user_two_id = GREATEST(u1.id, u2.id)
);
INSERT INTO friend_requests (sender_id, receiver_id, status)
SELECT sender.id, receiver.id, 'pending'
FROM users sender, users receiver
WHERE sender.email = 'junior@rjlresaka.app'
AND receiver.email = 'demo@rjlresaka.app'
AND NOT EXISTS (
SELECT 1 FROM friend_requests fr
WHERE fr.sender_id = sender.id AND fr.receiver_id = receiver.id
);
INSERT INTO conversations (name, avatar_color, is_group, created_by)
SELECT 'Groupe Projet Final', '#E7F3FF', 1, owner.id
FROM users owner
WHERE owner.email = 'demo@rjlresaka.app'
AND NOT EXISTS (
SELECT 1 FROM conversations c WHERE c.name = 'Groupe Projet Final' AND c.is_group = 1
);
INSERT INTO conversation_participants (conversation_id, user_id, role)
SELECT c.id, u.id,
CASE WHEN u.email = 'demo@rjlresaka.app' THEN 'admin' ELSE 'member' END
FROM conversations c
JOIN users u ON u.email IN ('demo@rjlresaka.app', 'alice@rjlresaka.app', 'junior@rjlresaka.app')
WHERE c.name = 'Groupe Projet Final' AND c.is_group = 1
AND NOT EXISTS (
SELECT 1 FROM conversation_participants cp
WHERE cp.conversation_id = c.id AND cp.user_id = u.id
);
INSERT INTO messages (conversation_id, sender_id, body)
SELECT c.id, owner.id, 'Bienvenue dans le groupe du projet final RJLResaka !'
FROM conversations c
JOIN users owner ON owner.email = 'demo@rjlresaka.app'
WHERE c.name = 'Groupe Projet Final' AND c.is_group = 1
AND NOT EXISTS (
SELECT 1 FROM messages m WHERE m.conversation_id = c.id AND m.sender_id = owner.id
);
-- Mot de passe démo attendu: demo123

View File

@ -0,0 +1,17 @@
Bibliothèques à placer dans WEB-INF/lib
======================================
Obligatoires:
- mysql-connector-j-8.x.x.jar
- jstl-1.2.jar
- jbcrypt-0.4.jar
Selon votre pack Eclipse/Tomcat:
- standard-1.1.2.jar
Déjà pris en charge par Tomcat 9:
- servlet-api (ne pas ajouter si déjà fourni par Tomcat)
Notes:
- L'upload utilise l'API native Part de Servlet 3+, donc commons-fileupload n'est pas obligatoire.
- Les fichiers chargés sont stockés côté serveur; seul leur chemin est enregistré en base.

View File

@ -0,0 +1,299 @@
package com.rjlresaka.dao;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import javax.servlet.ServletContext;
import com.rjlresaka.model.ConversationItem;
import com.rjlresaka.model.User;
import com.rjlresaka.util.DatabaseConnection;
public class ConversationDAO {
public Integer findConversationIdBetweenUsers(int firstUserId, int secondUserId, ServletContext context)
throws SQLException, ClassNotFoundException {
String sql = "SELECT c.id "
+ "FROM conversations c "
+ "INNER JOIN conversation_participants cp1 ON cp1.conversation_id = c.id AND cp1.user_id = ? "
+ "INNER JOIN conversation_participants cp2 ON cp2.conversation_id = c.id AND cp2.user_id = ? "
+ "WHERE c.is_group = 0 LIMIT 1";
try (Connection connection = DatabaseConnection.getConnection(context);
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, firstUserId);
statement.setInt(2, secondUserId);
try (ResultSet rs = statement.executeQuery()) {
return rs.next() ? Integer.valueOf(rs.getInt("id")) : null;
}
}
}
public int findOrCreateConversationId(int firstUserId, int secondUserId, ServletContext context)
throws SQLException, ClassNotFoundException {
Integer existingConversationId = findConversationIdBetweenUsers(firstUserId, secondUserId, context);
if (existingConversationId != null) {
return existingConversationId.intValue();
}
String insertConversation = "INSERT INTO conversations (name, avatar_color, is_group, created_by) VALUES (NULL, '#E7F3FF', 0, ?)";
String insertParticipant = "INSERT INTO conversation_participants (conversation_id, user_id, role) VALUES (?, ?, ?)";
try (Connection connection = DatabaseConnection.getConnection(context)) {
connection.setAutoCommit(false);
try (PreparedStatement conversationStatement = connection.prepareStatement(insertConversation,
Statement.RETURN_GENERATED_KEYS);
PreparedStatement participantStatement = connection.prepareStatement(insertParticipant)) {
conversationStatement.setInt(1, firstUserId);
conversationStatement.executeUpdate();
int conversationId = 0;
try (ResultSet keys = conversationStatement.getGeneratedKeys()) {
if (keys.next()) {
conversationId = keys.getInt(1);
}
}
participantStatement.setInt(1, conversationId);
participantStatement.setInt(2, firstUserId);
participantStatement.setString(3, "admin");
participantStatement.executeUpdate();
participantStatement.setInt(1, conversationId);
participantStatement.setInt(2, secondUserId);
participantStatement.setString(3, "member");
participantStatement.executeUpdate();
connection.commit();
return conversationId;
} catch (SQLException exception) {
connection.rollback();
throw exception;
} finally {
connection.setAutoCommit(true);
}
}
}
public int createGroupConversation(int currentUserId, String name, List<Integer> memberIds, ServletContext context)
throws SQLException, ClassNotFoundException {
String groupName = cleanText(name);
if (groupName == null) {
groupName = "Nouveau groupe";
}
Set<Integer> uniqueMembers = new LinkedHashSet<Integer>();
uniqueMembers.add(Integer.valueOf(currentUserId));
if (memberIds != null) {
for (Integer memberId : memberIds) {
if (memberId != null && memberId.intValue() > 0) {
uniqueMembers.add(memberId);
}
}
}
String insertConversation = "INSERT INTO conversations (name, avatar_color, is_group, created_by) VALUES (?, '#E7F3FF', 1, ?)";
String insertParticipant = "INSERT INTO conversation_participants (conversation_id, user_id, role) VALUES (?, ?, ?)";
try (Connection connection = DatabaseConnection.getConnection(context)) {
connection.setAutoCommit(false);
try (PreparedStatement conversationStatement = connection.prepareStatement(insertConversation,
Statement.RETURN_GENERATED_KEYS);
PreparedStatement participantStatement = connection.prepareStatement(insertParticipant)) {
conversationStatement.setString(1, groupName);
conversationStatement.setInt(2, currentUserId);
conversationStatement.executeUpdate();
int conversationId = 0;
try (ResultSet keys = conversationStatement.getGeneratedKeys()) {
if (keys.next()) {
conversationId = keys.getInt(1);
}
}
for (Integer memberId : uniqueMembers) {
participantStatement.setInt(1, conversationId);
participantStatement.setInt(2, memberId.intValue());
participantStatement.setString(3, memberId.intValue() == currentUserId ? "admin" : "member");
participantStatement.executeUpdate();
}
connection.commit();
return conversationId;
} catch (SQLException exception) {
connection.rollback();
throw exception;
} finally {
connection.setAutoCommit(true);
}
}
}
public List<ConversationItem> findConversationsForUser(int currentUserId, ServletContext context)
throws SQLException, ClassNotFoundException {
String sql = "SELECT c.id, c.name, c.avatar_color, c.is_group, c.updated_at, "
+ "other.id AS direct_user_id, other.full_name AS direct_name, other.username AS direct_username, "
+ "other.avatar_color AS direct_avatar_color, "
+ "(SELECT COUNT(*) FROM conversation_participants count_cp WHERE count_cp.conversation_id = c.id) AS member_count "
+ "FROM conversations c "
+ "INNER JOIN conversation_participants mine ON mine.conversation_id = c.id AND mine.user_id = ? "
+ "LEFT JOIN conversation_participants other_cp ON c.is_group = 0 AND other_cp.conversation_id = c.id AND other_cp.user_id <> ? "
+ "LEFT JOIN users other ON other.id = other_cp.user_id "
+ "ORDER BY c.updated_at DESC, c.created_at DESC";
List<ConversationItem> conversations = new ArrayList<ConversationItem>();
try (Connection connection = DatabaseConnection.getConnection(context);
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, currentUserId);
statement.setInt(2, currentUserId);
try (ResultSet rs = statement.executeQuery()) {
while (rs.next()) {
ConversationItem item = mapConversationBase(rs);
enrichConversationSnapshot(connection, item, currentUserId);
conversations.add(item);
}
}
}
return conversations;
}
public ConversationItem findConversationForUser(int conversationId, int currentUserId, ServletContext context)
throws SQLException, ClassNotFoundException {
String sql = "SELECT c.id, c.name, c.avatar_color, c.is_group, c.updated_at, "
+ "other.id AS direct_user_id, other.full_name AS direct_name, other.username AS direct_username, "
+ "other.avatar_color AS direct_avatar_color, "
+ "(SELECT COUNT(*) FROM conversation_participants count_cp WHERE count_cp.conversation_id = c.id) AS member_count "
+ "FROM conversations c "
+ "INNER JOIN conversation_participants mine ON mine.conversation_id = c.id AND mine.user_id = ? "
+ "LEFT JOIN conversation_participants other_cp ON c.is_group = 0 AND other_cp.conversation_id = c.id AND other_cp.user_id <> ? "
+ "LEFT JOIN users other ON other.id = other_cp.user_id "
+ "WHERE c.id = ? LIMIT 1";
try (Connection connection = DatabaseConnection.getConnection(context);
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, currentUserId);
statement.setInt(2, currentUserId);
statement.setInt(3, conversationId);
try (ResultSet rs = statement.executeQuery()) {
if (!rs.next()) {
return null;
}
ConversationItem item = mapConversationBase(rs);
enrichConversationSnapshot(connection, item, currentUserId);
return item;
}
}
}
public void enrichUsersWithConversationMeta(int currentUserId, List<User> users, ServletContext context)
throws SQLException, ClassNotFoundException {
for (User user : users) {
Integer conversationId = findConversationIdBetweenUsers(currentUserId, user.getId(), context);
user.setConversationId(conversationId);
if (conversationId != null) {
ConversationItem snapshot = findConversationForUser(conversationId.intValue(), currentUserId, context);
if (snapshot != null) {
user.setLastMessagePreview(snapshot.getLastMessagePreview());
user.setLastMessageAt(snapshot.getLastMessageAt());
user.setUnreadCount(snapshot.getUnreadCount());
}
} else {
user.setLastMessagePreview("Commencez la discussion");
}
}
}
public void markConversationAsSeen(int conversationId, int currentUserId, ServletContext context)
throws SQLException, ClassNotFoundException {
String sql = "UPDATE messages SET seen_at = NOW() WHERE conversation_id = ? AND sender_id <> ? AND seen_at IS NULL";
try (Connection connection = DatabaseConnection.getConnection(context);
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, conversationId);
statement.setInt(2, currentUserId);
statement.executeUpdate();
}
}
private ConversationItem mapConversationBase(ResultSet rs) throws SQLException {
ConversationItem item = new ConversationItem();
item.setId(rs.getInt("id"));
item.setGroup(rs.getInt("is_group") == 1);
item.setMemberCount(rs.getInt("member_count"));
item.setDirectUserId(rs.getObject("direct_user_id") == null ? null : Integer.valueOf(rs.getInt("direct_user_id")));
if (item.isGroup()) {
item.setTitle(defaultIfBlank(rs.getString("name"), "Groupe"));
item.setAvatarColor(defaultIfBlank(rs.getString("avatar_color"), "#E7F3FF"));
item.setSubtitle(item.getMemberCount() + " membres");
} else {
item.setTitle(defaultIfBlank(rs.getString("direct_name"), "Discussion privée"));
item.setAvatarColor(defaultIfBlank(rs.getString("direct_avatar_color"), "#1877f2"));
String username = rs.getString("direct_username");
item.setSubtitle(username == null ? "Discussion privée" : "@" + username);
}
return item;
}
private void enrichConversationSnapshot(Connection connection, ConversationItem item, int currentUserId)
throws SQLException {
String lastMessageSql = "SELECT body, attachment_name, is_deleted, created_at FROM messages "
+ "WHERE conversation_id = ? ORDER BY created_at DESC, id DESC LIMIT 1";
try (PreparedStatement statement = connection.prepareStatement(lastMessageSql)) {
statement.setInt(1, item.getId());
try (ResultSet rs = statement.executeQuery()) {
if (rs.next()) {
item.setLastMessagePreview(formatPreview(rs.getString("body"), rs.getString("attachment_name"), rs.getBoolean("is_deleted")));
item.setLastMessageAt(rs.getTimestamp("created_at"));
} else {
item.setLastMessagePreview(item.isGroup() ? "Nouveau groupe prêt à discuter" : "Dites bonjour 👋");
item.setLastMessageAt(null);
}
}
}
String unreadSql = "SELECT COUNT(*) AS total FROM messages WHERE conversation_id = ? AND sender_id <> ? AND seen_at IS NULL";
try (PreparedStatement statement = connection.prepareStatement(unreadSql)) {
statement.setInt(1, item.getId());
statement.setInt(2, currentUserId);
try (ResultSet rs = statement.executeQuery()) {
if (rs.next()) {
item.setUnreadCount(rs.getInt("total"));
}
}
}
}
private String formatPreview(String body, String attachmentName, boolean deleted) {
if (deleted) {
return "Message supprimé";
}
String cleanedBody = cleanText(body);
if (cleanedBody != null) {
return cleanedBody.length() > 54 ? cleanedBody.substring(0, 54) + "" : cleanedBody;
}
if (attachmentName != null && !attachmentName.trim().isEmpty()) {
return "📎 " + attachmentName;
}
return "Nouveau message";
}
private String defaultIfBlank(String value, String fallback) {
return value == null || value.trim().isEmpty() ? fallback : value;
}
private String cleanText(String value) {
if (value == null) {
return null;
}
String cleaned = value.trim();
return cleaned.isEmpty() ? null : cleaned;
}
}

View File

@ -0,0 +1,236 @@
package com.rjlresaka.dao;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import javax.servlet.ServletContext;
import com.rjlresaka.model.FriendRequest;
import com.rjlresaka.model.User;
import com.rjlresaka.util.DatabaseConnection;
public class FriendDAO {
public List<User> findFriends(int currentUserId, ServletContext context) throws SQLException, ClassNotFoundException {
String sql = "SELECT u.* FROM friends f "
+ "INNER JOIN users u ON u.id = CASE WHEN f.user_one_id = ? THEN f.user_two_id ELSE f.user_one_id END "
+ "WHERE f.user_one_id = ? OR f.user_two_id = ? "
+ "ORDER BY u.full_name ASC";
List<User> friends = new ArrayList<User>();
try (Connection connection = DatabaseConnection.getConnection(context);
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, currentUserId);
statement.setInt(2, currentUserId);
statement.setInt(3, currentUserId);
try (ResultSet rs = statement.executeQuery()) {
while (rs.next()) {
User friend = mapUser(rs);
friend.setFriend(true);
friends.add(friend);
}
}
}
return friends;
}
public List<User> findPeople(int currentUserId, ServletContext context) throws SQLException, ClassNotFoundException {
String sql = "SELECT u.*, "
+ "CASE WHEN fr_out.id IS NULL THEN 0 ELSE 1 END AS request_sent, "
+ "CASE WHEN fr_in.id IS NULL THEN 0 ELSE 1 END AS request_received "
+ "FROM users u "
+ "LEFT JOIN friend_requests fr_out ON fr_out.sender_id = ? AND fr_out.receiver_id = u.id AND fr_out.status = 'pending' "
+ "LEFT JOIN friend_requests fr_in ON fr_in.sender_id = u.id AND fr_in.receiver_id = ? AND fr_in.status = 'pending' "
+ "WHERE u.id <> ? "
+ "AND NOT EXISTS ("
+ " SELECT 1 FROM friends f WHERE f.user_one_id = LEAST(?, u.id) AND f.user_two_id = GREATEST(?, u.id)"
+ ") "
+ "ORDER BY CASE WHEN fr_in.id IS NULL THEN 1 ELSE 0 END ASC, u.full_name ASC";
List<User> users = new ArrayList<User>();
try (Connection connection = DatabaseConnection.getConnection(context);
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, currentUserId);
statement.setInt(2, currentUserId);
statement.setInt(3, currentUserId);
statement.setInt(4, currentUserId);
statement.setInt(5, currentUserId);
try (ResultSet rs = statement.executeQuery()) {
while (rs.next()) {
User user = mapUser(rs);
user.setRequestSent(rs.getInt("request_sent") == 1);
user.setRequestReceived(rs.getInt("request_received") == 1);
users.add(user);
}
}
}
return users;
}
public List<FriendRequest> findPendingRequestsReceived(int currentUserId, ServletContext context)
throws SQLException, ClassNotFoundException {
String sql = "SELECT fr.id, fr.sender_id, fr.receiver_id, fr.created_at, "
+ "u.full_name, u.username, u.avatar_color "
+ "FROM friend_requests fr "
+ "INNER JOIN users u ON u.id = fr.sender_id "
+ "WHERE fr.receiver_id = ? AND fr.status = 'pending' "
+ "ORDER BY fr.created_at DESC";
List<FriendRequest> requests = new ArrayList<FriendRequest>();
try (Connection connection = DatabaseConnection.getConnection(context);
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, currentUserId);
try (ResultSet rs = statement.executeQuery()) {
while (rs.next()) {
FriendRequest request = new FriendRequest();
request.setId(rs.getInt("id"));
request.setSenderId(rs.getInt("sender_id"));
request.setReceiverId(rs.getInt("receiver_id"));
request.setSenderName(rs.getString("full_name"));
request.setSenderUsername(rs.getString("username"));
request.setSenderAvatarColor(rs.getString("avatar_color"));
request.setCreatedAt(rs.getTimestamp("created_at"));
requests.add(request);
}
}
}
return requests;
}
public void sendRequest(int senderId, int receiverId, ServletContext context)
throws SQLException, ClassNotFoundException {
if (senderId <= 0 || receiverId <= 0 || senderId == receiverId) {
return;
}
try (Connection connection = DatabaseConnection.getConnection(context)) {
connection.setAutoCommit(false);
try {
if (areFriends(connection, senderId, receiverId)) {
connection.commit();
return;
}
Integer reversePendingId = findPendingRequestId(connection, receiverId, senderId);
if (reversePendingId != null) {
updateRequestStatus(connection, reversePendingId.intValue(), "accepted");
createFriendship(connection, senderId, receiverId);
connection.commit();
return;
}
if (findPendingRequestId(connection, senderId, receiverId) != null) {
connection.commit();
return;
}
String insert = "INSERT INTO friend_requests (sender_id, receiver_id, status) VALUES (?, ?, 'pending')";
try (PreparedStatement statement = connection.prepareStatement(insert)) {
statement.setInt(1, senderId);
statement.setInt(2, receiverId);
statement.executeUpdate();
}
connection.commit();
} catch (SQLException exception) {
connection.rollback();
throw exception;
} finally {
connection.setAutoCommit(true);
}
}
}
public void respondToRequest(int requestId, int currentUserId, boolean accept, ServletContext context)
throws SQLException, ClassNotFoundException {
try (Connection connection = DatabaseConnection.getConnection(context)) {
connection.setAutoCommit(false);
try {
String select = "SELECT sender_id, receiver_id FROM friend_requests WHERE id = ? AND receiver_id = ? AND status = 'pending' LIMIT 1";
try (PreparedStatement statement = connection.prepareStatement(select)) {
statement.setInt(1, requestId);
statement.setInt(2, currentUserId);
try (ResultSet rs = statement.executeQuery()) {
if (!rs.next()) {
connection.commit();
return;
}
int senderId = rs.getInt("sender_id");
int receiverId = rs.getInt("receiver_id");
updateRequestStatus(connection, requestId, accept ? "accepted" : "declined");
if (accept) {
createFriendship(connection, senderId, receiverId);
}
}
}
connection.commit();
} catch (SQLException exception) {
connection.rollback();
throw exception;
} finally {
connection.setAutoCommit(true);
}
}
}
private Integer findPendingRequestId(Connection connection, int senderId, int receiverId) throws SQLException {
String sql = "SELECT id FROM friend_requests WHERE sender_id = ? AND receiver_id = ? AND status = 'pending' LIMIT 1";
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, senderId);
statement.setInt(2, receiverId);
try (ResultSet rs = statement.executeQuery()) {
return rs.next() ? Integer.valueOf(rs.getInt("id")) : null;
}
}
}
private boolean areFriends(Connection connection, int firstUserId, int secondUserId) throws SQLException {
String sql = "SELECT id FROM friends WHERE user_one_id = ? AND user_two_id = ? LIMIT 1";
int userOneId = Math.min(firstUserId, secondUserId);
int userTwoId = Math.max(firstUserId, secondUserId);
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, userOneId);
statement.setInt(2, userTwoId);
try (ResultSet rs = statement.executeQuery()) {
return rs.next();
}
}
}
private void createFriendship(Connection connection, int firstUserId, int secondUserId) throws SQLException {
int userOneId = Math.min(firstUserId, secondUserId);
int userTwoId = Math.max(firstUserId, secondUserId);
if (areFriends(connection, userOneId, userTwoId)) {
return;
}
String sql = "INSERT INTO friends (user_one_id, user_two_id) VALUES (?, ?)";
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, userOneId);
statement.setInt(2, userTwoId);
statement.executeUpdate();
}
}
private void updateRequestStatus(Connection connection, int requestId, String status) throws SQLException {
String sql = "UPDATE friend_requests SET status = ?, responded_at = NOW() WHERE id = ?";
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, status);
statement.setInt(2, requestId);
statement.executeUpdate();
}
}
private User mapUser(ResultSet rs) throws SQLException {
User user = new User();
user.setId(rs.getInt("id"));
user.setFullName(rs.getString("full_name"));
user.setUsername(rs.getString("username"));
user.setEmail(rs.getString("email"));
user.setPasswordHash(rs.getString("password_hash"));
user.setAvatarColor(rs.getString("avatar_color"));
user.setBio(rs.getString("bio"));
user.setCreatedAt(rs.getTimestamp("created_at"));
user.setUpdatedAt(rs.getTimestamp("updated_at"));
return user;
}
}

View File

@ -0,0 +1,258 @@
package com.rjlresaka.dao;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import javax.servlet.ServletContext;
import com.rjlresaka.model.Message;
import com.rjlresaka.model.ReactionStat;
import com.rjlresaka.util.DatabaseConnection;
public class MessageDAO {
private final ConversationDAO conversationDAO = new ConversationDAO();
public List<Message> findMessagesBetweenUsers(int currentUserId, int otherUserId, ServletContext context)
throws SQLException, ClassNotFoundException {
Integer conversationId = conversationDAO.findConversationIdBetweenUsers(currentUserId, otherUserId, context);
if (conversationId == null) {
return new ArrayList<Message>();
}
return findMessagesByConversation(conversationId.intValue(), currentUserId, context);
}
public List<Message> findMessagesByConversation(int conversationId, int currentUserId, ServletContext context)
throws SQLException, ClassNotFoundException {
String sql = "SELECT m.*, u.full_name, u.avatar_color "
+ "FROM messages m "
+ "INNER JOIN users u ON u.id = m.sender_id "
+ "INNER JOIN conversation_participants cp ON cp.conversation_id = m.conversation_id AND cp.user_id = ? "
+ "WHERE m.conversation_id = ? "
+ "ORDER BY m.created_at ASC, m.id ASC";
List<Message> messages = new ArrayList<Message>();
try (Connection connection = DatabaseConnection.getConnection(context);
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, currentUserId);
statement.setInt(2, conversationId);
try (ResultSet rs = statement.executeQuery()) {
while (rs.next()) {
Message message = mapMessage(rs, currentUserId);
message.getReactions().addAll(findReactions(message.getId(), currentUserId, context));
messages.add(message);
}
}
}
return messages;
}
public int createMessage(int senderId, int receiverId, Message draft, ServletContext context)
throws SQLException, ClassNotFoundException {
int conversationId = conversationDAO.findOrCreateConversationId(senderId, receiverId, context);
createMessageInConversation(senderId, conversationId, draft, context);
return conversationId;
}
public void createMessageInConversation(int senderId, int conversationId, Message draft, ServletContext context)
throws SQLException, ClassNotFoundException {
String sql = "INSERT INTO messages (conversation_id, sender_id, body, attachment_name, attachment_path, attachment_type, attachment_size) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?)";
String updateConversation = "UPDATE conversations SET updated_at = NOW() WHERE id = ?";
try (Connection connection = DatabaseConnection.getConnection(context)) {
connection.setAutoCommit(false);
try {
if (!hasAccess(connection, conversationId, senderId)) {
throw new SQLException("Accès refusé à cette conversation.");
}
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, conversationId);
statement.setInt(2, senderId);
statement.setString(3, cleanText(draft.getBody()));
statement.setString(4, emptyToNull(draft.getAttachmentName()));
statement.setString(5, emptyToNull(draft.getAttachmentPath()));
statement.setString(6, emptyToNull(draft.getAttachmentType()));
if (draft.getAttachmentSize() > 0) {
statement.setLong(7, draft.getAttachmentSize());
} else {
statement.setNull(7, java.sql.Types.BIGINT);
}
statement.executeUpdate();
}
try (PreparedStatement statement = connection.prepareStatement(updateConversation)) {
statement.setInt(1, conversationId);
statement.executeUpdate();
}
connection.commit();
} catch (SQLException exception) {
connection.rollback();
throw exception;
} finally {
connection.setAutoCommit(true);
}
}
}
public boolean updateMessage(int messageId, int senderId, String body, ServletContext context)
throws SQLException, ClassNotFoundException {
String sql = "UPDATE messages SET body = ?, is_edited = 1, updated_at = NOW() "
+ "WHERE id = ? AND sender_id = ? AND is_deleted = 0";
try (Connection connection = DatabaseConnection.getConnection(context);
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, cleanText(body));
statement.setInt(2, messageId);
statement.setInt(3, senderId);
return statement.executeUpdate() > 0;
}
}
public boolean deleteMessage(int messageId, int senderId, ServletContext context)
throws SQLException, ClassNotFoundException {
String sql = "UPDATE messages SET body = NULL, attachment_name = NULL, attachment_path = NULL, attachment_type = NULL, "
+ "attachment_size = NULL, is_deleted = 1, updated_at = NOW() WHERE id = ? AND sender_id = ?";
try (Connection connection = DatabaseConnection.getConnection(context);
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, messageId);
statement.setInt(2, senderId);
return statement.executeUpdate() > 0;
}
}
public void toggleReaction(int messageId, int userId, String emoji, ServletContext context)
throws SQLException, ClassNotFoundException {
if (emoji == null || emoji.trim().isEmpty()) {
return;
}
String select = "SELECT id FROM message_reactions WHERE message_id = ? AND user_id = ? AND emoji = ? LIMIT 1";
String insert = "INSERT INTO message_reactions (message_id, user_id, emoji) VALUES (?, ?, ?)";
String delete = "DELETE FROM message_reactions WHERE id = ?";
try (Connection connection = DatabaseConnection.getConnection(context);
PreparedStatement selectStatement = connection.prepareStatement(select)) {
selectStatement.setInt(1, messageId);
selectStatement.setInt(2, userId);
selectStatement.setString(3, emoji);
try (ResultSet rs = selectStatement.executeQuery()) {
if (rs.next()) {
try (PreparedStatement deleteStatement = connection.prepareStatement(delete)) {
deleteStatement.setInt(1, rs.getInt("id"));
deleteStatement.executeUpdate();
}
} else {
try (PreparedStatement insertStatement = connection.prepareStatement(insert)) {
insertStatement.setInt(1, messageId);
insertStatement.setInt(2, userId);
insertStatement.setString(3, emoji);
insertStatement.executeUpdate();
}
}
}
}
}
public Message findAttachmentForDownload(int messageId, int currentUserId, ServletContext context)
throws SQLException, ClassNotFoundException {
String sql = "SELECT m.*, u.full_name, u.avatar_color "
+ "FROM messages m "
+ "INNER JOIN users u ON u.id = m.sender_id "
+ "INNER JOIN conversation_participants cp ON cp.conversation_id = m.conversation_id AND cp.user_id = ? "
+ "WHERE m.id = ? LIMIT 1";
try (Connection connection = DatabaseConnection.getConnection(context);
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, currentUserId);
statement.setInt(2, messageId);
try (ResultSet rs = statement.executeQuery()) {
return rs.next() ? mapMessage(rs, currentUserId) : null;
}
}
}
private boolean hasAccess(Connection connection, int conversationId, int userId) throws SQLException {
String sql = "SELECT 1 FROM conversation_participants WHERE conversation_id = ? AND user_id = ? LIMIT 1";
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, conversationId);
statement.setInt(2, userId);
try (ResultSet rs = statement.executeQuery()) {
return rs.next();
}
}
}
private List<ReactionStat> findReactions(int messageId, int currentUserId, ServletContext context)
throws SQLException, ClassNotFoundException {
String sql = "SELECT emoji, COUNT(*) AS total, MAX(CASE WHEN user_id = ? THEN 1 ELSE 0 END) AS mine "
+ "FROM message_reactions WHERE message_id = ? GROUP BY emoji ORDER BY total DESC, emoji ASC";
List<ReactionStat> reactions = new ArrayList<ReactionStat>();
try (Connection connection = DatabaseConnection.getConnection(context);
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, currentUserId);
statement.setInt(2, messageId);
try (ResultSet rs = statement.executeQuery()) {
while (rs.next()) {
ReactionStat stat = new ReactionStat();
stat.setEmoji(rs.getString("emoji"));
stat.setCount(rs.getInt("total"));
stat.setReactedByCurrentUser(rs.getInt("mine") == 1);
reactions.add(stat);
}
}
}
return reactions;
}
private Message mapMessage(ResultSet rs, int currentUserId) throws SQLException {
Message message = new Message();
message.setId(rs.getInt("id"));
message.setConversationId(rs.getInt("conversation_id"));
message.setSenderId(rs.getInt("sender_id"));
message.setSenderName(rs.getString("full_name"));
message.setSenderAvatarColor(rs.getString("avatar_color"));
message.setSenderInitials(initials(rs.getString("full_name")));
message.setBody(rs.getString("body"));
message.setAttachmentName(rs.getString("attachment_name"));
message.setAttachmentPath(rs.getString("attachment_path"));
message.setAttachmentType(rs.getString("attachment_type"));
message.setAttachmentSize(rs.getLong("attachment_size"));
message.setEdited(rs.getBoolean("is_edited"));
message.setDeleted(rs.getBoolean("is_deleted"));
message.setMine(rs.getInt("sender_id") == currentUserId);
message.setCreatedAt(rs.getTimestamp("created_at"));
message.setUpdatedAt(rs.getTimestamp("updated_at"));
return message;
}
private String initials(String fullName) {
if (fullName == null || fullName.trim().isEmpty()) {
return "RR";
}
String[] parts = fullName.trim().split("\\s+");
StringBuilder builder = new StringBuilder();
for (String part : parts) {
if (!part.isEmpty()) {
builder.append(Character.toUpperCase(part.charAt(0)));
}
if (builder.length() == 2) {
break;
}
}
return builder.length() == 0 ? "RR" : builder.toString();
}
private String cleanText(String body) {
if (body == null) {
return null;
}
String cleaned = body.trim();
return cleaned.isEmpty() ? null : cleaned;
}
private String emptyToNull(String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
return value;
}
}

View File

@ -0,0 +1,49 @@
package com.rjlresaka.dao;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import javax.servlet.ServletContext;
import com.rjlresaka.util.DatabaseConnection;
public class PasswordResetDAO {
public void createToken(int userId, String token, Timestamp expiresAt, ServletContext context) throws SQLException, ClassNotFoundException {
String cleanup = "UPDATE password_reset_tokens SET used = 1 WHERE user_id = ? AND used = 0";
String insert = "INSERT INTO password_reset_tokens (user_id, token, expires_at, used) VALUES (?, ?, ?, 0)";
try (Connection connection = DatabaseConnection.getConnection(context);
PreparedStatement cleanupStatement = connection.prepareStatement(cleanup);
PreparedStatement insertStatement = connection.prepareStatement(insert)) {
cleanupStatement.setInt(1, userId);
cleanupStatement.executeUpdate();
insertStatement.setInt(1, userId);
insertStatement.setString(2, token);
insertStatement.setTimestamp(3, expiresAt);
insertStatement.executeUpdate();
}
}
public Integer findValidUserIdByToken(String token, ServletContext context) throws SQLException, ClassNotFoundException {
String sql = "SELECT user_id FROM password_reset_tokens WHERE token = ? AND used = 0 AND expires_at >= NOW() LIMIT 1";
try (Connection connection = DatabaseConnection.getConnection(context);
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, token);
try (ResultSet rs = statement.executeQuery()) {
return rs.next() ? Integer.valueOf(rs.getInt("user_id")) : null;
}
}
}
public void markAsUsed(String token, ServletContext context) throws SQLException, ClassNotFoundException {
String sql = "UPDATE password_reset_tokens SET used = 1 WHERE token = ?";
try (Connection connection = DatabaseConnection.getConnection(context);
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, token);
statement.executeUpdate();
}
}
}

View File

@ -0,0 +1,132 @@
package com.rjlresaka.dao;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import javax.servlet.ServletContext;
import com.rjlresaka.model.User;
import com.rjlresaka.util.DatabaseConnection;
public class UserDAO {
public boolean emailExists(String email, ServletContext context) throws SQLException, ClassNotFoundException {
String sql = "SELECT id FROM users WHERE email = ?";
try (Connection connection = DatabaseConnection.getConnection(context);
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, email);
try (ResultSet rs = statement.executeQuery()) {
return rs.next();
}
}
}
public boolean usernameExists(String username, ServletContext context) throws SQLException, ClassNotFoundException {
String sql = "SELECT id FROM users WHERE username = ?";
try (Connection connection = DatabaseConnection.getConnection(context);
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, username);
try (ResultSet rs = statement.executeQuery()) {
return rs.next();
}
}
}
public User create(User user, ServletContext context) throws SQLException, ClassNotFoundException {
String sql = "INSERT INTO users (full_name, username, email, password_hash, avatar_color, bio) VALUES (?, ?, ?, ?, ?, ?)";
try (Connection connection = DatabaseConnection.getConnection(context);
PreparedStatement statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
statement.setString(1, user.getFullName());
statement.setString(2, user.getUsername());
statement.setString(3, user.getEmail());
statement.setString(4, user.getPasswordHash());
statement.setString(5, user.getAvatarColor());
statement.setString(6, user.getBio());
statement.executeUpdate();
try (ResultSet keys = statement.getGeneratedKeys()) {
if (keys.next()) {
user.setId(keys.getInt(1));
}
}
return user;
}
}
public User findById(int id, ServletContext context) throws SQLException, ClassNotFoundException {
String sql = "SELECT * FROM users WHERE id = ? LIMIT 1";
try (Connection connection = DatabaseConnection.getConnection(context);
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, id);
try (ResultSet rs = statement.executeQuery()) {
return rs.next() ? mapUser(rs) : null;
}
}
}
public User findByEmail(String email, ServletContext context) throws SQLException, ClassNotFoundException {
String sql = "SELECT * FROM users WHERE email = ? LIMIT 1";
try (Connection connection = DatabaseConnection.getConnection(context);
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, email);
try (ResultSet rs = statement.executeQuery()) {
return rs.next() ? mapUser(rs) : null;
}
}
}
public User findByEmailOrUsername(String value, ServletContext context) throws SQLException, ClassNotFoundException {
String sql = "SELECT * FROM users WHERE email = ? OR username = ? LIMIT 1";
try (Connection connection = DatabaseConnection.getConnection(context);
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, value);
statement.setString(2, value);
try (ResultSet rs = statement.executeQuery()) {
return rs.next() ? mapUser(rs) : null;
}
}
}
public List<User> findOtherUsers(int currentUserId, ServletContext context) throws SQLException, ClassNotFoundException {
String sql = "SELECT * FROM users WHERE id <> ? ORDER BY full_name ASC";
List<User> users = new ArrayList<User>();
try (Connection connection = DatabaseConnection.getConnection(context);
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, currentUserId);
try (ResultSet rs = statement.executeQuery()) {
while (rs.next()) {
users.add(mapUser(rs));
}
}
}
return users;
}
public void updatePassword(int userId, String passwordHash, ServletContext context) throws SQLException, ClassNotFoundException {
String sql = "UPDATE users SET password_hash = ? WHERE id = ?";
try (Connection connection = DatabaseConnection.getConnection(context);
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, passwordHash);
statement.setInt(2, userId);
statement.executeUpdate();
}
}
private User mapUser(ResultSet rs) throws SQLException {
User user = new User();
user.setId(rs.getInt("id"));
user.setFullName(rs.getString("full_name"));
user.setUsername(rs.getString("username"));
user.setEmail(rs.getString("email"));
user.setPasswordHash(rs.getString("password_hash"));
user.setAvatarColor(rs.getString("avatar_color"));
user.setBio(rs.getString("bio"));
user.setCreatedAt(rs.getTimestamp("created_at"));
user.setUpdatedAt(rs.getTimestamp("updated_at"));
return user;
}
}

View File

@ -0,0 +1 @@
package com.rjlresaka.dao;

View File

@ -0,0 +1,43 @@
package com.rjlresaka.filter;
import java.io.IOException;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
@WebFilter(urlPatterns = { "/app/*", "/logout" })
public class AuthFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
// No init params required.
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
HttpSession session = httpRequest.getSession(false);
boolean authenticated = session != null && session.getAttribute("authUser") != null;
if (!authenticated) {
httpResponse.sendRedirect(httpRequest.getContextPath() + "/login");
return;
}
chain.doFilter(request, response);
}
@Override
public void destroy() {
// Nothing to destroy.
}
}

View File

@ -0,0 +1,33 @@
package com.rjlresaka.filter;
import java.io.IOException;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.annotation.WebFilter;
@WebFilter("/*")
public class CharacterEncodingFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
// No init params required.
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
request.setCharacterEncoding("UTF-8");
response.setCharacterEncoding("UTF-8");
chain.doFilter(request, response);
}
@Override
public void destroy() {
// Nothing to destroy.
}
}

View File

@ -0,0 +1 @@
package com.rjlresaka.filter;

View File

@ -0,0 +1,128 @@
package com.rjlresaka.model;
import java.io.Serializable;
import java.sql.Timestamp;
public class ConversationItem implements Serializable {
private static final long serialVersionUID = 1L;
private int id;
private String title;
private String subtitle;
private String avatarColor;
private String lastMessagePreview;
private Timestamp lastMessageAt;
private int unreadCount;
private boolean active;
private boolean group;
private Integer directUserId;
private int memberCount;
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getSubtitle() {
return subtitle;
}
public void setSubtitle(String subtitle) {
this.subtitle = subtitle;
}
public String getAvatarColor() {
return avatarColor;
}
public void setAvatarColor(String avatarColor) {
this.avatarColor = avatarColor;
}
public String getLastMessagePreview() {
return lastMessagePreview;
}
public void setLastMessagePreview(String lastMessagePreview) {
this.lastMessagePreview = lastMessagePreview;
}
public Timestamp getLastMessageAt() {
return lastMessageAt;
}
public void setLastMessageAt(Timestamp lastMessageAt) {
this.lastMessageAt = lastMessageAt;
}
public int getUnreadCount() {
return unreadCount;
}
public void setUnreadCount(int unreadCount) {
this.unreadCount = unreadCount;
}
public boolean isActive() {
return active;
}
public void setActive(boolean active) {
this.active = active;
}
public boolean isGroup() {
return group;
}
public void setGroup(boolean group) {
this.group = group;
}
public Integer getDirectUserId() {
return directUserId;
}
public void setDirectUserId(Integer directUserId) {
this.directUserId = directUserId;
}
public int getMemberCount() {
return memberCount;
}
public void setMemberCount(int memberCount) {
this.memberCount = memberCount;
}
public String getInitials() {
if (title == null || title.trim().isEmpty()) {
return group ? "GR" : "RR";
}
String[] parts = title.trim().split("\\s+");
StringBuilder builder = new StringBuilder();
for (String part : parts) {
if (!part.isEmpty()) {
builder.append(Character.toUpperCase(part.charAt(0)));
}
if (builder.length() == 2) {
break;
}
}
if (builder.length() == 0) {
return group ? "GR" : "RR";
}
return builder.toString();
}
}

View File

@ -0,0 +1,89 @@
package com.rjlresaka.model;
import java.io.Serializable;
import java.sql.Timestamp;
public class FriendRequest implements Serializable {
private static final long serialVersionUID = 1L;
private int id;
private int senderId;
private int receiverId;
private String senderName;
private String senderUsername;
private String senderAvatarColor;
private Timestamp createdAt;
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public int getSenderId() {
return senderId;
}
public void setSenderId(int senderId) {
this.senderId = senderId;
}
public int getReceiverId() {
return receiverId;
}
public void setReceiverId(int receiverId) {
this.receiverId = receiverId;
}
public String getSenderName() {
return senderName;
}
public void setSenderName(String senderName) {
this.senderName = senderName;
}
public String getSenderUsername() {
return senderUsername;
}
public void setSenderUsername(String senderUsername) {
this.senderUsername = senderUsername;
}
public String getSenderAvatarColor() {
return senderAvatarColor;
}
public void setSenderAvatarColor(String senderAvatarColor) {
this.senderAvatarColor = senderAvatarColor;
}
public Timestamp getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Timestamp createdAt) {
this.createdAt = createdAt;
}
public String getSenderInitials() {
if (senderName == null || senderName.trim().isEmpty()) {
return "RR";
}
String[] parts = senderName.trim().split("\\s+");
StringBuilder builder = new StringBuilder();
for (String part : parts) {
if (!part.isEmpty()) {
builder.append(Character.toUpperCase(part.charAt(0)));
}
if (builder.length() == 2) {
break;
}
}
return builder.length() == 0 ? "RR" : builder.toString();
}
}

View File

@ -0,0 +1,175 @@
package com.rjlresaka.model;
import java.io.Serializable;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.List;
public class Message implements Serializable {
private static final long serialVersionUID = 1L;
private int id;
private int conversationId;
private int senderId;
private String senderName;
private String senderAvatarColor;
private String senderInitials;
private String body;
private String attachmentName;
private String attachmentPath;
private String attachmentType;
private long attachmentSize;
private boolean edited;
private boolean deleted;
private boolean mine;
private Timestamp createdAt;
private Timestamp updatedAt;
private final List<ReactionStat> reactions = new ArrayList<ReactionStat>();
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public int getConversationId() {
return conversationId;
}
public void setConversationId(int conversationId) {
this.conversationId = conversationId;
}
public int getSenderId() {
return senderId;
}
public void setSenderId(int senderId) {
this.senderId = senderId;
}
public String getSenderName() {
return senderName;
}
public void setSenderName(String senderName) {
this.senderName = senderName;
}
public String getSenderAvatarColor() {
return senderAvatarColor;
}
public void setSenderAvatarColor(String senderAvatarColor) {
this.senderAvatarColor = senderAvatarColor;
}
public String getSenderInitials() {
return senderInitials;
}
public void setSenderInitials(String senderInitials) {
this.senderInitials = senderInitials;
}
public String getBody() {
return body;
}
public void setBody(String body) {
this.body = body;
}
public String getAttachmentName() {
return attachmentName;
}
public void setAttachmentName(String attachmentName) {
this.attachmentName = attachmentName;
}
public String getAttachmentPath() {
return attachmentPath;
}
public void setAttachmentPath(String attachmentPath) {
this.attachmentPath = attachmentPath;
}
public String getAttachmentType() {
return attachmentType;
}
public void setAttachmentType(String attachmentType) {
this.attachmentType = attachmentType;
}
public long getAttachmentSize() {
return attachmentSize;
}
public void setAttachmentSize(long attachmentSize) {
this.attachmentSize = attachmentSize;
}
public boolean isEdited() {
return edited;
}
public void setEdited(boolean edited) {
this.edited = edited;
}
public boolean isDeleted() {
return deleted;
}
public void setDeleted(boolean deleted) {
this.deleted = deleted;
}
public boolean isMine() {
return mine;
}
public void setMine(boolean mine) {
this.mine = mine;
}
public Timestamp getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Timestamp createdAt) {
this.createdAt = createdAt;
}
public Timestamp getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(Timestamp updatedAt) {
this.updatedAt = updatedAt;
}
public List<ReactionStat> getReactions() {
return reactions;
}
public String getDisplayBody() {
if (deleted) {
return "Message supprimé";
}
return body == null ? "" : body;
}
public boolean hasAttachment() {
return attachmentPath != null && !attachmentPath.trim().isEmpty();
}
public boolean hasText() {
return body != null && !body.trim().isEmpty();
}
}

View File

@ -0,0 +1,35 @@
package com.rjlresaka.model;
import java.io.Serializable;
public class ReactionStat implements Serializable {
private static final long serialVersionUID = 1L;
private String emoji;
private int count;
private boolean reactedByCurrentUser;
public String getEmoji() {
return emoji;
}
public void setEmoji(String emoji) {
this.emoji = emoji;
}
public int getCount() {
return count;
}
public void setCount(int count) {
this.count = count;
}
public boolean isReactedByCurrentUser() {
return reactedByCurrentUser;
}
public void setReactedByCurrentUser(boolean reactedByCurrentUser) {
this.reactedByCurrentUser = reactedByCurrentUser;
}
}

View File

@ -0,0 +1,48 @@
package com.rjlresaka.model;
import java.io.Serializable;
public class StoredAttachment implements Serializable {
private static final long serialVersionUID = 1L;
private String originalName;
private String storagePath;
private String contentType;
private long size;
public String getOriginalName() {
return originalName;
}
public void setOriginalName(String originalName) {
this.originalName = originalName;
}
public String getStoragePath() {
return storagePath;
}
public void setStoragePath(String storagePath) {
this.storagePath = storagePath;
}
public String getContentType() {
return contentType;
}
public void setContentType(String contentType) {
this.contentType = contentType;
}
public long getSize() {
return size;
}
public void setSize(long size) {
this.size = size;
}
public boolean isImage() {
return contentType != null && contentType.startsWith("image/");
}
}

View File

@ -0,0 +1,180 @@
package com.rjlresaka.model;
import java.io.Serializable;
import java.sql.Timestamp;
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private int id;
private String fullName;
private String username;
private String email;
private String passwordHash;
private String avatarColor;
private String bio;
private Timestamp createdAt;
private Timestamp updatedAt;
private Integer conversationId;
private String lastMessagePreview;
private Timestamp lastMessageAt;
private int unreadCount;
private boolean active;
private boolean friend;
private boolean requestSent;
private boolean requestReceived;
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getFullName() {
return fullName;
}
public void setFullName(String fullName) {
this.fullName = fullName;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPasswordHash() {
return passwordHash;
}
public void setPasswordHash(String passwordHash) {
this.passwordHash = passwordHash;
}
public String getAvatarColor() {
return avatarColor;
}
public void setAvatarColor(String avatarColor) {
this.avatarColor = avatarColor;
}
public String getBio() {
return bio;
}
public void setBio(String bio) {
this.bio = bio;
}
public Timestamp getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Timestamp createdAt) {
this.createdAt = createdAt;
}
public Timestamp getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(Timestamp updatedAt) {
this.updatedAt = updatedAt;
}
public Integer getConversationId() {
return conversationId;
}
public void setConversationId(Integer conversationId) {
this.conversationId = conversationId;
}
public String getLastMessagePreview() {
return lastMessagePreview;
}
public void setLastMessagePreview(String lastMessagePreview) {
this.lastMessagePreview = lastMessagePreview;
}
public Timestamp getLastMessageAt() {
return lastMessageAt;
}
public void setLastMessageAt(Timestamp lastMessageAt) {
this.lastMessageAt = lastMessageAt;
}
public int getUnreadCount() {
return unreadCount;
}
public void setUnreadCount(int unreadCount) {
this.unreadCount = unreadCount;
}
public boolean isActive() {
return active;
}
public void setActive(boolean active) {
this.active = active;
}
public boolean isFriend() {
return friend;
}
public void setFriend(boolean friend) {
this.friend = friend;
}
public boolean isRequestSent() {
return requestSent;
}
public void setRequestSent(boolean requestSent) {
this.requestSent = requestSent;
}
public boolean isRequestReceived() {
return requestReceived;
}
public void setRequestReceived(boolean requestReceived) {
this.requestReceived = requestReceived;
}
public String getInitials() {
if (fullName == null || fullName.trim().isEmpty()) {
return "RR";
}
String[] parts = fullName.trim().split("\\s+");
StringBuilder initials = new StringBuilder();
for (String part : parts) {
if (!part.isEmpty()) {
initials.append(Character.toUpperCase(part.charAt(0)));
}
if (initials.length() == 2) {
break;
}
}
return initials.length() == 0 ? "RR" : initials.toString();
}
}

View File

@ -0,0 +1 @@
package com.rjlresaka.model;

View File

@ -0,0 +1,67 @@
package com.rjlresaka.servlet;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import com.rjlresaka.dao.ConversationDAO;
import com.rjlresaka.model.User;
@WebServlet("/app/groups/create")
public class CreateGroupServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
private final ConversationDAO conversationDAO = new ConversationDAO();
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
HttpSession session = request.getSession(false);
if (session == null || session.getAttribute("authUser") == null) {
response.sendRedirect(request.getContextPath() + "/login");
return;
}
User authUser = (User) session.getAttribute("authUser");
String groupName = request.getParameter("groupName");
String[] selectedMembers = request.getParameterValues("memberIds");
List<Integer> memberIds = new ArrayList<Integer>();
if (selectedMembers != null) {
for (String value : selectedMembers) {
int id = parseInt(value);
if (id > 0) {
memberIds.add(Integer.valueOf(id));
}
}
}
if (memberIds.isEmpty()) {
session.setAttribute("flashError", "Choisissez au moins un ami pour créer un groupe.");
response.sendRedirect(request.getContextPath() + "/app/dashboard");
return;
}
try {
int conversationId = conversationDAO.createGroupConversation(authUser.getId(), groupName, memberIds, getServletContext());
session.setAttribute("flashSuccess", "Groupe créé avec succès.");
response.sendRedirect(request.getContextPath() + "/app/dashboard?conversation=" + conversationId);
} catch (Exception exception) {
session.setAttribute("flashError", "Création du groupe impossible : " + exception.getMessage());
response.sendRedirect(request.getContextPath() + "/app/dashboard");
}
}
private int parseInt(String value) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException exception) {
return 0;
}
}
}

View File

@ -0,0 +1,126 @@
package com.rjlresaka.servlet;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import com.rjlresaka.dao.ConversationDAO;
import com.rjlresaka.dao.FriendDAO;
import com.rjlresaka.dao.MessageDAO;
import com.rjlresaka.model.ConversationItem;
import com.rjlresaka.model.FriendRequest;
import com.rjlresaka.model.Message;
import com.rjlresaka.model.User;
@WebServlet("/app/dashboard")
public class DashboardServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
private final ConversationDAO conversationDAO = new ConversationDAO();
private final FriendDAO friendDAO = new FriendDAO();
private final MessageDAO messageDAO = new MessageDAO();
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
HttpSession session = request.getSession(false);
if (session == null || session.getAttribute("authUser") == null) {
response.sendRedirect(request.getContextPath() + "/login");
return;
}
User authUser = (User) session.getAttribute("authUser");
try {
Integer forcedConversationId = null;
int requestedUserId = parseInt(request.getParameter("user"));
if (requestedUserId > 0 && requestedUserId != authUser.getId()) {
forcedConversationId = Integer.valueOf(conversationDAO.findOrCreateConversationId(authUser.getId(), requestedUserId, getServletContext()));
}
List<ConversationItem> conversations = conversationDAO.findConversationsForUser(authUser.getId(), getServletContext());
List<User> friends = friendDAO.findFriends(authUser.getId(), getServletContext());
List<User> people = friendDAO.findPeople(authUser.getId(), getServletContext());
List<FriendRequest> pendingRequests = friendDAO.findPendingRequestsReceived(authUser.getId(), getServletContext());
ConversationItem activeConversation = resolveActiveConversation(request, conversations, forcedConversationId);
List<Message> messages = new ArrayList<Message>();
if (activeConversation != null) {
activeConversation.setActive(true);
conversationDAO.markConversationAsSeen(activeConversation.getId(), authUser.getId(), getServletContext());
messages = messageDAO.findMessagesByConversation(activeConversation.getId(), authUser.getId(), getServletContext());
}
request.setAttribute("conversations", conversations);
request.setAttribute("activeConversation", activeConversation);
request.setAttribute("messages", messages);
request.setAttribute("friends", friends);
request.setAttribute("people", people);
request.setAttribute("pendingRequests", pendingRequests);
request.setAttribute("friendCount", Integer.valueOf(friends.size()));
request.setAttribute("requestCount", Integer.valueOf(pendingRequests.size()));
request.setAttribute("groupCandidateCount", Integer.valueOf(friends.size()));
consumeFlash(session, request);
request.getRequestDispatcher("/WEB-INF/views/dashboard.jsp").forward(request, response);
} catch (Exception exception) {
request.setAttribute("error", "Impossible de charger le tableau de bord pour le moment.");
request.setAttribute("debugMessage", exception.getMessage());
request.getRequestDispatcher("/WEB-INF/views/dashboard.jsp").forward(request, response);
}
}
private ConversationItem resolveActiveConversation(HttpServletRequest request, List<ConversationItem> conversations, Integer forcedConversationId) {
String conversationParam = request.getParameter("conversation");
Integer conversationId = null;
try {
if (conversationParam != null && !conversationParam.trim().isEmpty()) {
conversationId = Integer.valueOf(conversationParam);
}
} catch (NumberFormatException exception) {
conversationId = null;
}
if (conversations.isEmpty()) {
return null;
}
if (conversationId == null && forcedConversationId != null) {
conversationId = forcedConversationId;
}
if (conversationId == null) {
return conversations.get(0);
}
for (ConversationItem item : conversations) {
if (item.getId() == conversationId.intValue()) {
return item;
}
}
return conversations.get(0);
}
private int parseInt(String value) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException exception) {
return 0;
}
}
private void consumeFlash(HttpSession session, HttpServletRequest request) {
Object success = session.getAttribute("flashSuccess");
Object error = session.getAttribute("flashError");
if (success != null) {
request.setAttribute("success", success.toString());
session.removeAttribute("flashSuccess");
}
if (error != null) {
request.setAttribute("error", error.toString());
session.removeAttribute("flashError");
}
}
}

View File

@ -0,0 +1,50 @@
package com.rjlresaka.servlet;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import com.rjlresaka.dao.MessageDAO;
import com.rjlresaka.model.User;
@WebServlet("/app/messages/delete")
public class DeleteMessageServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
private final MessageDAO messageDAO = new MessageDAO();
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
HttpSession session = request.getSession(false);
if (session == null || session.getAttribute("authUser") == null) {
response.sendRedirect(request.getContextPath() + "/login");
return;
}
User authUser = (User) session.getAttribute("authUser");
int conversationId = parseInt(request.getParameter("conversationId"));
int messageId = parseInt(request.getParameter("messageId"));
try {
boolean deleted = messageDAO.deleteMessage(messageId, authUser.getId(), getServletContext());
session.setAttribute(deleted ? "flashSuccess" : "flashError",
deleted ? "Message supprimé." : "Suppression refusée.");
} catch (Exception exception) {
session.setAttribute("flashError", "Erreur de suppression : " + exception.getMessage());
}
response.sendRedirect(request.getContextPath() + "/app/dashboard?conversation=" + conversationId);
}
private int parseInt(String value) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException exception) {
return 0;
}
}
}

View File

@ -0,0 +1,60 @@
package com.rjlresaka.servlet;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import com.rjlresaka.dao.MessageDAO;
import com.rjlresaka.model.Message;
import com.rjlresaka.model.User;
import com.rjlresaka.util.FileStorageUtil;
@WebServlet("/app/files/download")
public class DownloadAttachmentServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
private final MessageDAO messageDAO = new MessageDAO();
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
HttpSession session = request.getSession(false);
User authUser = (User) session.getAttribute("authUser");
int messageId = parseInt(request.getParameter("id"));
try {
Message message = messageDAO.findAttachmentForDownload(messageId, authUser.getId(), getServletContext());
if (message == null || !message.hasAttachment()) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
Path filePath = FileStorageUtil.resolveStoredFile(message.getAttachmentPath(), getServletContext());
if (!Files.exists(filePath)) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
response.setContentType(message.getAttachmentType() == null ? "application/octet-stream" : message.getAttachmentType());
response.setHeader("Content-Disposition", "attachment; filename="" + message.getAttachmentName() + """);
response.setContentLengthLong(Files.size(filePath));
Files.copy(filePath, response.getOutputStream());
} catch (Exception exception) {
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, exception.getMessage());
}
}
private int parseInt(String value) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException exception) {
return 0;
}
}
}

View File

@ -0,0 +1,62 @@
package com.rjlresaka.servlet;
import java.io.IOException;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import com.rjlresaka.dao.PasswordResetDAO;
import com.rjlresaka.dao.UserDAO;
import com.rjlresaka.model.User;
import com.rjlresaka.util.TokenUtil;
@WebServlet("/forgot-password")
public class ForgotPasswordServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
private final UserDAO userDAO = new UserDAO();
private final PasswordResetDAO passwordResetDAO = new PasswordResetDAO();
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
request.getRequestDispatcher("/WEB-INF/views/auth/forgot-password.jsp").forward(request, response);
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String email = request.getParameter("email");
if (email == null || email.trim().isEmpty()) {
request.setAttribute("error", "Entrez votre adresse email.");
request.getRequestDispatcher("/WEB-INF/views/auth/forgot-password.jsp").forward(request, response);
return;
}
try {
User user = userDAO.findByEmail(email.trim().toLowerCase(), getServletContext());
if (user == null) {
request.setAttribute("error", "Aucun compte trouvé avec cet email.");
request.getRequestDispatcher("/WEB-INF/views/auth/forgot-password.jsp").forward(request, response);
return;
}
String token = TokenUtil.randomToken(40);
Timestamp expiresAt = Timestamp.valueOf(LocalDateTime.now().plusMinutes(30));
passwordResetDAO.createToken(user.getId(), token, expiresAt, getServletContext());
request.setAttribute("success", "Lien de réinitialisation généré. Branchez ensuite JavaMail pour l'envoyer par email.");
request.setAttribute("generatedToken", token);
request.setAttribute("resetLink", request.getContextPath() + "/reset-password?token=" + token);
request.getRequestDispatcher("/WEB-INF/views/auth/forgot-password.jsp").forward(request, response);
} catch (Exception exception) {
request.setAttribute("error", "Réinitialisation impossible pour le moment.");
request.setAttribute("debugMessage", exception.getMessage());
request.getRequestDispatcher("/WEB-INF/views/auth/forgot-password.jsp").forward(request, response);
}
}
}

View File

@ -0,0 +1,50 @@
package com.rjlresaka.servlet;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import com.rjlresaka.dao.FriendDAO;
import com.rjlresaka.model.User;
@WebServlet("/app/friends/respond")
public class FriendRequestActionServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
private final FriendDAO friendDAO = new FriendDAO();
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
HttpSession session = request.getSession(false);
if (session == null || session.getAttribute("authUser") == null) {
response.sendRedirect(request.getContextPath() + "/login");
return;
}
User authUser = (User) session.getAttribute("authUser");
int requestId = parseInt(request.getParameter("requestId"));
String action = request.getParameter("action");
boolean accept = "accept".equalsIgnoreCase(action);
try {
friendDAO.respondToRequest(requestId, authUser.getId(), accept, getServletContext());
session.setAttribute("flashSuccess", accept ? "Demande d'ami acceptée." : "Demande refusée.");
} catch (Exception exception) {
session.setAttribute("flashError", "Action impossible : " + exception.getMessage());
}
response.sendRedirect(request.getContextPath() + "/app/dashboard");
}
private int parseInt(String value) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException exception) {
return 0;
}
}
}

View File

@ -0,0 +1,47 @@
package com.rjlresaka.servlet;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import com.rjlresaka.dao.FriendDAO;
import com.rjlresaka.model.User;
@WebServlet("/app/friends/request")
public class FriendRequestServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
private final FriendDAO friendDAO = new FriendDAO();
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
HttpSession session = request.getSession(false);
if (session == null || session.getAttribute("authUser") == null) {
response.sendRedirect(request.getContextPath() + "/login");
return;
}
User authUser = (User) session.getAttribute("authUser");
int receiverId = parseInt(request.getParameter("receiverId"));
try {
friendDAO.sendRequest(authUser.getId(), receiverId, getServletContext());
session.setAttribute("flashSuccess", "Demande d'ami envoyée.");
} catch (Exception exception) {
session.setAttribute("flashError", "Impossible d'envoyer la demande : " + exception.getMessage());
}
response.sendRedirect(request.getContextPath() + "/app/dashboard");
}
private int parseInt(String value) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException exception) {
return 0;
}
}
}

View File

@ -0,0 +1,26 @@
package com.rjlresaka.servlet;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
@WebServlet("/home")
public class HomeServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
HttpSession session = request.getSession(false);
if (session != null && session.getAttribute("authUser") != null) {
response.sendRedirect(request.getContextPath() + "/app/dashboard");
return;
}
request.getRequestDispatcher("/WEB-INF/views/home.jsp").forward(request, response);
}
}

View File

@ -0,0 +1,56 @@
package com.rjlresaka.servlet;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import com.rjlresaka.dao.UserDAO;
import com.rjlresaka.model.User;
import com.rjlresaka.util.PasswordUtil;
@WebServlet("/login")
public class LoginServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
private final UserDAO userDAO = new UserDAO();
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
request.getRequestDispatcher("/WEB-INF/views/auth/login.jsp").forward(request, response);
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String identity = request.getParameter("identity");
String password = request.getParameter("password");
if (identity == null || identity.trim().isEmpty() || password == null || password.isEmpty()) {
request.setAttribute("error", "Veuillez remplir votre email/username et votre mot de passe.");
request.getRequestDispatcher("/WEB-INF/views/auth/login.jsp").forward(request, response);
return;
}
try {
User user = userDAO.findByEmailOrUsername(identity.trim(), getServletContext());
if (user == null || !PasswordUtil.verify(password, user.getPasswordHash())) {
request.setAttribute("error", "Identifiants invalides.");
request.getRequestDispatcher("/WEB-INF/views/auth/login.jsp").forward(request, response);
return;
}
HttpSession session = request.getSession();
session.setAttribute("authUser", user);
response.sendRedirect(request.getContextPath() + "/app/dashboard");
} catch (Exception exception) {
request.setAttribute("error", "Connexion impossible pour le moment. Vérifiez votre base MySQL.");
request.setAttribute("debugMessage", exception.getMessage());
request.getRequestDispatcher("/WEB-INF/views/auth/login.jsp").forward(request, response);
}
}
}

View File

@ -0,0 +1,25 @@
package com.rjlresaka.servlet;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
@WebServlet("/logout")
public class LogoutServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
response.sendRedirect(request.getContextPath() + "/login");
}
}

View File

@ -0,0 +1,94 @@
package com.rjlresaka.servlet;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import com.rjlresaka.dao.UserDAO;
import com.rjlresaka.model.User;
import com.rjlresaka.util.PasswordUtil;
@WebServlet("/register")
public class RegisterServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
private final UserDAO userDAO = new UserDAO();
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
request.getRequestDispatcher("/WEB-INF/views/auth/register.jsp").forward(request, response);
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String fullName = trim(request.getParameter("fullName"));
String username = trim(request.getParameter("username"));
String email = trim(request.getParameter("email"));
String password = request.getParameter("password");
String confirmPassword = request.getParameter("confirmPassword");
if (fullName.isEmpty() || username.isEmpty() || email.isEmpty() || password == null || password.isEmpty()) {
request.setAttribute("error", "Tous les champs sont obligatoires.");
request.getRequestDispatcher("/WEB-INF/views/auth/register.jsp").forward(request, response);
return;
}
if (password.length() < 6) {
request.setAttribute("error", "Le mot de passe doit contenir au moins 6 caractères.");
request.getRequestDispatcher("/WEB-INF/views/auth/register.jsp").forward(request, response);
return;
}
if (!password.equals(confirmPassword)) {
request.setAttribute("error", "La confirmation du mot de passe ne correspond pas.");
request.getRequestDispatcher("/WEB-INF/views/auth/register.jsp").forward(request, response);
return;
}
try {
if (userDAO.emailExists(email, getServletContext())) {
request.setAttribute("error", "Cet email existe déjà.");
request.getRequestDispatcher("/WEB-INF/views/auth/register.jsp").forward(request, response);
return;
}
if (userDAO.usernameExists(username, getServletContext())) {
request.setAttribute("error", "Ce nom d'utilisateur existe déjà.");
request.getRequestDispatcher("/WEB-INF/views/auth/register.jsp").forward(request, response);
return;
}
User user = new User();
user.setFullName(fullName);
user.setUsername(username);
user.setEmail(email.toLowerCase());
user.setPasswordHash(PasswordUtil.hash(password));
user.setAvatarColor(pickColor(fullName));
user.setBio("Nouveau membre RJLResaka");
userDAO.create(user, getServletContext());
HttpSession session = request.getSession();
session.setAttribute("authUser", user);
response.sendRedirect(request.getContextPath() + "/app/dashboard");
} catch (Exception exception) {
request.setAttribute("error", "Inscription impossible pour le moment.");
request.setAttribute("debugMessage", exception.getMessage());
request.getRequestDispatcher("/WEB-INF/views/auth/register.jsp").forward(request, response);
}
}
private String trim(String value) {
return value == null ? "" : value.trim();
}
private String pickColor(String seed) {
String[] colors = { "#0ea5e9", "#2563eb", "#06b6d4", "#14b8a6", "#f97316", "#ec4899" };
int index = Math.abs(seed.hashCode()) % colors.length;
return colors[index];
}
}

View File

@ -0,0 +1,74 @@
package com.rjlresaka.servlet;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import com.rjlresaka.dao.PasswordResetDAO;
import com.rjlresaka.dao.UserDAO;
import com.rjlresaka.util.PasswordUtil;
@WebServlet("/reset-password")
public class ResetPasswordServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
private final PasswordResetDAO passwordResetDAO = new PasswordResetDAO();
private final UserDAO userDAO = new UserDAO();
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
request.getRequestDispatcher("/WEB-INF/views/auth/reset-password.jsp").forward(request, response);
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String token = request.getParameter("token");
String password = request.getParameter("password");
String confirmPassword = request.getParameter("confirmPassword");
if (token == null || token.trim().isEmpty()) {
request.setAttribute("error", "Le token de réinitialisation est manquant.");
request.getRequestDispatcher("/WEB-INF/views/auth/reset-password.jsp").forward(request, response);
return;
}
if (password == null || password.length() < 6) {
request.setAttribute("error", "Le nouveau mot de passe doit contenir au moins 6 caractères.");
request.setAttribute("token", token);
request.getRequestDispatcher("/WEB-INF/views/auth/reset-password.jsp").forward(request, response);
return;
}
if (!password.equals(confirmPassword)) {
request.setAttribute("error", "La confirmation du mot de passe ne correspond pas.");
request.setAttribute("token", token);
request.getRequestDispatcher("/WEB-INF/views/auth/reset-password.jsp").forward(request, response);
return;
}
try {
Integer userId = passwordResetDAO.findValidUserIdByToken(token.trim(), getServletContext());
if (userId == null) {
request.setAttribute("error", "Le lien est invalide ou expiré.");
request.setAttribute("token", token);
request.getRequestDispatcher("/WEB-INF/views/auth/reset-password.jsp").forward(request, response);
return;
}
userDAO.updatePassword(userId.intValue(), PasswordUtil.hash(password), getServletContext());
passwordResetDAO.markAsUsed(token.trim(), getServletContext());
request.setAttribute("success", "Mot de passe mis à jour. Vous pouvez vous connecter.");
request.getRequestDispatcher("/WEB-INF/views/auth/login.jsp").forward(request, response);
} catch (Exception exception) {
request.setAttribute("error", "Impossible de mettre à jour le mot de passe.");
request.setAttribute("debugMessage", exception.getMessage());
request.setAttribute("token", token);
request.getRequestDispatcher("/WEB-INF/views/auth/reset-password.jsp").forward(request, response);
}
}
}

View File

@ -0,0 +1,99 @@
package com.rjlresaka.servlet;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.annotation.MultipartConfig;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import javax.servlet.http.Part;
import com.rjlresaka.dao.MessageDAO;
import com.rjlresaka.model.Message;
import com.rjlresaka.model.StoredAttachment;
import com.rjlresaka.model.User;
import com.rjlresaka.util.FileStorageUtil;
@WebServlet("/app/messages/send")
@MultipartConfig(fileSizeThreshold = 1024 * 1024, maxFileSize = 10 * 1024 * 1024, maxRequestSize = 12 * 1024 * 1024)
public class SendMessageServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
private final MessageDAO messageDAO = new MessageDAO();
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
HttpSession session = request.getSession(false);
if (session == null || session.getAttribute("authUser") == null) {
response.sendRedirect(request.getContextPath() + "/login");
return;
}
User authUser = (User) session.getAttribute("authUser");
int conversationId = parseInt(request.getParameter("conversationId"));
int partnerId = parseInt(request.getParameter("partnerId"));
String body = trim(request.getParameter("body"));
Part attachmentPart = request.getPart("attachment");
try {
StoredAttachment attachment = FileStorageUtil.store(attachmentPart, getServletContext());
if (body.isEmpty() && attachment == null) {
setFlash(session, "flashError", "Ajoutez un texte ou un fichier avant d'envoyer.");
response.sendRedirect(request.getContextPath() + redirectTarget(conversationId, partnerId));
return;
}
Message draft = new Message();
draft.setBody(body);
if (attachment != null) {
draft.setAttachmentName(attachment.getOriginalName());
draft.setAttachmentPath(attachment.getStoragePath());
draft.setAttachmentType(attachment.getContentType());
draft.setAttachmentSize(attachment.getSize());
}
if (conversationId > 0) {
messageDAO.createMessageInConversation(authUser.getId(), conversationId, draft, getServletContext());
} else if (partnerId > 0) {
conversationId = messageDAO.createMessage(authUser.getId(), partnerId, draft, getServletContext());
} else {
setFlash(session, "flashError", "Conversation introuvable.");
response.sendRedirect(request.getContextPath() + "/app/dashboard");
return;
}
setFlash(session, "flashSuccess", "Message envoyé.");
} catch (Exception exception) {
setFlash(session, "flashError", "Envoi impossible pour le moment : " + exception.getMessage());
}
response.sendRedirect(request.getContextPath() + "/app/dashboard?conversation=" + conversationId);
}
private String redirectTarget(int conversationId, int partnerId) {
if (conversationId > 0) {
return "/app/dashboard?conversation=" + conversationId;
}
if (partnerId > 0) {
return "/app/dashboard?user=" + partnerId;
}
return "/app/dashboard";
}
private int parseInt(String value) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException exception) {
return 0;
}
}
private String trim(String value) {
return value == null ? "" : value.trim();
}
private void setFlash(HttpSession session, String key, String message) {
session.setAttribute(key, message);
}
}

View File

@ -0,0 +1,49 @@
package com.rjlresaka.servlet;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import com.rjlresaka.dao.MessageDAO;
import com.rjlresaka.model.User;
@WebServlet("/app/messages/react")
public class ToggleReactionServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
private final MessageDAO messageDAO = new MessageDAO();
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
HttpSession session = request.getSession(false);
if (session == null || session.getAttribute("authUser") == null) {
response.sendRedirect(request.getContextPath() + "/login");
return;
}
User authUser = (User) session.getAttribute("authUser");
int conversationId = parseInt(request.getParameter("conversationId"));
int messageId = parseInt(request.getParameter("messageId"));
String emoji = request.getParameter("emoji");
try {
messageDAO.toggleReaction(messageId, authUser.getId(), emoji, getServletContext());
} catch (Exception exception) {
session.setAttribute("flashError", "Réaction indisponible : " + exception.getMessage());
}
response.sendRedirect(request.getContextPath() + "/app/dashboard?conversation=" + conversationId);
}
private int parseInt(String value) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException exception) {
return 0;
}
}
}

View File

@ -0,0 +1,51 @@
package com.rjlresaka.servlet;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import com.rjlresaka.dao.MessageDAO;
import com.rjlresaka.model.User;
@WebServlet("/app/messages/update")
public class UpdateMessageServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
private final MessageDAO messageDAO = new MessageDAO();
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
HttpSession session = request.getSession(false);
if (session == null || session.getAttribute("authUser") == null) {
response.sendRedirect(request.getContextPath() + "/login");
return;
}
User authUser = (User) session.getAttribute("authUser");
int conversationId = parseInt(request.getParameter("conversationId"));
int messageId = parseInt(request.getParameter("messageId"));
String body = request.getParameter("body");
try {
boolean updated = messageDAO.updateMessage(messageId, authUser.getId(), body, getServletContext());
session.setAttribute(updated ? "flashSuccess" : "flashError",
updated ? "Message modifié." : "Modification refusée.");
} catch (Exception exception) {
session.setAttribute("flashError", "Erreur de modification : " + exception.getMessage());
}
response.sendRedirect(request.getContextPath() + "/app/dashboard?conversation=" + conversationId);
}
private int parseInt(String value) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException exception) {
return 0;
}
}
}

View File

@ -0,0 +1 @@
package com.rjlresaka.servlet;

View File

@ -0,0 +1,23 @@
package com.rjlresaka.util;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import javax.servlet.ServletContext;
public final class DatabaseConnection {
private DatabaseConnection() {
}
public static Connection getConnection(ServletContext context) throws SQLException, ClassNotFoundException {
String driver = context.getInitParameter("db.driver");
String url = context.getInitParameter("db.url");
String user = context.getInitParameter("db.user");
String password = context.getInitParameter("db.password");
Class.forName(driver);
return DriverManager.getConnection(url, user, password);
}
}

View File

@ -0,0 +1,91 @@
package com.rjlresaka.util;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.Locale;
import java.util.UUID;
import javax.servlet.ServletContext;
import javax.servlet.http.Part;
import com.rjlresaka.model.StoredAttachment;
public final class FileStorageUtil {
private FileStorageUtil() {
}
public static StoredAttachment store(Part part, ServletContext context) throws IOException {
if (part == null || part.getSize() <= 0) {
return null;
}
String originalName = cleanFilename(part.getSubmittedFileName());
if (originalName.isEmpty()) {
return null;
}
String storedName = UUID.randomUUID().toString().replace("-", "") + "_" + originalName;
Path uploadDirectory = resolveUploadDirectory(context);
Files.createDirectories(uploadDirectory);
Path target = uploadDirectory.resolve(storedName);
try (InputStream inputStream = part.getInputStream()) {
Files.copy(inputStream, target, StandardCopyOption.REPLACE_EXISTING);
}
StoredAttachment attachment = new StoredAttachment();
attachment.setOriginalName(originalName);
attachment.setStoragePath(storedName);
attachment.setContentType(part.getContentType());
attachment.setSize(part.getSize());
return attachment;
}
public static Path resolveStoredFile(String storedName, ServletContext context) throws IOException {
if (storedName == null || storedName.contains("..") || storedName.contains("/") || storedName.contains("\\")) {
throw new IOException("Nom de fichier invalide.");
}
return resolveUploadDirectory(context).resolve(storedName);
}
private static Path resolveUploadDirectory(ServletContext context) {
String configuredDir = context.getInitParameter("upload.dir");
if (configuredDir == null || configuredDir.trim().isEmpty()) {
configuredDir = "uploads";
}
configuredDir = configuredDir.replace("\\", "/").replace("..", "").trim();
String realRoot = context.getRealPath("/");
if (realRoot != null && !realRoot.trim().isEmpty()) {
return Paths.get(realRoot, "WEB-INF", configuredDir);
}
return Paths.get(System.getProperty("java.io.tmpdir"), "rjlresaka", configuredDir);
}
private static String cleanFilename(String filename) {
if (filename == null) {
return "";
}
String cleaned = filename.replace("\\", "/");
int slashIndex = cleaned.lastIndexOf('/');
if (slashIndex >= 0) {
cleaned = cleaned.substring(slashIndex + 1);
}
cleaned = cleaned.trim().replaceAll("[^a-zA-Z0-9._-]", "_");
if (cleaned.length() > 120) {
String extension = "";
int dotIndex = cleaned.lastIndexOf('.');
if (dotIndex > 0) {
extension = cleaned.substring(dotIndex).toLowerCase(Locale.ROOT);
cleaned = cleaned.substring(0, dotIndex);
}
cleaned = cleaned.substring(0, 100) + extension;
}
return cleaned;
}
}

View File

@ -0,0 +1,20 @@
package com.rjlresaka.util;
import org.mindrot.jbcrypt.BCrypt;
public final class PasswordUtil {
private PasswordUtil() {
}
public static String hash(String plainPassword) {
return BCrypt.hashpw(plainPassword, BCrypt.gensalt(10));
}
public static boolean verify(String plainPassword, String hash) {
if (plainPassword == null || hash == null || hash.isEmpty()) {
return false;
}
return BCrypt.checkpw(plainPassword, hash);
}
}

View File

@ -0,0 +1,19 @@
package com.rjlresaka.util;
import java.security.SecureRandom;
public final class TokenUtil {
private static final String ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789";
private static final SecureRandom RANDOM = new SecureRandom();
private TokenUtil() {
}
public static String randomToken(int length) {
StringBuilder sb = new StringBuilder(length);
for (int i = 0; i < length; i++) {
sb.append(ALPHABET.charAt(RANDOM.nextInt(ALPHABET.length())));
}
return sb.toString();
}
}

View File

@ -0,0 +1 @@
package com.rjlresaka.util;

BIN
RJLResaka_final.zip Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

View File

@ -1,3 +1,33 @@
from django.contrib import admin from django.contrib import admin
# Register your models here. from .models import Conversation, Message, ResakaProfile
@admin.register(ResakaProfile)
class ResakaProfileAdmin(admin.ModelAdmin):
list_display = ("display_name", "handle", "status_text", "created_at")
search_fields = ("display_name", "handle", "status_text")
class MessageInline(admin.TabularInline):
model = Message
extra = 0
readonly_fields = ("created_at",)
@admin.register(Conversation)
class ConversationAdmin(admin.ModelAdmin):
list_display = ("subject", "starter", "recipient", "updated_at")
search_fields = ("subject", "starter__display_name", "recipient__display_name")
inlines = [MessageInline]
@admin.register(Message)
class MessageAdmin(admin.ModelAdmin):
list_display = ("conversation", "author", "short_body", "reaction", "is_read", "created_at")
list_filter = ("is_read", "reaction", "created_at")
search_fields = ("body", "author__display_name")
@staticmethod
def short_body(obj):
return obj.body[:60]

101
core/forms.py Normal file
View File

@ -0,0 +1,101 @@
from django import forms
from django.utils.text import slugify
from .models import Message, ResakaProfile
AVATAR_CHOICES = [
("#22D3EE", "Lagoon"),
("#FF7A59", "Sunset"),
("#9EF3D5", "Mint"),
("#F7C948", "Gold"),
]
REACTION_CHOICES = [
("❤️", "Love"),
("🔥", "Fire"),
("😂", "Laugh"),
("👏", "Clap"),
]
class ProfileForm(forms.ModelForm):
class Meta:
model = ResakaProfile
fields = ["display_name", "handle", "status_text", "avatar_color"]
widgets = {
"display_name": forms.TextInput(attrs={"placeholder": "Ex. Rija", "class": "form-control"}),
"handle": forms.TextInput(attrs={"placeholder": "ex. rija_resaka", "class": "form-control"}),
"status_text": forms.TextInput(attrs={"placeholder": "Disponible pour discuter ce soir", "class": "form-control"}),
"avatar_color": forms.Select(attrs={"class": "form-select"}, choices=AVATAR_CHOICES),
}
labels = {
"display_name": "Nom affiché",
"handle": "Pseudo",
"status_text": "Statut",
"avatar_color": "Couleur avatar",
}
def clean_handle(self):
handle = slugify(self.cleaned_data["handle"])
if not handle:
raise forms.ValidationError("Choisissez un pseudo valide.")
if ResakaProfile.objects.filter(handle=handle).exists():
raise forms.ValidationError("Ce pseudo existe déjà.")
return handle
class ConversationStartForm(forms.Form):
recipient = forms.ModelChoiceField(
queryset=ResakaProfile.objects.none(),
label="Destinataire",
widget=forms.Select(attrs={"class": "form-select"}),
)
body = forms.CharField(
label="Premier message",
max_length=1000,
widget=forms.Textarea(
attrs={
"rows": 3,
"placeholder": "Écris ton premier message privé…",
"class": "form-control",
}
),
)
def __init__(self, *args, active_profile=None, **kwargs):
super().__init__(*args, **kwargs)
queryset = ResakaProfile.objects.all().order_by("display_name")
if active_profile:
queryset = queryset.exclude(pk=active_profile.pk)
self.fields["recipient"].queryset = queryset
self.active_profile = active_profile
def clean_recipient(self):
recipient = self.cleaned_data["recipient"]
if self.active_profile and recipient.pk == self.active_profile.pk:
raise forms.ValidationError("Choisissez un autre profil pour démarrer une discussion.")
return recipient
class MessageForm(forms.ModelForm):
class Meta:
model = Message
fields = ["body"]
widgets = {
"body": forms.Textarea(
attrs={
"rows": 3,
"placeholder": "Tapez un message privé ou ajoutez des emojis…",
"class": "form-control js-message-body",
}
)
}
labels = {"body": "Message"}
class ReactionForm(forms.Form):
reaction = forms.ChoiceField(
choices=REACTION_CHOICES,
widget=forms.HiddenInput(),
)

View File

@ -0,0 +1,66 @@
# Generated by Django 5.2.7 on 2026-04-05 20:10
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Conversation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('subject', models.CharField(blank=True, max_length=120)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['-updated_at', '-created_at'],
},
),
migrations.CreateModel(
name='ResakaProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('display_name', models.CharField(max_length=80)),
('handle', models.SlugField(max_length=80, unique=True)),
('status_text', models.CharField(blank=True, max_length=140)),
('avatar_color', models.CharField(default='#22D3EE', max_length=7)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['display_name'],
},
),
migrations.CreateModel(
name='Message',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('body', models.TextField()),
('reaction', models.CharField(blank=True, max_length=8)),
('is_read', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='core.conversation')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='core.resakaprofile')),
],
options={
'ordering': ['created_at'],
},
),
migrations.AddField(
model_name='conversation',
name='recipient',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_conversations', to='core.resakaprofile'),
),
migrations.AddField(
model_name='conversation',
name='starter',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='started_conversations', to='core.resakaprofile'),
),
]

View File

@ -1,3 +1,70 @@
from django.db import models from django.db import models
from django.urls import reverse
# Create your models here.
class ResakaProfile(models.Model):
display_name = models.CharField(max_length=80)
handle = models.SlugField(max_length=80, unique=True)
status_text = models.CharField(max_length=140, blank=True)
avatar_color = models.CharField(max_length=7, default="#22D3EE")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["display_name"]
def __str__(self):
return f"{self.display_name} (@{self.handle})"
class Conversation(models.Model):
starter = models.ForeignKey(
ResakaProfile,
on_delete=models.CASCADE,
related_name="started_conversations",
)
recipient = models.ForeignKey(
ResakaProfile,
on_delete=models.CASCADE,
related_name="received_conversations",
)
subject = models.CharField(max_length=120, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-updated_at", "-created_at"]
def __str__(self):
return self.subject or f"{self.starter.display_name}{self.recipient.display_name}"
def counterpart_for(self, profile):
if profile and self.starter_id == profile.id:
return self.recipient
return self.starter
def get_absolute_url(self):
return reverse("conversation_detail", args=[self.pk])
class Message(models.Model):
conversation = models.ForeignKey(
Conversation,
on_delete=models.CASCADE,
related_name="messages",
)
author = models.ForeignKey(
ResakaProfile,
on_delete=models.CASCADE,
related_name="messages",
)
body = models.TextField()
reaction = models.CharField(max_length=8, blank=True)
is_read = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["created_at"]
def __str__(self):
preview = (self.body[:30] + "") if len(self.body) > 30 else self.body
return f"{self.author.display_name}: {preview}"

View File

@ -1,25 +1,57 @@
{% load static %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="fr">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>{% block title %}Knowledge Base{% endblock %}</title> <meta name="viewport" content="width=device-width, initial-scale=1">
{% if project_description %} <title>{% block title %}{{ page_title|default:project_name|default:"RJL Resaka" }}{% endblock %}</title>
<meta name="description" content="{{ project_description }}"> <meta name="description" content="{{ page_description|default:project_description|default:'Messagerie privée moderne pour échanges rapides et notifications de lecture.' }}">
<meta property="og:description" content="{{ project_description }}"> <meta name="author" content="Flatlogic">
<meta property="twitter:description" content="{{ project_description }}"> <meta name="keywords" content="chat privé, messagerie, discussion, RJL Resaka, messenger">
{% endif %} <link rel="preconnect" href="https://fonts.googleapis.com">
{% if project_image_url %} <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<meta property="og:image" content="{{ project_image_url }}"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@600;700;800&display=swap" rel="stylesheet">
<meta property="twitter:image" content="{{ project_image_url }}"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
{% endif %}
{% load static %}
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}"> <link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
<body class="resaka-body">
<div class="resaka-shell">
<header class="site-header py-3">
<div class="container-xl">
<nav class="navbar navbar-expand-lg navbar-dark p-0">
<a class="navbar-brand brand-mark" href="{% url 'home' %}">RJL <span>Resaka</span></a>
<button class="navbar-toggler border-0 shadow-none" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Basculer la navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse justify-content-end" id="mainNav">
<ul class="navbar-nav align-items-lg-center gap-lg-2">
<li class="nav-item"><a class="nav-link" href="{% url 'home' %}{{ profile_query|default:'' }}">Accueil</a></li>
<li class="nav-item"><a class="nav-link" href="#workflow">Discussion</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/">Admin</a></li>
{% if active_profile %}
<li class="nav-item profile-pill ms-lg-3">Actif : @{{ active_profile.handle }}</li>
{% endif %}
</ul>
</div>
</nav>
</div>
</header>
<body> <main>
{% if messages %}
<div class="container-xl flash-stack">
{% for message in messages %}
<div class="alert alert-{{ message.tags|default:'info' }} shadow-sm border-0" role="alert">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</body> </main>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<script src="{% static 'js/app.js' %}?v={{ deployment_timestamp }}" defer></script>
{% block scripts %}{% endblock %}
</body>
</html> </html>

View File

@ -0,0 +1,106 @@
{% extends "base.html" %}
{% block content %}
<section class="detail-section">
<div class="container-xl">
<div class="detail-grid">
<aside class="card-panel detail-side-panel">
<a class="back-link" href="{% url 'home' %}?profile={{ active_profile.pk }}">← Retour à l'accueil</a>
<div class="profile-spotlight mt-4">
<div class="avatar-orb avatar-xl" style="background: {{ counterpart.avatar_color }};">
{{ counterpart.display_name|slice:":1"|upper }}
</div>
<h1>{{ counterpart.display_name }}</h1>
<p>@{{ counterpart.handle }}</p>
{% if counterpart.status_text %}<p class="muted-copy">{{ counterpart.status_text }}</p>{% endif %}
</div>
<div class="info-card">
<h3>Discussion privée</h3>
<ul class="meta-list">
<li><span>Sujet</span><strong>{{ conversation.subject }}</strong></li>
<li><span>Créée</span><strong>{{ conversation.created_at|date:"d/m/Y H:i" }}</strong></li>
<li><span>Actif</span><strong>@{{ active_profile.handle }}</strong></li>
<li><span>Messages</span><strong>{{ messages_list|length }}</strong></li>
</ul>
</div>
<div class="info-card">
<h3>Réactions rapides</h3>
<p class="muted-copy">Ajoutez une réaction emoji sur n'importe quel message, comme dans Messenger.</p>
<div class="reaction-preview-row">
{% for value, label in reaction_choices %}
<span class="reaction-chip static">{{ value }} {{ label }}</span>
{% endfor %}
</div>
</div>
</aside>
<div class="card-panel detail-chat-panel">
<div class="thread-header">
<div>
<span class="eyebrow">Conversation active</span>
<h2>{{ counterpart.display_name }}</h2>
<p>Messages privés, suivi de lecture et interface responsive.</p>
</div>
<span class="presence-chip"><span class="presence-dot"></span> notifications lues lors de l'ouverture</span>
</div>
<div class="message-thread">
{% for message in messages_list %}
<article class="message-row {% if message.author_id == active_profile.id %}is-own{% endif %}">
<div class="message-bubble-wrap">
<div class="message-bubble">
<div class="message-meta">
<strong>{{ message.author.display_name }}</strong>
<span>{{ message.created_at|date:"H:i" }}</span>
{% if message.author_id == active_profile.id and message.is_read %}<span class="read-chip">Lu</span>{% endif %}
</div>
<p>{{ message.body|linebreaksbr }}</p>
{% if message.reaction %}<div class="reaction-pill">{{ message.reaction }}</div>{% endif %}
</div>
<div class="reaction-actions">
{% for value, label in reaction_choices %}
<form method="post" action="{% url 'react_message' message.pk %}?profile={{ active_profile.pk }}">
{% csrf_token %}
<input type="hidden" name="reaction" value="{{ value }}">
<button type="submit" class="reaction-chip" aria-label="Réagir avec {{ label }}">{{ value }}</button>
</form>
{% endfor %}
</div>
</div>
</article>
{% empty %}
<div class="empty-state compact">
<div class="empty-illustration">✉️</div>
<h4>Commencez la conversation</h4>
<p>Envoyez le premier message depuis le bloc ci-dessous.</p>
</div>
{% endfor %}
</div>
<div class="composer-panel">
<form method="post" class="resaka-form">
{% csrf_token %}
<label class="form-label" for="{{ message_form.body.id_for_label }}">Nouveau message</label>
{{ message_form.body }}
{% if message_form.body.errors %}<div class="form-error">{{ message_form.body.errors|striptags }}</div>{% endif %}
<div class="helper-row mt-3 d-flex flex-wrap gap-2">
<span class="helper-label">Emojis :</span>
<button class="emoji-chip" type="button" data-emoji="❤️">❤️</button>
<button class="emoji-chip" type="button" data-emoji="😂">😂</button>
<button class="emoji-chip" type="button" data-emoji="🔥">🔥</button>
<button class="emoji-chip" type="button" data-emoji="🎉">🎉</button>
</div>
<div class="d-flex flex-wrap gap-3 mt-4">
<button type="submit" class="btn btn-brand">Envoyer le message</button>
<a class="btn btn-ghost" href="{% url 'home' %}?profile={{ active_profile.pk }}">Retour à la liste</a>
</div>
</form>
</div>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -1,145 +1,193 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ project_name }}{% endblock %}
{% block head %}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M-10 10L110 10M10 -10L10 110' stroke-width='1' stroke='rgba(255,255,255,0.05)'/></svg>");
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% {
background-position: 0% 0%;
}
100% {
background-position: 100% 100%;
}
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2.5rem 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
}
h1 {
font-size: clamp(2.2rem, 3vw + 1.2rem, 3.2rem);
font-weight: 700;
margin: 0 0 1.2rem;
letter-spacing: -0.02em;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
opacity: 0.92;
}
.loader {
margin: 1.5rem auto;
width: 56px;
height: 56px;
border: 4px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.runtime code {
background: rgba(0, 0, 0, 0.25);
padding: 0.15rem 0.45rem;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
footer {
position: absolute;
bottom: 1rem;
width: 100%;
text-align: center;
font-size: 0.85rem;
opacity: 0.75;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<main> <section class="hero-section">
<div class="card"> <div class="container-xl">
<h1>Analyzing your requirements and generating your app…</h1> <div class="row align-items-center g-4 g-xl-5">
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes"> <div class="col-lg-7">
<span class="sr-only">Loading…</span> <div class="hero-copy">
</div> <span class="eyebrow">Messagerie privée • design inspiré des apps sociales</span>
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p> <h1 class="display-title">Discutez en privé, avec style, notifications et emojis.</h1>
<p class="hint">This page will refresh automatically as the plan is implemented.</p> <p class="lead-copy">
<p class="runtime"> RJL Resaka transforme votre sujet d'examen en première expérience utilisable : créez un profil,
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code> lancez une discussion privée, suivez les messages non lus et réagissez avec des emojis.
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
</p> </p>
<div class="hero-actions d-flex flex-wrap gap-3">
<a class="btn btn-brand btn-lg" href="#workflow">Commencer une discussion</a>
<a class="btn btn-ghost btn-lg" href="/admin/">Ouvrir l'admin</a>
</div> </div>
</main> <div class="hero-stats row g-3 mt-2">
<footer> <div class="col-sm-4">
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC) <div class="metric-card">
</footer> <strong>{{ stats.profiles }}</strong>
<span>profils créés</span>
</div>
</div>
<div class="col-sm-4">
<div class="metric-card">
<strong>{{ stats.conversations }}</strong>
<span>discussions</span>
</div>
</div>
<div class="col-sm-4">
<div class="metric-card">
<strong>{{ stats.messages }}</strong>
<span>messages envoyés</span>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-5">
<div class="hero-preview card-panel chat-preview-panel">
<div class="preview-window">
<div class="preview-topbar">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
</div>
<div class="preview-thread">
<div class="bubble bubble-incoming">Salut 👋 on peut discuter ce soir ?</div>
<div class="bubble bubble-outgoing">Oui, je t'envoie le brief du projet RJL Resaka.</div>
<div class="bubble bubble-incoming bubble-accent">Top, j'adore le style Messenger + réactions ❤️</div>
</div>
<div class="preview-footer">
<span class="presence-dot"></span>
<span>Notifications de lecture + interface responsive</span>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section id="workflow" class="workflow-section">
<div class="container-xl">
<div class="section-heading">
<span class="eyebrow">Premier MVP slice</span>
<h2>Créer un profil, démarrer un chat, suivre les non-lus.</h2>
<p>Une vraie boucle de valeur : entrée, confirmation, liste des discussions et détail conversationnel.</p>
</div>
<div class="row g-4 align-items-start">
<div class="col-xl-4">
<div class="card-panel stack-panel h-100">
<div class="panel-header">
<h3>1. Votre identité</h3>
<p>Le MVP permet de simuler des profils pour tester une messagerie privée sans flux d'inscription complet.</p>
</div>
{% if profiles %}
<form method="get" class="profile-switcher mb-4">
<label class="form-label" for="profile-select">Profil actif</label>
<select class="form-select" id="profile-select" name="profile" onchange="this.form.submit()">
{% for profile in profiles %}
<option value="{{ profile.pk }}" {% if active_profile and profile.pk == active_profile.pk %}selected{% endif %}>{{ profile.display_name }} · @{{ profile.handle }}</option>
{% endfor %}
</select>
</form>
{% endif %}
<form method="post" action="{% url 'create_profile' %}" class="resaka-form" novalidate>
{% csrf_token %}
{% for field in profile_form %}
<div class="mb-3">
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.errors %}<div class="form-error">{{ field.errors|striptags }}</div>{% endif %}
</div>
{% endfor %}
<button type="submit" class="btn btn-brand w-100">Créer ce profil</button>
</form>
</div>
</div>
<div class="col-xl-8">
<div class="card-panel mb-4">
<div class="panel-header d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-center">
<div>
<h3>2. Démarrer une discussion privée</h3>
<p>{% if active_profile %}Vous envoyez le premier message en tant que <strong>{{ active_profile.display_name }}</strong>.{% else %}Créez d'abord un profil pour activer le chat.{% endif %}</p>
</div>
{% if active_profile %}
<div class="presence-chip"><span class="presence-dot"></span> Connecté comme @{{ active_profile.handle }}</div>
{% endif %}
</div>
<form method="post" action="{% url 'start_conversation' %}" class="resaka-form">
{% csrf_token %}
<div class="row g-3">
<div class="col-md-4">
<label class="form-label" for="{{ conversation_form.recipient.id_for_label }}">{{ conversation_form.recipient.label }}</label>
{{ conversation_form.recipient }}
{% if conversation_form.recipient.errors %}<div class="form-error">{{ conversation_form.recipient.errors|striptags }}</div>{% endif %}
</div>
<div class="col-md-8">
<label class="form-label" for="{{ conversation_form.body.id_for_label }}">{{ conversation_form.body.label }}</label>
{{ conversation_form.body }}
{% if conversation_form.body.errors %}<div class="form-error">{{ conversation_form.body.errors|striptags }}</div>{% endif %}
</div>
</div>
<div class="helper-row mt-3 d-flex flex-wrap gap-2">
<span class="helper-label">Ajouts rapides :</span>
<button class="emoji-chip" type="button" data-emoji="😊">😊 Bonjour</button>
<button class="emoji-chip" type="button" data-emoji="🔥">🔥 Update</button>
<button class="emoji-chip" type="button" data-emoji="👍">👍 OK</button>
<button class="emoji-chip" type="button" data-emoji="🎯">🎯 Important</button>
</div>
<button type="submit" class="btn btn-brand mt-4">Créer ou continuer la conversation</button>
</form>
</div>
<div class="card-panel">
<div class="panel-header d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-center">
<div>
<h3>3. Discussions récentes</h3>
<p>Liste responsive avec aperçu du dernier message et badge de notifications non lues.</p>
</div>
{% if active_profile %}
<span class="panel-note">Affichage filtré pour @{{ active_profile.handle }}</span>
{% endif %}
</div>
{% if conversations %}
<div class="conversation-list">
{% for conversation in conversations %}
<a class="conversation-card" href="{% url 'conversation_detail' conversation.pk %}?profile={{ active_profile.pk }}">
<div class="avatar-orb" style="background: {{ conversation.counterpart.avatar_color }};">
{{ conversation.counterpart.display_name|slice:":1"|upper }}
</div>
<div class="conversation-copy">
<div class="conversation-head">
<h4>{{ conversation.counterpart.display_name }}</h4>
<span>{{ conversation.updated_at|date:"d M · H:i" }}</span>
</div>
<p>@{{ conversation.counterpart.handle }}{% if conversation.counterpart.status_text %} · {{ conversation.counterpart.status_text }}{% endif %}</p>
{% if conversation.last_message %}
<strong>{{ conversation.last_message.author.display_name }}</strong>
<span>{{ conversation.last_message.body|truncatechars:90 }}</span>
{% else %}
<span>Aucun message pour le moment.</span>
{% endif %}
</div>
<div class="conversation-meta">
{% if conversation.unread_count %}
<span class="badge unread-badge">{{ conversation.unread_count }} non lus</span>
{% endif %}
<span class="open-link">Ouvrir</span>
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-illustration">💬</div>
<h4>Aucune discussion pour l'instant</h4>
<p>Créez un profil puis envoyez un premier message privé pour voir la liste s'animer ici.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</section>
{% endblock %} {% endblock %}

View File

@ -1,7 +1,11 @@
from django.urls import path from django.urls import path
from .views import home from .views import conversation_detail, create_profile, home, react_message, start_conversation
urlpatterns = [ urlpatterns = [
path("", home, name="home"), path("", home, name="home"),
path("profiles/create/", create_profile, name="create_profile"),
path("conversations/start/", start_conversation, name="start_conversation"),
path("conversations/<int:pk>/", conversation_detail, name="conversation_detail"),
path("messages/<int:pk>/react/", react_message, name="react_message"),
] ]

View File

@ -2,24 +2,263 @@ import os
import platform import platform
from django import get_version as django_version from django import get_version as django_version
from django.shortcuts import render from django.contrib import messages
from django.db.models import Count, Prefetch, Q
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from .forms import ConversationStartForm, MessageForm, ProfileForm, REACTION_CHOICES, ReactionForm
from .models import Conversation, Message, ResakaProfile
DEFAULT_META_DESCRIPTION = (
"RJL Resaka est une messagerie privée moderne pour lancer des conversations, "
"envoyer des messages et suivre les notifications de lecture."
)
def _profile_from_request(request):
profiles = ResakaProfile.objects.order_by("display_name")
profile = None
requested_id = request.GET.get("profile") or request.session.get("active_profile_id")
if requested_id:
try:
profile = profiles.get(pk=requested_id)
except (ResakaProfile.DoesNotExist, ValueError, TypeError):
profile = None
if not profile and profiles.exists():
profile = profiles.first()
if profile:
request.session["active_profile_id"] = profile.pk
return profile, profiles
def _profile_query_suffix(profile):
return f"?profile={profile.pk}" if profile else ""
def _conversation_queryset(active_profile):
base = Conversation.objects.select_related("starter", "recipient").prefetch_related(
Prefetch(
"messages",
queryset=Message.objects.select_related("author").order_by("created_at"),
)
)
if active_profile:
base = base.filter(Q(starter=active_profile) | Q(recipient=active_profile)).annotate(
unread_count=Count(
"messages",
filter=Q(messages__is_read=False) & ~Q(messages__author=active_profile),
)
)
else:
base = base.none()
return base.order_by("-updated_at")
def home(request): def home(request):
"""Render the landing screen with loader and environment details."""
host_name = request.get_host().lower() host_name = request.get_host().lower()
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic" agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
now = timezone.now() now = timezone.now()
active_profile, profiles = _profile_from_request(request)
conversations = list(_conversation_queryset(active_profile))
for conversation in conversations:
message_list = list(conversation.messages.all())
conversation.last_message = message_list[-1] if message_list else None
conversation.counterpart = conversation.counterpart_for(active_profile)
stats = {
"profiles": profiles.count(),
"conversations": len(conversations),
"messages": Message.objects.count(),
}
context = { context = {
"project_name": "New Style", "project_name": "RJL Resaka",
"agent_brand": agent_brand, "agent_brand": agent_brand,
"django_version": django_version(), "django_version": django_version(),
"python_version": platform.python_version(), "python_version": platform.python_version(),
"current_time": now, "current_time": now,
"host_name": host_name, "host_name": host_name,
"project_description": os.getenv("PROJECT_DESCRIPTION", ""), "project_description": os.getenv("PROJECT_DESCRIPTION", DEFAULT_META_DESCRIPTION),
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""), "project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
"page_title": "RJL Resaka | Messagerie privée moderne",
"page_description": DEFAULT_META_DESCRIPTION,
"active_profile": active_profile,
"profiles": profiles,
"profile_form": ProfileForm(),
"conversation_form": ConversationStartForm(active_profile=active_profile),
"conversations": conversations,
"stats": stats,
"profile_query": _profile_query_suffix(active_profile),
} }
return render(request, "core/index.html", context) return render(request, "core/index.html", context)
def create_profile(request):
if request.method != "POST":
return redirect("home")
form = ProfileForm(request.POST)
if form.is_valid():
profile = form.save()
request.session["active_profile_id"] = profile.pk
messages.success(request, f"Profil {profile.display_name} créé. Vous pouvez maintenant lancer une discussion.")
return redirect(f"{reverse('home')}?profile={profile.pk}")
active_profile, profiles = _profile_from_request(request)
conversations = list(_conversation_queryset(active_profile))
for conversation in conversations:
message_list = list(conversation.messages.all())
conversation.last_message = message_list[-1] if message_list else None
conversation.counterpart = conversation.counterpart_for(active_profile)
context = {
"project_name": "RJL Resaka",
"page_title": "Créer un profil | RJL Resaka",
"page_description": DEFAULT_META_DESCRIPTION,
"active_profile": active_profile,
"profiles": profiles,
"profile_form": form,
"conversation_form": ConversationStartForm(active_profile=active_profile),
"conversations": conversations,
"stats": {
"profiles": profiles.count(),
"conversations": len(conversations),
"messages": Message.objects.count(),
},
"profile_query": _profile_query_suffix(active_profile),
"current_time": timezone.now(),
"django_version": django_version(),
"python_version": platform.python_version(),
}
return render(request, "core/index.html", context, status=400)
def start_conversation(request):
if request.method != "POST":
return redirect("home")
active_profile, _profiles = _profile_from_request(request)
if not active_profile:
messages.error(request, "Créez ou sélectionnez d'abord un profil pour discuter.")
return redirect("home")
form = ConversationStartForm(request.POST, active_profile=active_profile)
if not form.is_valid():
messages.error(request, "Impossible de lancer la discussion. Vérifiez les champs du formulaire.")
return redirect(f"{reverse('home')}?profile={active_profile.pk}")
recipient = form.cleaned_data["recipient"]
body = form.cleaned_data["body"]
conversation = (
Conversation.objects.filter(
(Q(starter=active_profile) & Q(recipient=recipient))
| (Q(starter=recipient) & Q(recipient=active_profile))
)
.select_related("starter", "recipient")
.first()
)
created = False
if not conversation:
conversation = Conversation.objects.create(
starter=active_profile,
recipient=recipient,
subject=f"Discussion privée · {active_profile.display_name} & {recipient.display_name}",
)
created = True
Message.objects.create(
conversation=conversation,
author=active_profile,
body=body,
is_read=False,
)
conversation.save(update_fields=["updated_at"])
if created:
messages.success(request, f"Nouvelle conversation lancée avec {recipient.display_name}.")
else:
messages.success(request, f"Message envoyé à {recipient.display_name}.")
return redirect(f"{conversation.get_absolute_url()}?profile={active_profile.pk}")
def conversation_detail(request, pk):
active_profile, profiles = _profile_from_request(request)
conversation = get_object_or_404(
Conversation.objects.select_related("starter", "recipient").prefetch_related(
Prefetch("messages", queryset=Message.objects.select_related("author").order_by("created_at"))
),
pk=pk,
)
if not active_profile or active_profile.pk not in {conversation.starter_id, conversation.recipient_id}:
if active_profile:
messages.error(request, "Cette conversation n'appartient pas au profil sélectionné.")
return redirect(f"{reverse('home')}?profile={active_profile.pk}")
messages.error(request, "Sélectionnez un profil pour ouvrir une conversation.")
return redirect("home")
if request.method == "POST":
form = MessageForm(request.POST)
if form.is_valid():
message = form.save(commit=False)
message.conversation = conversation
message.author = active_profile
message.is_read = False
message.save()
conversation.save(update_fields=["updated_at"])
messages.success(request, "Message envoyé.")
return redirect(f"{conversation.get_absolute_url()}?profile={active_profile.pk}")
else:
form = MessageForm()
Message.objects.filter(conversation=conversation).exclude(author=active_profile).filter(is_read=False).update(is_read=True)
message_list = list(conversation.messages.all())
counterpart = conversation.counterpart_for(active_profile)
reaction_forms = {message.pk: ReactionForm() for message in message_list}
context = {
"project_name": "RJL Resaka",
"page_title": f"Chat avec {counterpart.display_name} | RJL Resaka",
"page_description": f"Conversation privée entre {active_profile.display_name} et {counterpart.display_name} sur RJL Resaka.",
"active_profile": active_profile,
"profiles": profiles,
"conversation": conversation,
"counterpart": counterpart,
"message_form": form,
"messages_list": message_list,
"reaction_forms": reaction_forms,
"reaction_choices": REACTION_CHOICES,
"profile_query": _profile_query_suffix(active_profile),
}
return render(request, "core/chat_detail.html", context)
def react_message(request, pk):
if request.method != "POST":
raise Http404
active_profile, _profiles = _profile_from_request(request)
message = get_object_or_404(Message.objects.select_related("conversation", "author"), pk=pk)
conversation = message.conversation
if not active_profile or active_profile.pk not in {conversation.starter_id, conversation.recipient_id}:
messages.error(request, "Sélectionnez un profil valide pour réagir au message.")
return redirect("home")
form = ReactionForm(request.POST)
if form.is_valid():
message.reaction = form.cleaned_data["reaction"]
message.save(update_fields=["reaction"])
messages.success(request, "Réaction envoyée.")
else:
messages.error(request, "Réaction invalide.")
return redirect(f"{conversation.get_absolute_url()}?profile={active_profile.pk}")

View File

@ -1,4 +1,700 @@
/* Custom styles for the application */ /* RJL Resaka custom theme */
body { :root {
font-family: system-ui, -apple-system, sans-serif; --bg: #08111f;
--bg-soft: #0e1a2c;
--surface: rgba(10, 21, 38, 0.82);
--surface-strong: #12233a;
--surface-border: rgba(255, 255, 255, 0.08);
--text: #ecf6ff;
--muted: #a7bed3;
--primary: #22d3ee;
--primary-dark: #0ea5b7;
--secondary: #ff7a59;
--accent: #9ef3d5;
--gold: #f7c948;
--danger: #ff7a59;
--shadow: 0 24px 60px rgba(0, 0, 0, 0.34);
--radius-xl: 28px;
--radius-lg: 22px;
--radius-md: 16px;
--space-section: clamp(4rem, 7vw, 6rem);
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body.resaka-body {
margin: 0;
font-family: "Inter", system-ui, sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(34, 211, 238, 0.18), transparent 30%),
radial-gradient(circle at 85% 10%, rgba(255, 122, 89, 0.12), transparent 26%),
linear-gradient(180deg, #09111f 0%, #0d1c32 45%, #09111f 100%);
min-height: 100vh;
}
body.resaka-body::before,
body.resaka-body::after {
content: "";
position: fixed;
inset: auto;
border-radius: 999px;
pointer-events: none;
z-index: 0;
filter: blur(20px);
}
body.resaka-body::before {
width: 280px;
height: 280px;
top: 7rem;
right: -6rem;
background: rgba(34, 211, 238, 0.18);
}
body.resaka-body::after {
width: 200px;
height: 200px;
left: -4rem;
bottom: 15%;
background: rgba(255, 122, 89, 0.12);
}
.resaka-shell,
.site-header,
main {
position: relative;
z-index: 1;
}
.container-xl {
max-width: 1240px;
}
.navbar {
background: rgba(7, 16, 29, 0.48);
border: 1px solid var(--surface-border);
border-radius: 999px;
padding: 0.85rem 1.15rem;
backdrop-filter: blur(20px);
box-shadow: 0 10px 35px rgba(0, 0, 0, 0.2);
}
.brand-mark {
font-family: "Manrope", sans-serif;
font-weight: 800;
letter-spacing: -0.04em;
font-size: 1.3rem;
}
.brand-mark span,
.nav-link:hover,
.nav-link:focus {
color: var(--primary) !important;
}
.nav-link {
color: rgba(236, 246, 255, 0.82) !important;
font-weight: 600;
}
.profile-pill,
.presence-chip,
.panel-note {
display: inline-flex;
align-items: center;
gap: 0.55rem;
border-radius: 999px;
padding: 0.6rem 0.95rem;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
color: var(--text);
font-size: 0.94rem;
}
.flash-stack {
margin-top: 0.35rem;
}
.flash-stack .alert {
background: rgba(18, 35, 58, 0.92);
color: var(--text);
border-left: 4px solid var(--primary);
border-radius: 18px;
}
.hero-section,
.workflow-section,
.detail-section {
padding: var(--space-section) 0;
}
.hero-copy {
padding-right: 0.5rem;
}
.eyebrow {
display: inline-flex;
padding: 0.45rem 0.8rem;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.16em;
font-size: 0.74rem;
font-weight: 700;
color: var(--accent);
background: rgba(158, 243, 213, 0.08);
border: 1px solid rgba(158, 243, 213, 0.14);
margin-bottom: 1.15rem;
}
.display-title,
.section-heading h2,
.thread-header h2,
.profile-spotlight h1 {
font-family: "Manrope", sans-serif;
letter-spacing: -0.04em;
}
.display-title {
font-size: clamp(2.9rem, 6vw, 5.2rem);
line-height: 0.98;
margin: 0 0 1.4rem;
}
.lead-copy,
.section-heading p,
.panel-header p,
.muted-copy,
.preview-footer,
.conversation-copy p {
color: var(--muted);
font-size: 1.04rem;
}
.hero-actions,
.helper-row {
align-items: center;
}
.metric-card,
.card-panel,
.info-card,
.preview-window {
background: var(--surface);
border: 1px solid var(--surface-border);
backdrop-filter: blur(22px);
box-shadow: var(--shadow);
}
.metric-card {
border-radius: 22px;
padding: 1.15rem 1rem;
}
.metric-card strong {
display: block;
font-size: 1.65rem;
font-family: "Manrope", sans-serif;
}
.metric-card span {
color: var(--muted);
}
.card-panel,
.info-card {
border-radius: var(--radius-xl);
padding: 1.6rem;
}
.chat-preview-panel {
position: relative;
overflow: hidden;
}
.chat-preview-panel::after {
content: "";
position: absolute;
inset: auto -2.5rem -3rem auto;
width: 180px;
height: 180px;
border-radius: 32px;
transform: rotate(24deg);
background: linear-gradient(135deg, rgba(34, 211, 238, 0.4), rgba(158, 243, 213, 0.08));
}
.preview-window {
border-radius: 28px;
padding: 1.2rem;
position: relative;
z-index: 1;
}
.preview-topbar {
display: flex;
gap: 0.45rem;
margin-bottom: 1.15rem;
}
.dot {
width: 10px;
height: 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.25);
}
.preview-thread {
display: grid;
gap: 0.95rem;
}
.bubble,
.message-bubble {
max-width: 88%;
border-radius: 22px;
padding: 0.95rem 1rem;
line-height: 1.55;
}
.bubble-incoming,
.message-row:not(.is-own) .message-bubble {
background: rgba(255, 255, 255, 0.07);
border-top-left-radius: 8px;
}
.bubble-outgoing,
.message-row.is-own .message-bubble {
margin-left: auto;
background: linear-gradient(135deg, rgba(34, 211, 238, 0.95), rgba(14, 165, 183, 0.92));
color: #062130;
border-top-right-radius: 8px;
}
.bubble-accent {
border: 1px solid rgba(255, 122, 89, 0.4);
}
.preview-footer {
margin-top: 1rem;
display: flex;
align-items: center;
gap: 0.6rem;
}
.presence-dot {
width: 9px;
height: 9px;
border-radius: 999px;
background: var(--accent);
box-shadow: 0 0 0 6px rgba(158, 243, 213, 0.12);
flex-shrink: 0;
}
.section-heading {
max-width: 720px;
margin-bottom: 2rem;
}
.section-heading h2 {
font-size: clamp(2rem, 4vw, 3.2rem);
margin-bottom: 0.8rem;
}
.panel-header {
margin-bottom: 1.2rem;
}
.panel-header h3,
.info-card h3 {
font-family: "Manrope", sans-serif;
font-size: 1.35rem;
margin-bottom: 0.45rem;
}
.form-control,
.form-select {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
color: var(--text);
padding: 0.86rem 1rem;
}
.form-control::placeholder {
color: rgba(167, 190, 211, 0.8);
}
.form-control:focus,
.form-select:focus {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(34, 211, 238, 0.6);
box-shadow: 0 0 0 0.25rem rgba(34, 211, 238, 0.15);
color: var(--text);
}
.form-select {
background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%);
background-position: calc(100% - 18px) calc(1.25rem), calc(100% - 12px) calc(1.25rem);
background-size: 6px 6px, 6px 6px;
background-repeat: no-repeat;
}
.form-select option {
color: #0a1526;
}
.form-label,
.helper-label {
color: rgba(236, 246, 255, 0.92);
font-weight: 600;
}
.form-error {
color: #ffd1c7;
font-size: 0.92rem;
margin-top: 0.45rem;
}
.btn {
border-radius: 999px;
font-weight: 700;
padding: 0.88rem 1.35rem;
transition: transform 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
}
.btn:hover,
.btn:focus {
transform: translateY(-1px);
}
.btn-brand {
color: #072033;
border: 0;
background: linear-gradient(135deg, var(--primary), #66e6f0);
box-shadow: 0 18px 36px rgba(34, 211, 238, 0.22);
}
.btn-brand:hover,
.btn-brand:focus {
color: #072033;
background: linear-gradient(135deg, #6ce8f4, var(--primary));
}
.btn-ghost {
color: var(--text);
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
}
.btn-ghost:hover,
.btn-ghost:focus {
color: var(--text);
background: rgba(255, 255, 255, 0.08);
}
.emoji-chip,
.reaction-chip {
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
color: var(--text);
border-radius: 999px;
padding: 0.5rem 0.8rem;
font-size: 0.92rem;
text-decoration: none;
}
.emoji-chip:hover,
.reaction-chip:hover,
.emoji-chip:focus,
.reaction-chip:focus {
background: rgba(34, 211, 238, 0.14);
color: var(--text);
}
.reaction-chip.static {
display: inline-flex;
}
.conversation-list {
display: grid;
gap: 1rem;
}
.conversation-card {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 1rem;
padding: 1.1rem;
border-radius: 22px;
text-decoration: none;
color: inherit;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
transition: transform 0.18s ease, border-color 0.18s ease, background-color 0.18s ease;
}
.conversation-card:hover,
.conversation-card:focus {
transform: translateY(-2px);
border-color: rgba(34, 211, 238, 0.28);
background: rgba(255, 255, 255, 0.06);
color: inherit;
}
.avatar-orb {
width: 54px;
height: 54px;
border-radius: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #072033;
font-family: "Manrope", sans-serif;
font-weight: 800;
font-size: 1.3rem;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.24);
}
.avatar-xl {
width: 86px;
height: 86px;
border-radius: 28px;
font-size: 2rem;
}
.conversation-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: baseline;
}
.conversation-head h4,
.profile-spotlight h1 {
margin: 0;
font-size: 1.2rem;
}
.conversation-copy strong,
.conversation-copy span,
.conversation-head span,
.meta-list span,
.meta-list strong,
.message-meta span {
display: inline-block;
}
.conversation-copy strong {
margin-right: 0.35rem;
}
.conversation-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.85rem;
}
.unread-badge,
.read-chip,
.reaction-pill {
border-radius: 999px;
padding: 0.3rem 0.7rem;
font-weight: 700;
}
.unread-badge {
background: rgba(255, 122, 89, 0.16);
color: #ffd8cf;
border: 1px solid rgba(255, 122, 89, 0.24);
}
.open-link,
.back-link {
color: var(--primary);
font-weight: 700;
text-decoration: none;
}
.empty-state {
text-align: center;
padding: 2.2rem 1.4rem;
border-radius: 24px;
background: rgba(255, 255, 255, 0.04);
border: 1px dashed rgba(255, 255, 255, 0.12);
}
.empty-state.compact {
padding: 1.8rem 1rem;
}
.empty-illustration {
font-size: 2.5rem;
margin-bottom: 0.8rem;
}
.detail-grid {
display: grid;
grid-template-columns: 340px minmax(0, 1fr);
gap: 1.35rem;
}
.profile-spotlight {
text-align: left;
}
.profile-spotlight p {
color: var(--muted);
}
.meta-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 0.9rem;
}
.meta-list li {
display: flex;
justify-content: space-between;
gap: 1rem;
}
.meta-list span {
color: var(--muted);
}
.thread-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: start;
margin-bottom: 1.2rem;
}
.thread-header h2 {
font-size: 2rem;
margin: 0.3rem 0 0.4rem;
}
.message-thread {
display: grid;
gap: 1rem;
max-height: 62vh;
overflow-y: auto;
padding-right: 0.35rem;
}
.message-row {
display: flex;
}
.message-row.is-own {
justify-content: flex-end;
}
.message-bubble-wrap {
display: grid;
gap: 0.5rem;
max-width: min(100%, 720px);
}
.message-meta {
display: flex;
align-items: center;
gap: 0.65rem;
margin-bottom: 0.55rem;
font-size: 0.88rem;
}
.message-bubble p {
margin: 0;
}
.message-row.is-own .message-meta,
.message-row.is-own .message-bubble p {
color: #072033;
}
.read-chip {
background: rgba(7, 32, 51, 0.14);
color: #072033;
}
.reaction-pill {
margin-top: 0.8rem;
background: rgba(255, 255, 255, 0.14);
display: inline-flex;
}
.reaction-actions {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.reaction-actions form {
margin: 0;
}
.composer-panel {
margin-top: 1.35rem;
padding-top: 1.25rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.reaction-preview-row {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
}
.site-header .navbar-toggler:focus {
box-shadow: 0 0 0 0.2rem rgba(34, 211, 238, 0.22);
}
@media (max-width: 1199.98px) {
.detail-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 767.98px) {
.hero-section,
.workflow-section,
.detail-section {
padding: 2.8rem 0;
}
.navbar {
border-radius: 24px;
}
.display-title {
font-size: clamp(2.4rem, 12vw, 3.4rem);
}
.conversation-card {
grid-template-columns: 1fr;
}
.conversation-meta {
align-items: flex-start;
}
.thread-header {
flex-direction: column;
}
.bubble,
.message-bubble {
max-width: 100%;
}
} }