From db8313c88e26f822f5f87975ee7bdca99da2e578 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sun, 5 Apr 2026 20:23:53 +0000 Subject: [PATCH] V2 --- RJLResaka/README_IMPORT.txt | 41 + .../WEB-INF/views/auth/forgot-password.jsp | 52 ++ .../WebContent/WEB-INF/views/auth/login.jsp | 53 ++ .../WEB-INF/views/auth/register.jsp | 63 ++ .../WEB-INF/views/auth/reset-password.jsp | 55 ++ .../WebContent/WEB-INF/views/dashboard.jsp | 77 ++ RJLResaka/WebContent/WEB-INF/views/home.jsp | 56 ++ RJLResaka/WebContent/WEB-INF/web.xml | 33 + RJLResaka/WebContent/assets/css/app.css | 316 ++++++++ RJLResaka/WebContent/assets/js/app.js | 11 + RJLResaka/WebContent/index.jsp | 4 + RJLResaka/database/rjlresaka.sql | 94 +++ RJLResaka/docs/INSTALL_JARS.txt | 15 + .../com/rjlresaka/dao/PasswordResetDAO.java | 49 ++ RJLResaka/src/com/rjlresaka/dao/UserDAO.java | 121 +++ .../src/com/rjlresaka/dao/package-info.java | 1 + .../src/com/rjlresaka/filter/AuthFilter.java | 43 ++ .../filter/CharacterEncodingFilter.java | 33 + .../com/rjlresaka/filter/package-info.java | 1 + RJLResaka/src/com/rjlresaka/model/User.java | 107 +++ .../src/com/rjlresaka/model/package-info.java | 1 + .../rjlresaka/servlet/DashboardServlet.java | 36 + .../servlet/ForgotPasswordServlet.java | 62 ++ .../com/rjlresaka/servlet/HomeServlet.java | 26 + .../com/rjlresaka/servlet/LoginServlet.java | 56 ++ .../com/rjlresaka/servlet/LogoutServlet.java | 25 + .../rjlresaka/servlet/RegisterServlet.java | 94 +++ .../servlet/ResetPasswordServlet.java | 74 ++ .../com/rjlresaka/servlet/package-info.java | 1 + .../rjlresaka/util/DatabaseConnection.java | 23 + .../src/com/rjlresaka/util/PasswordUtil.java | 20 + .../src/com/rjlresaka/util/TokenUtil.java | 19 + .../src/com/rjlresaka/util/package-info.java | 1 + core/__pycache__/admin.cpython-311.pyc | Bin 212 -> 2214 bytes core/__pycache__/forms.cpython-311.pyc | Bin 0 -> 5440 bytes core/__pycache__/models.cpython-311.pyc | Bin 209 -> 4571 bytes core/__pycache__/urls.cpython-311.pyc | Bin 347 -> 783 bytes core/__pycache__/views.cpython-311.pyc | Bin 1364 -> 14778 bytes core/admin.py | 32 +- core/forms.py | 101 +++ core/migrations/0001_initial.py | 66 ++ .../__pycache__/0001_initial.cpython-311.pyc | Bin 0 -> 3259 bytes core/models.py | 69 +- core/templates/base.html | 64 +- core/templates/core/chat_detail.html | 106 +++ core/templates/core/index.html | 328 ++++---- core/urls.py | 6 +- core/views.py | 247 +++++- static/css/custom.css | 702 +++++++++++++++++- 49 files changed, 3218 insertions(+), 166 deletions(-) create mode 100644 RJLResaka/README_IMPORT.txt create mode 100644 RJLResaka/WebContent/WEB-INF/views/auth/forgot-password.jsp create mode 100644 RJLResaka/WebContent/WEB-INF/views/auth/login.jsp create mode 100644 RJLResaka/WebContent/WEB-INF/views/auth/register.jsp create mode 100644 RJLResaka/WebContent/WEB-INF/views/auth/reset-password.jsp create mode 100644 RJLResaka/WebContent/WEB-INF/views/dashboard.jsp create mode 100644 RJLResaka/WebContent/WEB-INF/views/home.jsp create mode 100644 RJLResaka/WebContent/WEB-INF/web.xml create mode 100644 RJLResaka/WebContent/assets/css/app.css create mode 100644 RJLResaka/WebContent/assets/js/app.js create mode 100644 RJLResaka/WebContent/index.jsp create mode 100644 RJLResaka/database/rjlresaka.sql create mode 100644 RJLResaka/docs/INSTALL_JARS.txt create mode 100644 RJLResaka/src/com/rjlresaka/dao/PasswordResetDAO.java create mode 100644 RJLResaka/src/com/rjlresaka/dao/UserDAO.java create mode 100644 RJLResaka/src/com/rjlresaka/dao/package-info.java create mode 100644 RJLResaka/src/com/rjlresaka/filter/AuthFilter.java create mode 100644 RJLResaka/src/com/rjlresaka/filter/CharacterEncodingFilter.java create mode 100644 RJLResaka/src/com/rjlresaka/filter/package-info.java create mode 100644 RJLResaka/src/com/rjlresaka/model/User.java create mode 100644 RJLResaka/src/com/rjlresaka/model/package-info.java create mode 100644 RJLResaka/src/com/rjlresaka/servlet/DashboardServlet.java create mode 100644 RJLResaka/src/com/rjlresaka/servlet/ForgotPasswordServlet.java create mode 100644 RJLResaka/src/com/rjlresaka/servlet/HomeServlet.java create mode 100644 RJLResaka/src/com/rjlresaka/servlet/LoginServlet.java create mode 100644 RJLResaka/src/com/rjlresaka/servlet/LogoutServlet.java create mode 100644 RJLResaka/src/com/rjlresaka/servlet/RegisterServlet.java create mode 100644 RJLResaka/src/com/rjlresaka/servlet/ResetPasswordServlet.java create mode 100644 RJLResaka/src/com/rjlresaka/servlet/package-info.java create mode 100644 RJLResaka/src/com/rjlresaka/util/DatabaseConnection.java create mode 100644 RJLResaka/src/com/rjlresaka/util/PasswordUtil.java create mode 100644 RJLResaka/src/com/rjlresaka/util/TokenUtil.java create mode 100644 RJLResaka/src/com/rjlresaka/util/package-info.java create mode 100644 core/__pycache__/forms.cpython-311.pyc create mode 100644 core/forms.py create mode 100644 core/migrations/0001_initial.py create mode 100644 core/migrations/__pycache__/0001_initial.cpython-311.pyc create mode 100644 core/templates/core/chat_detail.html diff --git a/RJLResaka/README_IMPORT.txt b/RJLResaka/README_IMPORT.txt new file mode 100644 index 0000000..2d731e2 --- /dev/null +++ b/RJLResaka/README_IMPORT.txt @@ -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 diff --git a/RJLResaka/WebContent/WEB-INF/views/auth/forgot-password.jsp b/RJLResaka/WebContent/WEB-INF/views/auth/forgot-password.jsp new file mode 100644 index 0000000..043e8c9 --- /dev/null +++ b/RJLResaka/WebContent/WEB-INF/views/auth/forgot-password.jsp @@ -0,0 +1,52 @@ +<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> + + + + + + Mot de passe oublié | RJLResaka + + + + + + + +
+
+ ← Retour + Réinitialisation +

