This commit is contained in:
Flatlogic Bot 2026-04-05 20:23:53 +00:00
parent fd834c4871
commit db8313c88e
49 changed files with 3218 additions and 166 deletions

View 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

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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&amp;allowPublicKeyRetrieval=true&amp;serverTimezone=UTC</param-value>
</context-param>
<context-param>
<param-name>db.user</param-name>
<param-value>root</param-value>
</context-param>
<context-param>
<param-name>db.password</param-name>
<param-value></param-value>
</context-param>
<welcome-file-list>
<welcome-file>index.jsp</welcome-file>
</welcome-file-list>
<session-config>
<session-timeout>60</session-timeout>
</session-config>
</web-app>

View File

@ -0,0 +1,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; }
}

View 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);
});
});

View File

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

View File

@ -0,0 +1,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');

View 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

View File

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

View File

@ -0,0 +1,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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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();
}
}

View File

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

View 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);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

View File

@ -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
View File

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

View File

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

View File

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

View File

@ -1,25 +1,57 @@
{% load static %}
<!DOCTYPE html>
<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>

View File

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

View File

@ -1,145 +1,193 @@
{% extends "base.html" %}
{% 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 %}

View File

@ -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"),
]

View File

@ -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}")

View File

@ -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%;
}
}