V2
This commit is contained in:
parent
fd834c4871
commit
db8313c88e
41
RJLResaka/README_IMPORT.txt
Normal file
41
RJLResaka/README_IMPORT.txt
Normal file
@ -0,0 +1,41 @@
|
||||
RJLResaka - Dynamic Web Project (Tomcat 9)
|
||||
|
||||
Objectif:
|
||||
- Application web de discussion privée style Facebook Messenger
|
||||
- Java JEE (Servlet/JSP) + MySQL Workbench
|
||||
- Projet Dynamic Web Project sans Maven
|
||||
|
||||
Structure actuellement livrée:
|
||||
- src/com/rjlresaka/model -> modèles Java
|
||||
- src/com/rjlresaka/dao -> accès MySQL (users + reset password)
|
||||
- src/com/rjlresaka/servlet -> Home, Register, Login, ForgotPassword, ResetPassword, Dashboard, Logout
|
||||
- src/com/rjlresaka/filter -> filtre UTF-8 + protection des pages privées
|
||||
- src/com/rjlresaka/util -> connexion MySQL, BCrypt, génération de token
|
||||
- WebContent/WEB-INF/views -> JSP protégées
|
||||
- WebContent/assets -> design moderne clair responsive
|
||||
- database/rjlresaka.sql -> schéma MySQL complet prêt pour Workbench
|
||||
|
||||
JARs à placer dans WebContent/WEB-INF/lib:
|
||||
- jbcrypt-0.4.jar
|
||||
- mysql-connector-java-8.0.18.jar
|
||||
- javax.mail-api-1.6.2.jar
|
||||
- jstl-1.2.jar
|
||||
|
||||
Fonctionnalités déjà codées dans cette étape:
|
||||
1. Page d'accueil moderne
|
||||
2. Inscription
|
||||
3. Connexion
|
||||
4. Mot de passe oublié (génération de token)
|
||||
5. Réinitialisation du mot de passe
|
||||
6. Tableau de bord avec liste des autres utilisateurs
|
||||
|
||||
Configuration MySQL à adapter dans WebContent/WEB-INF/web.xml:
|
||||
- db.url
|
||||
- db.user
|
||||
- db.password
|
||||
|
||||
Étapes suivantes recommandées:
|
||||
1. Vraies conversations privées 1-1
|
||||
2. Envoi / modification / suppression de messages
|
||||
3. Upload image/fichier + téléchargement
|
||||
4. Emojis, réactions, badge non lus
|
||||
52
RJLResaka/WebContent/WEB-INF/views/auth/forgot-password.jsp
Normal file
52
RJLResaka/WebContent/WEB-INF/views/auth/forgot-password.jsp
Normal file
@ -0,0 +1,52 @@
|
||||
<%@ 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">
|
||||
<section class="auth-panel glass">
|
||||
<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>
|
||||
53
RJLResaka/WebContent/WEB-INF/views/auth/login.jsp
Normal file
53
RJLResaka/WebContent/WEB-INF/views/auth/login.jsp
Normal file
@ -0,0 +1,53 @@
|
||||
<%@ 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">
|
||||
<section class="auth-panel glass">
|
||||
<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>
|
||||
63
RJLResaka/WebContent/WEB-INF/views/auth/register.jsp
Normal file
63
RJLResaka/WebContent/WEB-INF/views/auth/register.jsp
Normal file
@ -0,0 +1,63 @@
|
||||
<%@ 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">
|
||||
<section class="auth-panel wide glass">
|
||||
<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>
|
||||
55
RJLResaka/WebContent/WEB-INF/views/auth/reset-password.jsp
Normal file
55
RJLResaka/WebContent/WEB-INF/views/auth/reset-password.jsp
Normal file
@ -0,0 +1,55 @@
|
||||
<%@ 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">
|
||||
<section class="auth-panel glass">
|
||||
<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>
|
||||
77
RJLResaka/WebContent/WEB-INF/views/dashboard.jsp
Normal file
77
RJLResaka/WebContent/WEB-INF/views/dashboard.jsp
Normal file
@ -0,0 +1,77 @@
|
||||
<%@ 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" %>
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Tableau de bord | RJLResaka</title>
|
||||
<meta name="description" content="Tableau de bord RJLResaka avec liste des utilisateurs disponibles pour chatter.">
|
||||
<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-body">
|
||||
<main class="dashboard-shell">
|
||||
<section class="sidebar glass">
|
||||
<div class="sidebar-top">
|
||||
<span class="hero-badge">Connecté</span>
|
||||
<a class="ghost-link" href="${pageContext.request.contextPath}/logout">Déconnexion</a>
|
||||
</div>
|
||||
<div class="profile-card">
|
||||
<div class="avatar" style="background:${sessionScope.authUser.avatarColor}">${sessionScope.authUser.initials}</div>
|
||||
<div>
|
||||
<h1>${sessionScope.authUser.fullName}</h1>
|
||||
<p>@${sessionScope.authUser.username}</p>
|
||||
<small>${sessionScope.authUser.email}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-note">
|
||||
<strong>Étape livrée :</strong>
|
||||
Authentification complète + liste des utilisateurs.<br>
|
||||
La prochaine étape branchera les conversations privées, l'upload d'images/fichiers et les réactions.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="content-panel glass">
|
||||
<div class="content-head">
|
||||
<div>
|
||||
<span class="section-kicker">Utilisateurs</span>
|
||||
<h2>Choisissez une personne à qui parler</h2>
|
||||
</div>
|
||||
<span class="pill">${fn:length(users)} compte(s)</span>
|
||||
</div>
|
||||
|
||||
<c:if test="${not empty error}">
|
||||
<div class="alert error">${error}</div>
|
||||
</c:if>
|
||||
|
||||
<div class="user-grid">
|
||||
<c:forEach items="${users}" var="item">
|
||||
<article class="user-card">
|
||||
<div class="avatar large" style="background:${item.avatarColor}">${item.initials}</div>
|
||||
<h3>${item.fullName}</h3>
|
||||
<p>@${item.username}</p>
|
||||
<small>${item.email}</small>
|
||||
<button class="button secondary" type="button" disabled>Chat disponible à l'étape suivante</button>
|
||||
</article>
|
||||
</c:forEach>
|
||||
|
||||
<c:if test="${empty users}">
|
||||
<article class="empty-card">
|
||||
<h3>Aucun autre utilisateur pour le moment</h3>
|
||||
<p>Créez un deuxième compte pour tester le chat privé ensuite.</p>
|
||||
</article>
|
||||
</c:if>
|
||||
</div>
|
||||
|
||||
<c:if test="${not empty debugMessage}">
|
||||
<p class="debug-note">Détail technique: ${debugMessage}</p>
|
||||
</c:if>
|
||||
</section>
|
||||
</main>
|
||||
<script src="${pageContext.request.contextPath}/assets/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
56
RJLResaka/WebContent/WEB-INF/views/home.jsp
Normal file
56
RJLResaka/WebContent/WEB-INF/views/home.jsp
Normal file
@ -0,0 +1,56 @@
|
||||
<%@ 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 | Discussion privée moderne en Java JEE</title>
|
||||
<meta name="description" content="RJLResaka est une application de discussion privée en Java JEE avec authentification, liste d'utilisateurs, chat et style moderne inspiré de 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>
|
||||
<main class="landing-shell">
|
||||
<section class="hero-card hero-grid">
|
||||
<div>
|
||||
<span class="hero-badge">RJLResaka • Java JEE</span>
|
||||
<h1 class="hero-title">Discutez comme sur Messenger, dans votre propre application web.</h1>
|
||||
<p class="hero-copy">
|
||||
Inscription, connexion, mot de passe oublié, liste d'utilisateurs, discussion privée,
|
||||
design clair et responsive. La prochaine étape ajoute les vraies conversations,
|
||||
les pièces jointes et les réactions.
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a class="button primary" href="${pageContext.request.contextPath}/register">Créer un compte</a>
|
||||
<a class="button secondary" href="${pageContext.request.contextPath}/login">Se connecter</a>
|
||||
</div>
|
||||
<ul class="hero-list">
|
||||
<li>Tomcat 9 + Servlet/JSP</li>
|
||||
<li>MySQL Workbench</li>
|
||||
<li>Base prête pour chat privé + fichiers</li>
|
||||
</ul>
|
||||
</div>
|
||||
<aside class="preview-card">
|
||||
<div class="preview-window">
|
||||
<div class="preview-topbar">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
<div class="preview-chat">
|
||||
<div class="preview-bubble incoming">Salut 👋 prêt pour l'examen ?</div>
|
||||
<div class="preview-bubble outgoing">Oui, RJLResaka avance bien !</div>
|
||||
<div class="preview-users">
|
||||
<div class="mini-user active">AM</div>
|
||||
<div class="mini-user">JR</div>
|
||||
<div class="mini-user">LK</div>
|
||||
<div class="mini-user">TS</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
<script src="${pageContext.request.contextPath}/assets/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
33
RJLResaka/WebContent/WEB-INF/web.xml
Normal file
33
RJLResaka/WebContent/WEB-INF/web.xml
Normal file
@ -0,0 +1,33 @@
|
||||
<?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>
|
||||
|
||||
<welcome-file-list>
|
||||
<welcome-file>index.jsp</welcome-file>
|
||||
</welcome-file-list>
|
||||
|
||||
<session-config>
|
||||
<session-timeout>60</session-timeout>
|
||||
</session-config>
|
||||
</web-app>
|
||||
316
RJLResaka/WebContent/assets/css/app.css
Normal file
316
RJLResaka/WebContent/assets/css/app.css
Normal file
@ -0,0 +1,316 @@
|
||||
* { box-sizing: border-box; }
|
||||
:root {
|
||||
--bg: #eef6ff;
|
||||
--bg-soft: #f8fbff;
|
||||
--text: #16324f;
|
||||
--muted: #5a728e;
|
||||
--line: rgba(140, 174, 205, 0.32);
|
||||
--primary: #1d9bf0;
|
||||
--primary-dark: #0f6fcc;
|
||||
--accent: #0fd4c2;
|
||||
--white: #ffffff;
|
||||
--shadow: 0 25px 60px rgba(30, 80, 120, 0.16);
|
||||
}
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: 'Inter', Arial, sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(15, 212, 194, 0.16), transparent 28%),
|
||||
radial-gradient(circle at bottom right, rgba(29, 155, 240, 0.18), transparent 24%),
|
||||
linear-gradient(135deg, var(--bg) 0%, var(--bg-soft) 48%, #ffffff 100%);
|
||||
}
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button, input { font: inherit; }
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
backdrop-filter: blur(18px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.64);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.hero-badge, .pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 999px;
|
||||
padding: 10px 16px;
|
||||
background: rgba(29, 155, 240, 0.12);
|
||||
color: var(--primary-dark);
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
.button {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 22px;
|
||||
border-radius: 18px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: transform .2s ease, box-shadow .2s ease, background .2s ease;
|
||||
box-shadow: 0 12px 26px rgba(29, 155, 240, 0.18);
|
||||
}
|
||||
.button:hover { transform: translateY(-2px); }
|
||||
.button.primary {
|
||||
color: var(--white);
|
||||
background: linear-gradient(135deg, var(--primary), var(--accent));
|
||||
}
|
||||
.button.secondary {
|
||||
color: var(--primary-dark);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid rgba(29, 155, 240, 0.16);
|
||||
box-shadow: none;
|
||||
}
|
||||
.ghost-link {
|
||||
color: var(--primary-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
.hero-title, h1, h2, h3 { letter-spacing: -0.04em; }
|
||||
.hero-title { font-size: clamp(2.6rem, 6vw, 4.6rem); line-height: .95; margin: 18px 0; }
|
||||
.hero-copy, .auth-copy, p, small { color: var(--muted); line-height: 1.7; }
|
||||
.landing-shell, .auth-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 32px;
|
||||
}
|
||||
.hero-card {
|
||||
width: min(1180px, 100%);
|
||||
border-radius: 36px;
|
||||
padding: 36px;
|
||||
background: rgba(255,255,255,0.82);
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid rgba(255,255,255,0.75);
|
||||
}
|
||||
.hero-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.15fr .85fr;
|
||||
gap: 28px;
|
||||
align-items: center;
|
||||
}
|
||||
.hero-actions, .button-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.hero-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 26px 0 0;
|
||||
}
|
||||
.hero-list li {
|
||||
background: rgba(255,255,255,0.88);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
padding: 12px 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.preview-card {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.preview-window {
|
||||
width: min(420px, 100%);
|
||||
padding: 20px;
|
||||
border-radius: 30px;
|
||||
background: linear-gradient(180deg, #fdfefe 0%, #edf7ff 100%);
|
||||
border: 1px solid rgba(29, 155, 240, 0.16);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.85), 0 35px 50px rgba(35, 97, 148, 0.18);
|
||||
}
|
||||
.preview-topbar { display: flex; gap: 8px; margin-bottom: 18px; }
|
||||
.preview-topbar span {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: rgba(22, 50, 79, 0.18);
|
||||
}
|
||||
.preview-chat {
|
||||
padding: 18px;
|
||||
border-radius: 24px;
|
||||
background: rgba(255,255,255,0.88);
|
||||
border: 1px solid rgba(29, 155, 240, 0.14);
|
||||
}
|
||||
.preview-bubble {
|
||||
max-width: 78%;
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.preview-bubble.incoming {
|
||||
background: #f3f7fb;
|
||||
color: var(--text);
|
||||
}
|
||||
.preview-bubble.outgoing {
|
||||
margin-left: auto;
|
||||
background: linear-gradient(135deg, var(--primary), var(--accent));
|
||||
color: white;
|
||||
}
|
||||
.preview-users { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.mini-user, .avatar {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
font-weight: 800;
|
||||
}
|
||||
.mini-user {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
background: rgba(29, 155, 240, 0.72);
|
||||
}
|
||||
.mini-user.active { background: linear-gradient(135deg, var(--primary), var(--accent)); }
|
||||
.auth-panel {
|
||||
width: min(520px, 100%);
|
||||
padding: 34px;
|
||||
border-radius: 28px;
|
||||
}
|
||||
.auth-panel.wide { width: min(720px, 100%); }
|
||||
.stack-form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.stack-form.two-col {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.stack-form label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.stack-form label span { font-size: 14px; }
|
||||
.stack-form input {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(29, 155, 240, 0.14);
|
||||
background: rgba(255,255,255,0.92);
|
||||
outline: none;
|
||||
}
|
||||
.stack-form input:focus {
|
||||
border-color: rgba(29, 155, 240, 0.52);
|
||||
box-shadow: 0 0 0 4px rgba(29, 155, 240, 0.12);
|
||||
}
|
||||
.auth-links {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
.auth-links.left { justify-content: flex-start; }
|
||||
.alert {
|
||||
border-radius: 18px;
|
||||
padding: 14px 16px;
|
||||
margin-top: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.alert.error {
|
||||
color: #a33434;
|
||||
background: rgba(255, 92, 92, 0.12);
|
||||
border: 1px solid rgba(255, 92, 92, 0.2);
|
||||
}
|
||||
.alert.success {
|
||||
color: #0b7a61;
|
||||
background: rgba(13, 217, 168, 0.12);
|
||||
border: 1px solid rgba(13, 217, 168, 0.22);
|
||||
}
|
||||
.debug-note, .token-box {
|
||||
margin-top: 18px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(22, 50, 79, 0.06);
|
||||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.dashboard-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 340px minmax(0, 1fr);
|
||||
gap: 22px;
|
||||
padding: 22px;
|
||||
}
|
||||
.sidebar, .content-panel {
|
||||
border-radius: 28px;
|
||||
padding: 24px;
|
||||
}
|
||||
.sidebar-top, .content-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
.profile-card {
|
||||
display: grid;
|
||||
grid-template-columns: 76px 1fr;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 22px 0;
|
||||
}
|
||||
.avatar {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
font-size: 24px;
|
||||
}
|
||||
.avatar.large {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
font-size: 20px;
|
||||
margin: 0 auto 12px;
|
||||
}
|
||||
.profile-card h1, .user-card h3, .empty-card h3 { margin: 0 0 6px; }
|
||||
.profile-card p, .user-card p { margin: 0 0 4px; }
|
||||
.panel-note {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 20px;
|
||||
color: var(--muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
.section-kicker {
|
||||
font-weight: 700;
|
||||
color: var(--primary-dark);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
font-size: 12px;
|
||||
}
|
||||
.content-head h2 { margin: 8px 0 0; font-size: clamp(2rem, 3vw, 2.8rem); }
|
||||
.user-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 18px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.user-card, .empty-card {
|
||||
border-radius: 24px;
|
||||
padding: 22px;
|
||||
background: rgba(255,255,255,0.88);
|
||||
border: 1px solid var(--line);
|
||||
text-align: center;
|
||||
}
|
||||
.empty-card {
|
||||
text-align: left;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.hero-grid,
|
||||
.dashboard-shell,
|
||||
.stack-form.two-col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.dashboard-shell { padding: 16px; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.landing-shell, .auth-shell { padding: 18px; }
|
||||
.hero-card, .auth-panel, .sidebar, .content-panel { padding: 22px; border-radius: 24px; }
|
||||
.hero-title { font-size: 2.4rem; }
|
||||
.auth-links, .sidebar-top, .content-head { flex-direction: column; align-items: flex-start; }
|
||||
}
|
||||
11
RJLResaka/WebContent/assets/js/app.js
Normal file
11
RJLResaka/WebContent/assets/js/app.js
Normal file
@ -0,0 +1,11 @@
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('.alert').forEach(function (alert) {
|
||||
setTimeout(function () {
|
||||
alert.style.opacity = '0';
|
||||
alert.style.transform = 'translateY(-6px)';
|
||||
setTimeout(function () {
|
||||
alert.style.display = 'none';
|
||||
}, 220);
|
||||
}, 4200);
|
||||
});
|
||||
});
|
||||
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");
|
||||
%>
|
||||
94
RJLResaka/database/rjlresaka.sql
Normal file
94
RJLResaka/database/rjlresaka.sql
Normal file
@ -0,0 +1,94 @@
|
||||
-- RJLResaka MySQL schema
|
||||
-- Compatible with MySQL Workbench / MariaDB
|
||||
-- Create the database then import this file.
|
||||
|
||||
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 '#0ea5e9',
|
||||
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,
|
||||
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 conversation_participants (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
conversation_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
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)
|
||||
);
|
||||
|
||||
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)
|
||||
);
|
||||
|
||||
INSERT INTO users (full_name, username, email, password_hash, avatar_color, bio)
|
||||
SELECT 'Demo User', 'demo', 'demo@rjlresaka.app', '$2a$10$u6N8G6s8wWC4b7A9iI5L8e2ZfQFlA95zT4zWS3TzFmpXQxwCLWv0W', '#2563eb', 'Compte de démonstration'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM users WHERE email = 'demo@rjlresaka.app');
|
||||
15
RJLResaka/docs/INSTALL_JARS.txt
Normal file
15
RJLResaka/docs/INSTALL_JARS.txt
Normal file
@ -0,0 +1,15 @@
|
||||
Placez ces fichiers dans: RJLResaka/WebContent/WEB-INF/lib
|
||||
|
||||
Obligatoires pour l'étape actuelle:
|
||||
- jbcrypt-0.4.jar
|
||||
- mysql-connector-java-8.0.18.jar
|
||||
- jstl-1.2.jar
|
||||
|
||||
Pour la future étape email:
|
||||
- javax.mail-api-1.6.2.jar
|
||||
|
||||
Conseils Eclipse / Tomcat 9:
|
||||
1. Right click project -> Properties -> Java Build Path -> Libraries
|
||||
2. Vérifiez que WEB-INF/lib est bien dans Deployment Assembly
|
||||
3. Importez database/rjlresaka.sql dans MySQL Workbench
|
||||
4. Modifiez web.xml si votre mot de passe MySQL n'est pas vide
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
121
RJLResaka/src/com/rjlresaka/dao/UserDAO.java
Normal file
121
RJLResaka/src/com/rjlresaka/dao/UserDAO.java
Normal file
@ -0,0 +1,121 @@
|
||||
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 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;
|
||||
107
RJLResaka/src/com/rjlresaka/model/User.java
Normal file
107
RJLResaka/src/com/rjlresaka/model/User.java
Normal file
@ -0,0 +1,107 @@
|
||||
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;
|
||||
|
||||
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 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;
|
||||
36
RJLResaka/src/com/rjlresaka/servlet/DashboardServlet.java
Normal file
36
RJLResaka/src/com/rjlresaka/servlet/DashboardServlet.java
Normal file
@ -0,0 +1,36 @@
|
||||
package com.rjlresaka.servlet;
|
||||
|
||||
import java.io.IOException;
|
||||
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.UserDAO;
|
||||
import com.rjlresaka.model.User;
|
||||
|
||||
@WebServlet("/app/dashboard")
|
||||
public class DashboardServlet 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 {
|
||||
HttpSession session = request.getSession(false);
|
||||
User authUser = (User) session.getAttribute("authUser");
|
||||
try {
|
||||
List<User> users = userDAO.findOtherUsers(authUser.getId(), getServletContext());
|
||||
request.setAttribute("users", users);
|
||||
request.getRequestDispatcher("/WEB-INF/views/dashboard.jsp").forward(request, response);
|
||||
} catch (Exception exception) {
|
||||
request.setAttribute("error", "Impossible de charger les utilisateurs pour le moment.");
|
||||
request.setAttribute("debugMessage", exception.getMessage());
|
||||
request.getRequestDispatcher("/WEB-INF/views/dashboard.jsp").forward(request, response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
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;
|
||||
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
|
||||
|
||||
# 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.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>
|
||||
<html lang="en">
|
||||
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{% block title %}Knowledge Base{% endblock %}</title>
|
||||
{% if project_description %}
|
||||
<meta name="description" content="{{ project_description }}">
|
||||
<meta property="og:description" content="{{ project_description }}">
|
||||
<meta property="twitter:description" content="{{ project_description }}">
|
||||
{% endif %}
|
||||
{% if project_image_url %}
|
||||
<meta property="og:image" content="{{ project_image_url }}">
|
||||
<meta property="twitter:image" content="{{ project_image_url }}">
|
||||
{% endif %}
|
||||
{% load static %}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}{{ page_title|default:project_name|default:"RJL Resaka" }}{% endblock %}</title>
|
||||
<meta name="description" content="{{ page_description|default:project_description|default:'Messagerie privée moderne pour échanges rapides et notifications de lecture.' }}">
|
||||
<meta name="author" content="Flatlogic">
|
||||
<meta name="keywords" content="chat privé, messagerie, discussion, RJL Resaka, 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&family=Manrope:wght@600;700;800&display=swap" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
|
||||
{% block head %}{% endblock %}
|
||||
</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>
|
||||
{% block content %}{% endblock %}
|
||||
<main>
|
||||
{% if messages %}
|
||||
<div class="container-xl flash-stack">
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags|default:'info' }} shadow-sm border-0" role="alert">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
||||
<script src="{% static 'js/app.js' %}?v={{ deployment_timestamp }}" defer></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
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" %}
|
||||
|
||||
{% 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 %}
|
||||
<main>
|
||||
<div class="card">
|
||||
<h1>Analyzing your requirements and generating your app…</h1>
|
||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
||||
<span class="sr-only">Loading…</span>
|
||||
<section class="hero-section">
|
||||
<div class="container-xl">
|
||||
<div class="row align-items-center g-4 g-xl-5">
|
||||
<div class="col-lg-7">
|
||||
<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>
|
||||
<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>
|
||||
</main>
|
||||
<footer>
|
||||
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
|
||||
</footer>
|
||||
{% endblock %}
|
||||
</section>
|
||||
|
||||
<section id="workflow" class="workflow-section">
|
||||
<div class="container-xl">
|
||||
<div class="section-heading">
|
||||
<span class="eyebrow">Premier MVP slice</span>
|
||||
<h2>Créer un profil, démarrer un chat, suivre les non-lus.</h2>
|
||||
<p>Une vraie boucle de valeur : entrée, confirmation, liste des discussions et détail conversationnel.</p>
|
||||
</div>
|
||||
|
||||
<div class="row g-4 align-items-start">
|
||||
<div class="col-xl-4">
|
||||
<div class="card-panel stack-panel h-100">
|
||||
<div class="panel-header">
|
||||
<h3>1. Votre identité</h3>
|
||||
<p>Le MVP permet de simuler des profils pour tester une messagerie privée sans flux d'inscription complet.</p>
|
||||
</div>
|
||||
|
||||
{% if profiles %}
|
||||
<form method="get" class="profile-switcher mb-4">
|
||||
<label class="form-label" for="profile-select">Profil actif</label>
|
||||
<select class="form-select" id="profile-select" name="profile" onchange="this.form.submit()">
|
||||
{% for profile in profiles %}
|
||||
<option value="{{ profile.pk }}" {% if active_profile and profile.pk == active_profile.pk %}selected{% endif %}>{{ profile.display_name }} · @{{ profile.handle }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{% url 'create_profile' %}" class="resaka-form" novalidate>
|
||||
{% csrf_token %}
|
||||
{% for field in profile_form %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||
{{ field }}
|
||||
{% if field.errors %}<div class="form-error">{{ field.errors|striptags }}</div>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<button type="submit" class="btn btn-brand w-100">Créer ce profil</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-8">
|
||||
<div class="card-panel mb-4">
|
||||
<div class="panel-header d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-center">
|
||||
<div>
|
||||
<h3>2. Démarrer une discussion privée</h3>
|
||||
<p>{% if active_profile %}Vous envoyez le premier message en tant que <strong>{{ active_profile.display_name }}</strong>.{% else %}Créez d'abord un profil pour activer le chat.{% endif %}</p>
|
||||
</div>
|
||||
{% if active_profile %}
|
||||
<div class="presence-chip"><span class="presence-dot"></span> Connecté comme @{{ active_profile.handle }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<form method="post" action="{% url 'start_conversation' %}" class="resaka-form">
|
||||
{% csrf_token %}
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="{{ conversation_form.recipient.id_for_label }}">{{ conversation_form.recipient.label }}</label>
|
||||
{{ conversation_form.recipient }}
|
||||
{% if conversation_form.recipient.errors %}<div class="form-error">{{ conversation_form.recipient.errors|striptags }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label" for="{{ conversation_form.body.id_for_label }}">{{ conversation_form.body.label }}</label>
|
||||
{{ conversation_form.body }}
|
||||
{% if conversation_form.body.errors %}<div class="form-error">{{ conversation_form.body.errors|striptags }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="helper-row mt-3 d-flex flex-wrap gap-2">
|
||||
<span class="helper-label">Ajouts rapides :</span>
|
||||
<button class="emoji-chip" type="button" data-emoji="😊">😊 Bonjour</button>
|
||||
<button class="emoji-chip" type="button" data-emoji="🔥">🔥 Update</button>
|
||||
<button class="emoji-chip" type="button" data-emoji="👍">👍 OK</button>
|
||||
<button class="emoji-chip" type="button" data-emoji="🎯">🎯 Important</button>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-brand mt-4">Créer ou continuer la conversation</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card-panel">
|
||||
<div class="panel-header d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-center">
|
||||
<div>
|
||||
<h3>3. Discussions récentes</h3>
|
||||
<p>Liste responsive avec aperçu du dernier message et badge de notifications non lues.</p>
|
||||
</div>
|
||||
{% if active_profile %}
|
||||
<span class="panel-note">Affichage filtré pour @{{ active_profile.handle }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if conversations %}
|
||||
<div class="conversation-list">
|
||||
{% for conversation in conversations %}
|
||||
<a class="conversation-card" href="{% url 'conversation_detail' conversation.pk %}?profile={{ active_profile.pk }}">
|
||||
<div class="avatar-orb" style="background: {{ conversation.counterpart.avatar_color }};">
|
||||
{{ conversation.counterpart.display_name|slice:":1"|upper }}
|
||||
</div>
|
||||
<div class="conversation-copy">
|
||||
<div class="conversation-head">
|
||||
<h4>{{ conversation.counterpart.display_name }}</h4>
|
||||
<span>{{ conversation.updated_at|date:"d M · H:i" }}</span>
|
||||
</div>
|
||||
<p>@{{ conversation.counterpart.handle }}{% if conversation.counterpart.status_text %} · {{ conversation.counterpart.status_text }}{% endif %}</p>
|
||||
{% if conversation.last_message %}
|
||||
<strong>{{ conversation.last_message.author.display_name }}</strong>
|
||||
<span>{{ conversation.last_message.body|truncatechars:90 }}</span>
|
||||
{% else %}
|
||||
<span>Aucun message pour le moment.</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="conversation-meta">
|
||||
{% if conversation.unread_count %}
|
||||
<span class="badge unread-badge">{{ conversation.unread_count }} non lus</span>
|
||||
{% endif %}
|
||||
<span class="open-link">Ouvrir</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-illustration">💬</div>
|
||||
<h4>Aucune discussion pour l'instant</h4>
|
||||
<p>Créez un profil puis envoyez un premier message privé pour voir la liste s'animer ici.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import home
|
||||
from .views import conversation_detail, create_profile, home, react_message, start_conversation
|
||||
|
||||
urlpatterns = [
|
||||
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
|
||||
|
||||
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 .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):
|
||||
"""Render the landing screen with loader and environment details."""
|
||||
host_name = request.get_host().lower()
|
||||
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
|
||||
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 = {
|
||||
"project_name": "New Style",
|
||||
"project_name": "RJL Resaka",
|
||||
"agent_brand": agent_brand,
|
||||
"django_version": django_version(),
|
||||
"python_version": platform.python_version(),
|
||||
"current_time": now,
|
||||
"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", ""),
|
||||
"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)
|
||||
|
||||
|
||||
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 */
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
/* RJL Resaka custom theme */
|
||||
:root {
|
||||
--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