Mot de passe oublié

+

Entrez votre email. Pour l'instant, le lien est affiché à l'écran; ensuite vous pourrez brancher JavaMail.

+ + <% if (request.getAttribute("error") != null) { %> +
<%= request.getAttribute("error") %>
+ <% } %> + <% if (request.getAttribute("success") != null) { %> +
<%= request.getAttribute("success") %>
+ <% } %> + +
+ + +
+ + <% if (request.getAttribute("resetLink") != null) { %> +
+ Lien généré :
+ <%= request.getAttribute("resetLink") %>

+ Token : <%= request.getAttribute("generatedToken") %> +
+ <% } %> + + <% if (request.getAttribute("debugMessage") != null) { %> +

Détail technique: <%= request.getAttribute("debugMessage") %>

+ <% } %> +
+
+ + + diff --git a/RJLResaka/WebContent/WEB-INF/views/auth/login.jsp b/RJLResaka/WebContent/WEB-INF/views/auth/login.jsp new file mode 100644 index 0000000..d287e4f --- /dev/null +++ b/RJLResaka/WebContent/WEB-INF/views/auth/login.jsp @@ -0,0 +1,53 @@ +<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> + + + + + + Connexion | RJLResaka + + + + + + + +
+
+ ← Retour + Connexion +

Bon retour sur RJLResaka

+

Utilisez votre email ou votre nom d'utilisateur pour continuer.

+ + <% if (request.getAttribute("error") != null) { %> +
<%= request.getAttribute("error") %>
+ <% } %> + <% if (request.getAttribute("success") != null) { %> +
<%= request.getAttribute("success") %>
+ <% } %> + +
+ + + +
+ + + + <% if (request.getAttribute("debugMessage") != null) { %> +

Détail technique: <%= request.getAttribute("debugMessage") %>

+ <% } %> +
+
+ + + diff --git a/RJLResaka/WebContent/WEB-INF/views/auth/register.jsp b/RJLResaka/WebContent/WEB-INF/views/auth/register.jsp new file mode 100644 index 0000000..7ebc059 --- /dev/null +++ b/RJLResaka/WebContent/WEB-INF/views/auth/register.jsp @@ -0,0 +1,63 @@ +<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> + + + + + + Inscription | RJLResaka + + + + + + + +
+
+ ← Retour + Inscription +

