Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cac267951 | ||
|
|
3b03cdb4a1 | ||
|
|
db8313c88e |
31
RJLResaka/README_IMPORT.txt
Normal file
31
RJLResaka/README_IMPORT.txt
Normal 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.
|
||||||
37
RJLResaka/WebContent/WEB-INF/views/auth/forgot-password.jsp
Normal file
37
RJLResaka/WebContent/WEB-INF/views/auth/forgot-password.jsp
Normal 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>
|
||||||
41
RJLResaka/WebContent/WEB-INF/views/auth/login.jsp
Normal file
41
RJLResaka/WebContent/WEB-INF/views/auth/login.jsp
Normal 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>
|
||||||
40
RJLResaka/WebContent/WEB-INF/views/auth/register.jsp
Normal file
40
RJLResaka/WebContent/WEB-INF/views/auth/register.jsp
Normal 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>
|
||||||
38
RJLResaka/WebContent/WEB-INF/views/auth/reset-password.jsp
Normal file
38
RJLResaka/WebContent/WEB-INF/views/auth/reset-password.jsp
Normal 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>
|
||||||
375
RJLResaka/WebContent/WEB-INF/views/dashboard.jsp
Normal file
375
RJLResaka/WebContent/WEB-INF/views/dashboard.jsp
Normal 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>
|
||||||
73
RJLResaka/WebContent/WEB-INF/views/home.jsp
Normal file
73
RJLResaka/WebContent/WEB-INF/views/home.jsp
Normal 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 😂 1</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="${pageContext.request.contextPath}/assets/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
37
RJLResaka/WebContent/WEB-INF/web.xml
Normal file
37
RJLResaka/WebContent/WEB-INF/web.xml
Normal 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&allowPublicKeyRetrieval=true&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>
|
||||||
878
RJLResaka/WebContent/assets/css/app.css
Normal file
878
RJLResaka/WebContent/assets/css/app.css
Normal 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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
RJLResaka/WebContent/assets/img/logo_resaka.png
Normal file
BIN
RJLResaka/WebContent/assets/img/logo_resaka.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
36
RJLResaka/WebContent/assets/js/app.js
Normal file
36
RJLResaka/WebContent/assets/js/app.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
4
RJLResaka/WebContent/index.jsp
Normal file
4
RJLResaka/WebContent/index.jsp
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
|
||||||
|
<%
|
||||||
|
response.sendRedirect(request.getContextPath() + "/home");
|
||||||
|
%>
|
||||||
197
RJLResaka/database/rjlresaka.sql
Normal file
197
RJLResaka/database/rjlresaka.sql
Normal 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
|
||||||
17
RJLResaka/docs/INSTALL_JARS.txt
Normal file
17
RJLResaka/docs/INSTALL_JARS.txt
Normal 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.
|
||||||
299
RJLResaka/src/com/rjlresaka/dao/ConversationDAO.java
Normal file
299
RJLResaka/src/com/rjlresaka/dao/ConversationDAO.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
236
RJLResaka/src/com/rjlresaka/dao/FriendDAO.java
Normal file
236
RJLResaka/src/com/rjlresaka/dao/FriendDAO.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
258
RJLResaka/src/com/rjlresaka/dao/MessageDAO.java
Normal file
258
RJLResaka/src/com/rjlresaka/dao/MessageDAO.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
49
RJLResaka/src/com/rjlresaka/dao/PasswordResetDAO.java
Normal file
49
RJLResaka/src/com/rjlresaka/dao/PasswordResetDAO.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
132
RJLResaka/src/com/rjlresaka/dao/UserDAO.java
Normal file
132
RJLResaka/src/com/rjlresaka/dao/UserDAO.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
RJLResaka/src/com/rjlresaka/dao/package-info.java
Normal file
1
RJLResaka/src/com/rjlresaka/dao/package-info.java
Normal file
@ -0,0 +1 @@
|
|||||||
|
package com.rjlresaka.dao;
|
||||||
43
RJLResaka/src/com/rjlresaka/filter/AuthFilter.java
Normal file
43
RJLResaka/src/com/rjlresaka/filter/AuthFilter.java
Normal 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
1
RJLResaka/src/com/rjlresaka/filter/package-info.java
Normal file
1
RJLResaka/src/com/rjlresaka/filter/package-info.java
Normal file
@ -0,0 +1 @@
|
|||||||
|
package com.rjlresaka.filter;
|
||||||
128
RJLResaka/src/com/rjlresaka/model/ConversationItem.java
Normal file
128
RJLResaka/src/com/rjlresaka/model/ConversationItem.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
89
RJLResaka/src/com/rjlresaka/model/FriendRequest.java
Normal file
89
RJLResaka/src/com/rjlresaka/model/FriendRequest.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
175
RJLResaka/src/com/rjlresaka/model/Message.java
Normal file
175
RJLResaka/src/com/rjlresaka/model/Message.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
35
RJLResaka/src/com/rjlresaka/model/ReactionStat.java
Normal file
35
RJLResaka/src/com/rjlresaka/model/ReactionStat.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
48
RJLResaka/src/com/rjlresaka/model/StoredAttachment.java
Normal file
48
RJLResaka/src/com/rjlresaka/model/StoredAttachment.java
Normal 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/");
|
||||||
|
}
|
||||||
|
}
|
||||||
180
RJLResaka/src/com/rjlresaka/model/User.java
Normal file
180
RJLResaka/src/com/rjlresaka/model/User.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
1
RJLResaka/src/com/rjlresaka/model/package-info.java
Normal file
1
RJLResaka/src/com/rjlresaka/model/package-info.java
Normal file
@ -0,0 +1 @@
|
|||||||
|
package com.rjlresaka.model;
|
||||||
67
RJLResaka/src/com/rjlresaka/servlet/CreateGroupServlet.java
Normal file
67
RJLResaka/src/com/rjlresaka/servlet/CreateGroupServlet.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
126
RJLResaka/src/com/rjlresaka/servlet/DashboardServlet.java
Normal file
126
RJLResaka/src/com/rjlresaka/servlet/DashboardServlet.java
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
RJLResaka/src/com/rjlresaka/servlet/HomeServlet.java
Normal file
26
RJLResaka/src/com/rjlresaka/servlet/HomeServlet.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
RJLResaka/src/com/rjlresaka/servlet/LoginServlet.java
Normal file
56
RJLResaka/src/com/rjlresaka/servlet/LoginServlet.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
RJLResaka/src/com/rjlresaka/servlet/LogoutServlet.java
Normal file
25
RJLResaka/src/com/rjlresaka/servlet/LogoutServlet.java
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
94
RJLResaka/src/com/rjlresaka/servlet/RegisterServlet.java
Normal file
94
RJLResaka/src/com/rjlresaka/servlet/RegisterServlet.java
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
99
RJLResaka/src/com/rjlresaka/servlet/SendMessageServlet.java
Normal file
99
RJLResaka/src/com/rjlresaka/servlet/SendMessageServlet.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
RJLResaka/src/com/rjlresaka/servlet/package-info.java
Normal file
1
RJLResaka/src/com/rjlresaka/servlet/package-info.java
Normal file
@ -0,0 +1 @@
|
|||||||
|
package com.rjlresaka.servlet;
|
||||||
23
RJLResaka/src/com/rjlresaka/util/DatabaseConnection.java
Normal file
23
RJLResaka/src/com/rjlresaka/util/DatabaseConnection.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
RJLResaka/src/com/rjlresaka/util/FileStorageUtil.java
Normal file
91
RJLResaka/src/com/rjlresaka/util/FileStorageUtil.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
RJLResaka/src/com/rjlresaka/util/PasswordUtil.java
Normal file
20
RJLResaka/src/com/rjlresaka/util/PasswordUtil.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
RJLResaka/src/com/rjlresaka/util/TokenUtil.java
Normal file
19
RJLResaka/src/com/rjlresaka/util/TokenUtil.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
1
RJLResaka/src/com/rjlresaka/util/package-info.java
Normal file
1
RJLResaka/src/com/rjlresaka/util/package-info.java
Normal file
@ -0,0 +1 @@
|
|||||||
|
package com.rjlresaka.util;
|
||||||
BIN
RJLResaka_final.zip
Normal file
BIN
RJLResaka_final.zip
Normal file
Binary file not shown.
BIN
assets/pasted-20260405-223528-3def3589.png
Normal file
BIN
assets/pasted-20260405-223528-3def3589.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 155 KiB |
BIN
assets/pasted-20260405-224026-e3561c29.png
Normal file
BIN
assets/pasted-20260405-224026-e3561c29.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
BIN
assets/pasted-20260408-203803-f016db24.png
Normal file
BIN
assets/pasted-20260408-203803-f016db24.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 416 KiB |
Binary file not shown.
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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
101
core/forms.py
Normal 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(),
|
||||||
|
)
|
||||||
66
core/migrations/0001_initial.py
Normal file
66
core/migrations/0001_initial.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
Binary file not shown.
@ -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}"
|
||||||
|
|||||||
@ -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>
|
||||||
{% block content %}{% endblock %}
|
{% 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 %}
|
||||||
|
</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>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
106
core/templates/core/chat_detail.html
Normal file
106
core/templates/core/chat_detail.html
Normal 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 %}
|
||||||
@ -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">
|
||||||
|
<span class="eyebrow">Messagerie privée • design inspiré des apps sociales</span>
|
||||||
|
<h1 class="display-title">Discutez en privé, avec style, notifications et emojis.</h1>
|
||||||
|
<p class="lead-copy">
|
||||||
|
RJL Resaka transforme votre sujet d'examen en première expérience utilisable : créez un profil,
|
||||||
|
lancez une discussion privée, suivez les messages non lus et réagissez avec des emojis.
|
||||||
|
</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 class="hero-stats row g-3 mt-2">
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<div class="metric-card">
|
||||||
|
<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>
|
||||||
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p>
|
|
||||||
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
|
|
||||||
<p class="runtime">
|
|
||||||
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
|
|
||||||
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</section>
|
||||||
<footer>
|
|
||||||
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
|
<section id="workflow" class="workflow-section">
|
||||||
</footer>
|
<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 %}
|
||||||
@ -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"),
|
||||||
]
|
]
|
||||||
|
|||||||
247
core/views.py
247
core/views.py
@ -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}")
|
||||||
|
|||||||
@ -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%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user