Créez votre compte

+

Une seule inscription suffit pour commencer vos discussions privées.

+ + <% if (request.getAttribute("error") != null) { %> +
<%= request.getAttribute("error") %>
+ <% } %> + +
+ + + + + +
+ +
+
+ + + + <% if (request.getAttribute("debugMessage") != null) { %> +

Détail technique: <%= request.getAttribute("debugMessage") %>

+ <% } %> +
+
+ + + diff --git a/RJLResaka/WebContent/WEB-INF/views/auth/reset-password.jsp b/RJLResaka/WebContent/WEB-INF/views/auth/reset-password.jsp new file mode 100644 index 0000000..d3f05c3 --- /dev/null +++ b/RJLResaka/WebContent/WEB-INF/views/auth/reset-password.jsp @@ -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"); + } +%> + + + + + + Nouveau mot de passe | RJLResaka + + + + + + + +
+
+ ← Retour + Nouveau mot de passe +

Réinitialisez votre mot de passe

+

Collez le token reçu ou ouvrez directement le lien généré.

+ + <% if (request.getAttribute("error") != null) { %> +
<%= request.getAttribute("error") %>
+ <% } %> + +
+ + + + +
+ + <% if (request.getAttribute("debugMessage") != null) { %> +

Détail technique: <%= request.getAttribute("debugMessage") %>

+ <% } %> +
+
+ + + diff --git a/RJLResaka/WebContent/WEB-INF/views/dashboard.jsp b/RJLResaka/WebContent/WEB-INF/views/dashboard.jsp new file mode 100644 index 0000000..c9bf45c --- /dev/null +++ b/RJLResaka/WebContent/WEB-INF/views/dashboard.jsp @@ -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" %> + + + + + + Tableau de bord | RJLResaka + + + + + + + +
+ + +
+
+
+ Utilisateurs +

Choisissez une personne à qui parler

+
+ ${fn:length(users)} compte(s) +
+ + +
${error}
+
+ +
+ +
+
${item.initials}
+

${item.fullName}

+

@${item.username}

+ ${item.email} + +
+
+ + +
+

Aucun autre utilisateur pour le moment

+

Créez un deuxième compte pour tester le chat privé ensuite.

+
+
+
+ + +

Détail technique: ${debugMessage}

+
+
+
+ + + diff --git a/RJLResaka/WebContent/WEB-INF/views/home.jsp b/RJLResaka/WebContent/WEB-INF/views/home.jsp new file mode 100644 index 0000000..f8dfbd6 --- /dev/null +++ b/RJLResaka/WebContent/WEB-INF/views/home.jsp @@ -0,0 +1,56 @@ +<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> + + + + + + RJLResaka | Discussion privée moderne en Java JEE + + + + + + + +
+
+
+ RJLResaka • Java JEE +

Discutez comme sur Messenger, dans votre propre application web.

+

+ 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. +

+ +
    +
  • Tomcat 9 + Servlet/JSP
  • +
  • MySQL Workbench
  • +
  • Base prête pour chat privé + fichiers
  • +
+
+ +
+
+ + + diff --git a/RJLResaka/WebContent/WEB-INF/web.xml b/RJLResaka/WebContent/WEB-INF/web.xml new file mode 100644 index 0000000..37d9c91 --- /dev/null +++ b/RJLResaka/WebContent/WEB-INF/web.xml @@ -0,0 +1,33 @@ + + + + RJLResaka + + + db.driver + com.mysql.cj.jdbc.Driver + + + db.url + jdbc:mysql://127.0.0.1:3306/rjlresaka?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC + + + db.user + root + + + db.password + + + + + index.jsp + + + + 60 + + diff --git a/RJLResaka/WebContent/assets/css/app.css b/RJLResaka/WebContent/assets/css/app.css new file mode 100644 index 0000000..fd188c3 --- /dev/null +++ b/RJLResaka/WebContent/assets/css/app.css @@ -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; } +} diff --git a/RJLResaka/WebContent/assets/js/app.js b/RJLResaka/WebContent/assets/js/app.js new file mode 100644 index 0000000..6aec255 --- /dev/null +++ b/RJLResaka/WebContent/assets/js/app.js @@ -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); + }); +}); diff --git a/RJLResaka/WebContent/index.jsp b/RJLResaka/WebContent/index.jsp new file mode 100644 index 0000000..656fdc1 --- /dev/null +++ b/RJLResaka/WebContent/index.jsp @@ -0,0 +1,4 @@ +<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> +<% + response.sendRedirect(request.getContextPath() + "/home"); +%> diff --git a/RJLResaka/database/rjlresaka.sql b/RJLResaka/database/rjlresaka.sql new file mode 100644 index 0000000..532a1ff --- /dev/null +++ b/RJLResaka/database/rjlresaka.sql @@ -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'); diff --git a/RJLResaka/docs/INSTALL_JARS.txt b/RJLResaka/docs/INSTALL_JARS.txt new file mode 100644 index 0000000..1b037a9 --- /dev/null +++ b/RJLResaka/docs/INSTALL_JARS.txt @@ -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 diff --git a/RJLResaka/src/com/rjlresaka/dao/PasswordResetDAO.java b/RJLResaka/src/com/rjlresaka/dao/PasswordResetDAO.java new file mode 100644 index 0000000..b4d27e6 --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/dao/PasswordResetDAO.java @@ -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(); + } + } +} diff --git a/RJLResaka/src/com/rjlresaka/dao/UserDAO.java b/RJLResaka/src/com/rjlresaka/dao/UserDAO.java new file mode 100644 index 0000000..e6e8ff6 --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/dao/UserDAO.java @@ -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 findOtherUsers(int currentUserId, ServletContext context) throws SQLException, ClassNotFoundException { + String sql = "SELECT * FROM users WHERE id <> ? ORDER BY full_name ASC"; + List users = new ArrayList(); + 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; + } +} diff --git a/RJLResaka/src/com/rjlresaka/dao/package-info.java b/RJLResaka/src/com/rjlresaka/dao/package-info.java new file mode 100644 index 0000000..3c1e41c --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/dao/package-info.java @@ -0,0 +1 @@ +package com.rjlresaka.dao; diff --git a/RJLResaka/src/com/rjlresaka/filter/AuthFilter.java b/RJLResaka/src/com/rjlresaka/filter/AuthFilter.java new file mode 100644 index 0000000..95802fe --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/filter/AuthFilter.java @@ -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. + } +} diff --git a/RJLResaka/src/com/rjlresaka/filter/CharacterEncodingFilter.java b/RJLResaka/src/com/rjlresaka/filter/CharacterEncodingFilter.java new file mode 100644 index 0000000..5bfe64e --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/filter/CharacterEncodingFilter.java @@ -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. + } +} diff --git a/RJLResaka/src/com/rjlresaka/filter/package-info.java b/RJLResaka/src/com/rjlresaka/filter/package-info.java new file mode 100644 index 0000000..4e0c8a6 --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/filter/package-info.java @@ -0,0 +1 @@ +package com.rjlresaka.filter; diff --git a/RJLResaka/src/com/rjlresaka/model/User.java b/RJLResaka/src/com/rjlresaka/model/User.java new file mode 100644 index 0000000..9e04c43 --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/model/User.java @@ -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(); + } +} diff --git a/RJLResaka/src/com/rjlresaka/model/package-info.java b/RJLResaka/src/com/rjlresaka/model/package-info.java new file mode 100644 index 0000000..b7f9cfb --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/model/package-info.java @@ -0,0 +1 @@ +package com.rjlresaka.model; diff --git a/RJLResaka/src/com/rjlresaka/servlet/DashboardServlet.java b/RJLResaka/src/com/rjlresaka/servlet/DashboardServlet.java new file mode 100644 index 0000000..a357925 --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/servlet/DashboardServlet.java @@ -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 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); + } + } +} diff --git a/RJLResaka/src/com/rjlresaka/servlet/ForgotPasswordServlet.java b/RJLResaka/src/com/rjlresaka/servlet/ForgotPasswordServlet.java new file mode 100644 index 0000000..38eb9ea --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/servlet/ForgotPasswordServlet.java @@ -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); + } + } +} diff --git a/RJLResaka/src/com/rjlresaka/servlet/HomeServlet.java b/RJLResaka/src/com/rjlresaka/servlet/HomeServlet.java new file mode 100644 index 0000000..a3848d7 --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/servlet/HomeServlet.java @@ -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); + } +} diff --git a/RJLResaka/src/com/rjlresaka/servlet/LoginServlet.java b/RJLResaka/src/com/rjlresaka/servlet/LoginServlet.java new file mode 100644 index 0000000..21062a0 --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/servlet/LoginServlet.java @@ -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); + } + } +} diff --git a/RJLResaka/src/com/rjlresaka/servlet/LogoutServlet.java b/RJLResaka/src/com/rjlresaka/servlet/LogoutServlet.java new file mode 100644 index 0000000..6014651 --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/servlet/LogoutServlet.java @@ -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"); + } +} diff --git a/RJLResaka/src/com/rjlresaka/servlet/RegisterServlet.java b/RJLResaka/src/com/rjlresaka/servlet/RegisterServlet.java new file mode 100644 index 0000000..9eac484 --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/servlet/RegisterServlet.java @@ -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]; + } +} diff --git a/RJLResaka/src/com/rjlresaka/servlet/ResetPasswordServlet.java b/RJLResaka/src/com/rjlresaka/servlet/ResetPasswordServlet.java new file mode 100644 index 0000000..99ff707 --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/servlet/ResetPasswordServlet.java @@ -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); + } + } +} diff --git a/RJLResaka/src/com/rjlresaka/servlet/package-info.java b/RJLResaka/src/com/rjlresaka/servlet/package-info.java new file mode 100644 index 0000000..609cfef --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/servlet/package-info.java @@ -0,0 +1 @@ +package com.rjlresaka.servlet; diff --git a/RJLResaka/src/com/rjlresaka/util/DatabaseConnection.java b/RJLResaka/src/com/rjlresaka/util/DatabaseConnection.java new file mode 100644 index 0000000..ac755c4 --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/util/DatabaseConnection.java @@ -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); + } +} diff --git a/RJLResaka/src/com/rjlresaka/util/PasswordUtil.java b/RJLResaka/src/com/rjlresaka/util/PasswordUtil.java new file mode 100644 index 0000000..2f2ebae --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/util/PasswordUtil.java @@ -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); + } +} diff --git a/RJLResaka/src/com/rjlresaka/util/TokenUtil.java b/RJLResaka/src/com/rjlresaka/util/TokenUtil.java new file mode 100644 index 0000000..0925570 --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/util/TokenUtil.java @@ -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(); + } +} diff --git a/RJLResaka/src/com/rjlresaka/util/package-info.java b/RJLResaka/src/com/rjlresaka/util/package-info.java new file mode 100644 index 0000000..8dbd1bd --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/util/package-info.java @@ -0,0 +1 @@ +package com.rjlresaka.util; diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index 5e8987a0cd478c226e501b5189550e758662c560..1943ba312eedba39c219f57509a486aadb8fbc89 100644 GIT binary patch literal 2214 zcma)7%}?V-6rZu(bu+UGV>|1ty2uarpoYGBWRu?H@2?KPow5fTu3htqNXaXa4Eet zuB>ml^qza)i0D}mazBqA`<&GCJ22QIoH&#bM`P5{ndTIj4$cayI$+>qbP2O23*=_# zZM#jc!BuhHYqF55Q5k!2OS&=NidDrI+>1GLz1Y?T)`u8HFacI=LzUfB*>zRLbsG_D zg&e+3*Zr~Og=iK>VIX6-=Sx|Vd%|CLp9MT*@-K1@mdML@YwJ;i*IM;fGj7%R7WZ3m zBx*lL;dwMFPfdJ!0Fc44L<{gB(Z~%OEWq zI7e^87KDv}b!2O*kSa`OMh2Pl^Aiq|MDM-li}0~WekB{wXN|#?{JS=r4+(5qTRnXQ zV<^{m(oX47eM6$XMUmFt1Ni)9`f6ZH{rw}=5=l?U$yoKp*1o=9-Pd3Ic2u5ADPFb9 z!{v`DRio)~hl>h|D9x`j@kR;!1_z3mK&Qh=zK3%NxPOV8zMAA8Aj#E-$(NnU_mh=is>1Z!hk_~Y6boe zVanFvvV!Wbtl%L|lbv2P%KFZ$w?a?!?t%2!Vv(iW9*XB>`B8QkE{lgCf{e>$z^+ay z?U2Qz!QTn_@aWaQZjorWlc}ABUF(=GwCO@u)97-ST!;Os_N;w|3l|7Lj&}D_zM|9n zU2+|EAGaspI>UvF{;HX~G>_?go6dJN9sI)jFzg%cnFpx7fZ7Y_61LcrzqC?1rZa6i c)746_M6Sc`2knVB&v4-a0jHq)$ literal 212 zcmZ3^%ge<81a&?uGVOu%V-N=hn4pZ$LO{lJh7^Vr#vF!R#wbQch7_h?22JLdAO)I? zw^$QXax?S%G?{MkrDP@MrRVD<=jW9aWhNCd0~M@f_zY6_OIJT5KQ~psG^sSNq*On( zA~m_RB)>?%JijQrxF9h(RX;huC{-V9lwLvQFAkgB{FKt1RJ$S$pl(JWE|vunAD9^# P8E-HsT)>8k*nlbkB)c`? diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d9ea6929791a430f57ca355fc25b37a4fa2032ef GIT binary patch literal 5440 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 Ui)AOv2ug8ZV2}Z#A~v810Q>+R4FCWD diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index f705988a223abc7b5af333902925bc196186bbf5..26549ceeb725b5e10687daba4cee06ef29955299 100644 GIT binary patch literal 783 zcma)&zi-n(6vyx4XD6vi8wDhgK#KUWL_$3Y2BfOWK)PgUhGwz^k?|d=seeRw=ay#5 z*#Dp%nLr3e1|$alg)_=fc|v097O7h&&aM$H%E0s4I=|0--}l}7&}gh69q#uZ{uxH- zSN`)S>gDGAi{2a~iYN|Hh#ia(txzMVgoa~;;6P|PW@tH9SaqrxRS?4zm`Tmg)_Vh? zPx`%#f>asM+EP+yRl2g2*vz0emXZdu=<2^@Y&tc%wu3s&v)s6YGnlw?H@_h2nAuG) zj`kTBuJq$*M45E`AY1h~b0r&1c>LNASO&Xs$g+k`dU6ypA>1*`T0**9j;`u6#H*!8 z1HtoIXXd`o4n(0Z+=~v2^Mzw(>!W%glJQ7L^2`l zE%Oo?b8-;#Juz`TM!cA7tUcBzhZnWYFs4bsy1b>u`CP>}{aF|o<5P4`U0*-ZNd4bh zSjeo;wvJjy!xKwEJB9Wf+NV&PZK?X*lb7nra|OK=dUNQN*|xg>RBdc47^E-Ie?v8?C3d1=J%k;x{FTS=F^i$~1p}(-LwaU!SAKujc{|1O^-Kqcp delta 238 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%0Auhez{t}i>3 zqZ3+GZoH^8UX<92#d;jmAAOX!^}V^wptvvmfH2aQ3za`VR6#AA#-+I;(tOKiY0a|S7S!vt2qVu>m zyU?@?1l+D|CAaHKj}FVu-HLO!MDJd$lGtDy5S3&Ww!(9)x^@xr%Hc;Qqnp(T1&9?F z)fSxvS45?}6m+NJvFBBRGuPU9K_#fQ)zSc0D(wU0)f9wEFIlTjS+G`>1)?By>)ohT z&;!DMwf$BH&A6I#SKv<7sSW_hE7nTmeBgPs?_;6lhrnH0U!D%Vg*QuAotFT!)&%#Q z9bj|K@mTu!Bsn89M1zo#KTtlT?se>BUb2qu4k)E zTPyC)pW8q6EQj{yLwg^;^W_aWbXEzSl`c+7?l)xj8;bi4@`D4VaRYF^k{#^1BC8^C z&dJP#!c0iagi+!=a|V{|cUjr_qT+n9#wBRA?kKc&DsX0GLTTN(UHcQOrs##};%Ym6u1)?b5Y?ug zqT`WOYG<@c=j!169$>@x6#3SwS1T6gro#BMr-VR`I&1g~KbYz)6}SpN0^@N(RjLSZ zz*~eLiwHb{aRhoHqN!AYV|47ACT{u-Oy$x5!=gu3XibJ zDB&kS;rAgXQ-N~KlfMGxnJ0fwsm+q{Do|aL@hVUQ((|tZ)hQXT0`-dY{A;c9FG!T( zdP+@6-|s3=JEiAeMaBd^Y&f!QYA&+%ruHILAw4G34*2;?nJ``x{79xk*1w1u6>`$_ lq6yl8gbF=r+GKhil63S9(@Um3MXExkO%0^T+LU~<{{;$TnLYpj delta 806 zcmZ`%%}*0S6rb(KvTdQID5VBVmye`rH1%RyLrAL zR_zkQn5cDwS5%W~(5wn4DkfV7qr}35t2*YIVcHpIDKZ^u1XgfeUFSO{cBJ9h405BZ zYY*=)%s(zJJ-D;DP<*nsoL1byO-8G@#EV3;uyYW3z8md(3`;BHVvU)NNQp9h!+;5I zXDZw>(?M4e^RG~wyMB|_F=KwH-}Mq&XJMf!o2JG$D6^QDqwArzE5j8im-)bv_PYZN zzo=oGvnbeXNKgzY;trH3;dq4FY06PF;zXRGs-<@kCvr1P+S7uJe zrks!CqvezM_{r$RX>zi)(@y8Vd5)9wo#gy!BH4P{PR)M4dz>hA5`}Iw5RtnGXm9c7 zWi&YQHq{=#epo&ZWjmp4+m}6q{O}Mg4jUT-a%H`$kuqiCp;*{i&BgMf4R`R|pQ&*e zd~XK5DnB4LAtJ8={Zi&Es@F`MXVYMcUukb3XX;iVGucLetgbvP<+|u!>Sg;4cYDqg diff --git a/core/admin.py b/core/admin.py index 8c38f3f..54ec2a6 100644 --- a/core/admin.py +++ b/core/admin.py @@ -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] diff --git a/core/forms.py b/core/forms.py new file mode 100644 index 0000000..b8204b9 --- /dev/null +++ b/core/forms.py @@ -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(), + ) diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py new file mode 100644 index 0000000..ef67096 --- /dev/null +++ b/core/migrations/0001_initial.py @@ -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'), + ), + ] diff --git a/core/migrations/__pycache__/0001_initial.cpython-311.pyc b/core/migrations/__pycache__/0001_initial.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bada779b653185c416ba3d47c50c33f3d4048885 GIT binary patch literal 3259 zcmbtWJ8T5%2KdSK_LHL<9y2aP2yqtr|zXc>9u__cr_?4QuHD5wvhGhvF6RJM8h?;Y$%qhYbN#AbcCyBspHFk zVx7jz8(uay=JOUMBo;koq3D$a?5p^@0Z2j~=m}JU2R$MZkr(-z{%weV29I0TBoyGD zp>7ZO1)HI53B2Gr@CtJ;t_|x$VzXyk1Rr?Z0!Wb)KE20%%3jomPN8U7QpINMs3s77 zm_z(`aOgkAVW2s9d}ef-`@ovI1q}d)#0j47977V(&}-BO5mW@-QPkb-t9y0q4%2~9Ni<3{fGYtUiKtjIRGmVe&Y)CA7nFz&i&z;$I*$I zn-fQw0k*Glc((I@c(&Yc&sO#o2T&ePph+59>YhAzXk=E`Hn3sxgNRBhny1qBxvZB) zl!m3tB?BuKMzp7Hs5Qmdl<(tB8Zxn=DpmC{rV)r;)lDpGN)6M1*;uXMlC}2;G=wA0 zw^~)S`*6}JkK`)W%GNp!cMxU8%8H#yH2tBhAVkBBI%2cAV?mw(jM1QOAed1rKkhmI z$^{ySusqbuQ}C~;xQa{~(ChBe+Pi{H<-W3P=xb^f5=N?7uPU27;k{*+r)aRPNd4=I zhN?I#b(#%LebB(P$FvlyVagVMWbOTnIYyKX#ZnBpq*rz0abWb~#kot@uI;g%4V(iA zuPKeH<)Q?ZuxTn~Ouef*+N42{v&8b+W9KvtsHO}(2vQj3$fd)J5v;kf=HpmNH*oIo zVAexJx+cS=fh{0q;3^l6OCVV~^@DipkX@Wd8eD>UM}m-nOKM%knnh1T6{{N{`C)Jq z(8K&j;wl*lmYa_Y3rh^tEo{(1w{k4w)W7Ls%=R*fiK}Nqc7brdwiHqQUMh8ktec)36=q+1k?3?7CvO)!sRveMha~ z&dBX*qui<9f%S5$kr`dDVnuUX;d#(fEo+~`M)sqMqLpTEf!M!0hzx+3C4! zG&qe=CrGd3&RzXq;U%Ob{PIbDU9aJMW3{1KjXZvYOOT+Ef2bSx&AL*;d8T~+)g7Oo zoSeKMcWCG8n+ChPxnUc}{2Zu1Ir#k47k^T4yyAfW$)Xb!Lg{~o?cmvVEd5h#kR;AM z-LMnqcD+B0k)ljAgCxvW!n6}6iCXR08IqdVnzvIEJ0CrtC8>p0YQat|km&VxY?!1k zZEe`;OV0;LdcKvOx6|_^S~xVYdxxZFTIm@(Jwu|iPRKXV=ky7&3c`^-4+yvlFnE%Hl~%!RK{+nEcyDAccI+$}nf%&sk4)}ZKX}N;<5xk@7|JLaJ$bf^u$&qF6#u7bz*1Zf1CE@f_-_}zWi5G zegF;+#ZOseYLP{Pg-Eb0a!GPd1wo|&rqbtIH(ikueM}@U`^-3Jg;0v^%w#+9UOSd* z$48&OZ^uW!U2Cd#ZpzLT?A&MM-Ui_P!%eZDrwA4%0_z043=hmcGfwyj-kCSxH307m z@iUfY|1wJ)EW-m4j^WATM{)m?8|-6T@4jwXKOJ)4`y5)$xqrNK-Iw2(0XG^t80RMv zKZM-(b%CL9s2foGCHNQje48?spvnFjo2y_Pk0^?cUl21y7;(RC;Y0GeujBt%^gF^y S^E7Nn$6m03{nw209DW5GL{pgn literal 0 HcmV?d00001 diff --git a/core/models.py b/core/models.py index 71a8362..600635e 100644 --- a/core/models.py +++ b/core/models.py @@ -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}" diff --git a/core/templates/base.html b/core/templates/base.html index 1e7e5fb..5d4986f 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -1,25 +1,57 @@ +{% load static %} - - + - {% block title %}Knowledge Base{% endblock %} - {% if project_description %} - - - - {% endif %} - {% if project_image_url %} - - - {% endif %} - {% load static %} + + {% block title %}{{ page_title|default:project_name|default:"RJL Resaka" }}{% endblock %} + + + + + + + {% block head %}{% endblock %} + +
+ - - {% block content %}{% endblock %} +
+ {% if messages %} +
+ {% for message in messages %} + + {% endfor %} +
+ {% endif %} + {% block content %}{% endblock %} +
+
+ + + + {% block scripts %}{% endblock %} - diff --git a/core/templates/core/chat_detail.html b/core/templates/core/chat_detail.html new file mode 100644 index 0000000..3049508 --- /dev/null +++ b/core/templates/core/chat_detail.html @@ -0,0 +1,106 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+ + +
+
+
+ Conversation active +

{{ counterpart.display_name }}

+

Messages privés, suivi de lecture et interface responsive.

+
+ notifications lues lors de l'ouverture +
+ +
+ {% for message in messages_list %} +
+
+
+
+ {{ message.author.display_name }} + {{ message.created_at|date:"H:i" }} + {% if message.author_id == active_profile.id and message.is_read %}Lu{% endif %} +
+

{{ message.body|linebreaksbr }}

+ {% if message.reaction %}
{{ message.reaction }}
{% endif %} +
+ +
+ {% for value, label in reaction_choices %} +
+ {% csrf_token %} + + +
+ {% endfor %} +
+
+
+ {% empty %} +
+
✉️
+

Commencez la conversation

+

Envoyez le premier message depuis le bloc ci-dessous.

+
+ {% endfor %} +
+ +
+
+ {% csrf_token %} + + {{ message_form.body }} + {% if message_form.body.errors %}
{{ message_form.body.errors|striptags }}
{% endif %} +
+ Emojis : + + + + +
+
+ + Retour à la liste +
+
+
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/index.html b/core/templates/core/index.html index faec813..b363928 100644 --- a/core/templates/core/index.html +++ b/core/templates/core/index.html @@ -1,145 +1,193 @@ {% extends "base.html" %} -{% block title %}{{ project_name }}{% endblock %} - -{% block head %} - - - - -{% endblock %} - {% block content %} -
-
-

Analyzing your requirements and generating your app…

-
- Loading… +
+
+
+
+
+ Messagerie privée • design inspiré des apps sociales +

Discutez en privé, avec style, notifications et emojis.

+

+ 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. +

+ +
+
+
+ {{ stats.profiles }} + profils créés +
+
+
+
+ {{ stats.conversations }} + discussions +
+
+
+
+ {{ stats.messages }} + messages envoyés +
+
+
+
+
+
+
+
+
+ +
+
+
Salut 👋 on peut discuter ce soir ?
+
Oui, je t'envoie le brief du projet RJL Resaka.
+
Top, j'adore le style Messenger + réactions ❤️
+
+ +
+
+
-

AppWizzy AI is collecting your requirements and applying the first changes.

-

This page will refresh automatically as the plan is implemented.

-

- Runtime: Django {{ django_version }} · Python {{ python_version }} - — UTC {{ current_time|date:"Y-m-d H:i:s" }} -

-
-
- Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC) -
-{% endblock %} \ No newline at end of file + + +
+
+
+ Premier MVP slice +

Créer un profil, démarrer un chat, suivre les non-lus.

+

Une vraie boucle de valeur : entrée, confirmation, liste des discussions et détail conversationnel.

+
+ +
+
+
+
+

1. Votre identité

+

Le MVP permet de simuler des profils pour tester une messagerie privée sans flux d'inscription complet.

+
+ + {% if profiles %} +
+ + +
+ {% endif %} + +
+ {% csrf_token %} + {% for field in profile_form %} +
+ + {{ field }} + {% if field.errors %}
{{ field.errors|striptags }}
{% endif %} +
+ {% endfor %} + +
+
+
+ +
+
+
+
+

2. Démarrer une discussion privée

+

{% if active_profile %}Vous envoyez le premier message en tant que {{ active_profile.display_name }}.{% else %}Créez d'abord un profil pour activer le chat.{% endif %}

+
+ {% if active_profile %} +
Connecté comme @{{ active_profile.handle }}
+ {% endif %} +
+ +
+ {% csrf_token %} +
+
+ + {{ conversation_form.recipient }} + {% if conversation_form.recipient.errors %}
{{ conversation_form.recipient.errors|striptags }}
{% endif %} +
+
+ + {{ conversation_form.body }} + {% if conversation_form.body.errors %}
{{ conversation_form.body.errors|striptags }}
{% endif %} +
+
+
+ Ajouts rapides : + + + + +
+ +
+
+ +
+
+
+

3. Discussions récentes

+

Liste responsive avec aperçu du dernier message et badge de notifications non lues.

+
+ {% if active_profile %} + Affichage filtré pour @{{ active_profile.handle }} + {% endif %} +
+ + {% if conversations %} + + {% else %} +
+
💬
+

Aucune discussion pour l'instant

+

Créez un profil puis envoyez un premier message privé pour voir la liste s'animer ici.

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