From 860dadf82c7e83822d3e7e16cf2a6b53cf3b8cff Mon Sep 17 00:00:00 2001 From: Leon Morival Date: Sun, 12 Apr 2026 14:56:43 +0200 Subject: [PATCH] feat: messages images and components --- bun.lock | 25 +++++ package.json | 3 + src/app/messages/page.tsx | 49 +++++++++ src/app/page.tsx | 29 ++--- src/components/messaging/ChatHeader.tsx | 40 +++++++ src/components/messaging/ChatInput.tsx | 102 ++++++++++++++++++ src/components/messaging/ChatWindow.tsx | 43 ++++++++ src/components/messaging/ConversationList.tsx | 83 ++++++++++++++ src/components/messaging/MessageItem.tsx | 42 ++++++++ src/components/messaging/MessageList.tsx | 36 +++++++ src/hooks/queries/auth.ts | 8 +- src/hooks/queries/messaging.ts | 99 +++++++++++++++++ src/lib/api-client.ts | 48 +++++---- src/lib/echo.ts | 30 ++++++ src/providers/auth-provider.tsx | 70 ++++++++++++ src/providers/index.tsx | 6 +- src/types/messaging.ts | 32 ++++++ 17 files changed, 708 insertions(+), 37 deletions(-) create mode 100644 src/app/messages/page.tsx create mode 100644 src/components/messaging/ChatHeader.tsx create mode 100644 src/components/messaging/ChatInput.tsx create mode 100644 src/components/messaging/ChatWindow.tsx create mode 100644 src/components/messaging/ConversationList.tsx create mode 100644 src/components/messaging/MessageItem.tsx create mode 100644 src/components/messaging/MessageList.tsx create mode 100644 src/hooks/queries/messaging.ts create mode 100644 src/lib/echo.ts create mode 100644 src/providers/auth-provider.tsx create mode 100644 src/types/messaging.ts diff --git a/bun.lock b/bun.lock index da2e5dc..41d2b5a 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,10 @@ "dependencies": { "@tanstack/react-query": "^5.99.0", "js-cookie": "^3.0.5", + "laravel-echo": "^2.3.4", + "lucide-react": "^1.8.0", "next": "16.2.3", + "pusher-js": "^8.5.0", "react": "19.2.4", "react-dom": "19.2.4", }, @@ -187,6 +190,8 @@ "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], + "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], @@ -397,6 +402,10 @@ "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "engine.io-client": ["engine.io-client@6.6.4", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.18.3", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw=="], + + "engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="], + "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], "es-abstract": ["es-abstract@1.24.2", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg=="], @@ -613,6 +622,8 @@ "language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="], + "laravel-echo": ["laravel-echo@2.3.4", "", { "peerDependencies": { "pusher-js": "*", "socket.io-client": "*" } }, "sha512-rpALCIK1uw2SrttcK9P5JzItt5I85RcfXQKUNnkcorzhtKeXi5GS0PVFFBH8ppNo8wnbdBKuD1EtIHgTbXo9FQ=="], + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], @@ -647,6 +658,8 @@ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lucide-react": ["lucide-react@1.8.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -719,6 +732,8 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "pusher-js": ["pusher-js@8.5.0", "", { "dependencies": { "tweetnacl": "^1.0.3" } }, "sha512-V7uzGi9bqOOOyM/6IkJdpFyjGZj7llz1v0oWnYkZKcYLvbz6VcHVLmzKqkvegjuMumpfIEKGLmWHwFb39XFCpw=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], @@ -771,6 +786,10 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="], + + "socket.io-parser": ["socket.io-parser@4.2.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], @@ -813,6 +832,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tweetnacl": ["tweetnacl@1.0.3", "", {}, "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], @@ -849,6 +870,10 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + + "xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], diff --git a/package.json b/package.json index 554cdf5..50d4f09 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,10 @@ "dependencies": { "@tanstack/react-query": "^5.99.0", "js-cookie": "^3.0.5", + "laravel-echo": "^2.3.4", + "lucide-react": "^1.8.0", "next": "16.2.3", + "pusher-js": "^8.5.0", "react": "19.2.4", "react-dom": "19.2.4" }, diff --git a/src/app/messages/page.tsx b/src/app/messages/page.tsx new file mode 100644 index 0000000..2d224cf --- /dev/null +++ b/src/app/messages/page.tsx @@ -0,0 +1,49 @@ +'use client' + +import { useState } from 'react'; +import { Conversation } from '@/types/messaging'; +import ConversationList from '@/components/messaging/ConversationList'; +import ChatWindow from '@/components/messaging/ChatWindow'; +import { MessageSquare } from 'lucide-react'; + +export default function MessagesPage() { + const [selectedConversation, setSelectedConversation] = useState(null); + + return ( +
+ {/* Sidebar / List */} +
+ +
+ + {/* Main Chat Area */} +
+ {selectedConversation ? ( +
+ {/* Back button for mobile */} + + +
+ ) : ( +
+
+ +
+

Vos messages

+

+ Sélectionnez une conversation pour commencer à discuter ou créez-en une nouvelle. +

+
+ )} +
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 71eda0e..e6cbffa 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,15 +1,14 @@ 'use client' -import { useUser, useLogout } from '@/hooks/queries/auth' import Link from 'next/link' import Button from '@/components/common/Button' import Card from '@/components/common/Card' import { useEffect, useState } from 'react' +import { useAuth } from '@/providers/auth-provider' export default function Home() { const [mounted, setMounted] = useState(false) - const { data: user, isLoading } = useUser() - const logout = useLogout() + const { user, isLoading, logout } = useAuth() useEffect(() => { setMounted(true) @@ -36,7 +35,7 @@ export default function Home() {
{user.avatarUrl && ( {user.name} @@ -45,14 +44,20 @@ export default function Home() {

{user.email}

- +
+ + + + +
) : (
diff --git a/src/components/messaging/ChatHeader.tsx b/src/components/messaging/ChatHeader.tsx new file mode 100644 index 0000000..777b478 --- /dev/null +++ b/src/components/messaging/ChatHeader.tsx @@ -0,0 +1,40 @@ +'use client' + +import { Conversation } from '@/types/messaging'; +import { User } from '@/types/auth'; +import { MoreVertical } from 'lucide-react'; +import Button from '@/components/common/Button'; + +interface ChatHeaderProps { + conversation: Conversation; + currentUser: User | null; +} + +export default function ChatHeader({ conversation, currentUser }: ChatHeaderProps) { + const otherParticipant = conversation.participants?.find(p => p.id !== currentUser?.id); + + return ( +
+
+
+ {otherParticipant?.avatarUrl ? ( + + ) : ( + + {(conversation.label || otherParticipant?.name || '?').substring(0, 2).toUpperCase()} + + )} +
+
+

+ {conversation.label || otherParticipant?.name || 'Conversation'} +

+

En ligne

+
+
+ +
+ ); +} diff --git a/src/components/messaging/ChatInput.tsx b/src/components/messaging/ChatInput.tsx new file mode 100644 index 0000000..8ede32a --- /dev/null +++ b/src/components/messaging/ChatInput.tsx @@ -0,0 +1,102 @@ +'use client' + +import { useState, useRef } from 'react'; +import { Send, Image as ImageIcon, Paperclip, X } from 'lucide-react'; +import Button from '@/components/common/Button'; +import Input from '@/components/common/Input'; + +interface ChatInputProps { + onSend: (data: { body?: string, image?: File }) => void; + disabled?: boolean; +} + +export default function ChatInput({ onSend, disabled }: ChatInputProps) { + const [content, setContent] = useState(''); + const [selectedImage, setSelectedImage] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const fileInputRef = useRef(null); + + const handleImageSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + setSelectedImage(file); + setPreviewUrl(URL.createObjectURL(file)); + } + }; + + const removeImage = () => { + setSelectedImage(null); + setPreviewUrl(null); + if (fileInputRef.current) fileInputRef.current.value = ''; + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if ((!content.trim() && !selectedImage) || disabled) return; + + onSend({ + body: content.trim() || undefined, + image: selectedImage || undefined + }); + + setContent(''); + removeImage(); + }; + + return ( +
+ {previewUrl && ( +
+ Preview + +
+ )} + +
+ + +
+ + +
+ + setContent(e.target.value)} + placeholder={selectedImage ? "Ajouter une légende..." : "Écrivez un message..."} + className="flex-1 bg-gray-50 dark:bg-neutral-900 border-0 focus:ring-0" + disabled={disabled} + /> + + +
+
+ ); +} diff --git a/src/components/messaging/ChatWindow.tsx b/src/components/messaging/ChatWindow.tsx new file mode 100644 index 0000000..e6f1297 --- /dev/null +++ b/src/components/messaging/ChatWindow.tsx @@ -0,0 +1,43 @@ +'use client' + +import { useMessages, useSendMessage } from '@/hooks/queries/messaging'; +import { useAuth } from '@/providers/auth-provider'; +import { Conversation } from '@/types/messaging'; +import ChatHeader from './ChatHeader'; +import MessageList from './MessageList'; +import ChatInput from './ChatInput'; + +interface ChatWindowProps { + conversation: Conversation; +} + +export default function ChatWindow({ conversation }: ChatWindowProps) { + const { data: messages, isLoading } = useMessages(conversation.id); + const sendMessage = useSendMessage(conversation.id); + const { user: currentUser } = useAuth(); + + const handleSend = (data: { body?: string, image?: File }) => { + sendMessage.mutate(data); + }; + + if (isLoading) return ( +
+

Chargement des messages...

+
+ ); + + const messageList = Array.isArray(messages) ? messages : []; + + return ( +
+ + + + + +
+ ); +} diff --git a/src/components/messaging/ConversationList.tsx b/src/components/messaging/ConversationList.tsx new file mode 100644 index 0000000..cdfed82 --- /dev/null +++ b/src/components/messaging/ConversationList.tsx @@ -0,0 +1,83 @@ +'use client' + +import { useConversations } from '@/hooks/queries/messaging'; +import { useUser } from '@/hooks/queries/auth'; +import { Conversation } from '@/types/messaging'; +import { Search } from 'lucide-react'; +import Input from '@/components/common/Input'; + +interface ConversationListProps { + onSelect: (conversation: Conversation) => void; + activeId?: string; +} + +export default function ConversationList({ onSelect, activeId }: ConversationListProps) { + const { data: conversations, isLoading } = useConversations(); + const { data: currentUser } = useUser(); + + if (isLoading) return
Chargement des conversations...
; + + // Fix: If conversations is not an array (though useQuery should handle it now with response.data) + const conversationList = Array.isArray(conversations) ? conversations : []; + + return ( +
+
+

Messages

+
+ + +
+
+ +
+ {conversationList.map((conv) => { + const otherParticipant = conv.participants?.find(p => p.id !== currentUser?.id); + const isActive = activeId === conv.id; + + return ( + + ); + })} +
+
+ ); +} diff --git a/src/components/messaging/MessageItem.tsx b/src/components/messaging/MessageItem.tsx new file mode 100644 index 0000000..5c40860 --- /dev/null +++ b/src/components/messaging/MessageItem.tsx @@ -0,0 +1,42 @@ +'use client' + +import { Message } from '@/types/messaging'; +import { User } from '@/types/auth'; + +interface MessageItemProps { + message: Message; + currentUser: User | null; +} + +export default function MessageItem({ message, currentUser }: MessageItemProps) { + const isMe = message.senderId === currentUser?.id; + + return ( +
+
+
+ {message.imageUrl && ( +
+ Shared image window.open(message.imageUrl, '_blank')} + /> +
+ )} + + {message.body &&

{message.body}

} + +

+ {new Date(message.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} +

+
+
+
+ ); +} diff --git a/src/components/messaging/MessageList.tsx b/src/components/messaging/MessageList.tsx new file mode 100644 index 0000000..3aec0b5 --- /dev/null +++ b/src/components/messaging/MessageList.tsx @@ -0,0 +1,36 @@ +'use client' + +import { useRef, useEffect } from 'react'; +import { Message } from '@/types/messaging'; +import { User } from '@/types/auth'; +import MessageItem from './MessageItem'; + +interface MessageListProps { + messages: Message[]; + currentUser: User | null; +} + +export default function MessageList({ messages, currentUser }: MessageListProps) { + const scrollRef = useRef(null); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [messages]); + + return ( +
+ {messages.map((msg, index) => ( + + ))} +
+ ); +} diff --git a/src/hooks/queries/auth.ts b/src/hooks/queries/auth.ts index 63f5988..efa2131 100644 --- a/src/hooks/queries/auth.ts +++ b/src/hooks/queries/auth.ts @@ -27,7 +27,9 @@ export function useLogin() { body: JSON.stringify(credentials), }), onSuccess: (data) => { - Cookies.set('token', data.token, { expires: 30, sameSite: 'lax', secure: process.env.NODE_ENV === 'production' }) + console.log('Token received and saved:', data.token.substring(0, 10) + '...'); + Cookies.set('token', data.token, { expires: 30, sameSite: 'lax' }) + localStorage.setItem('token', data.token) queryClient.setQueryData(authKeys.user(), data.user) }, }) @@ -54,7 +56,9 @@ export function useRegister() { }) }, onSuccess: (data) => { - Cookies.set('token', data.token, { expires: 30, sameSite: 'lax', secure: process.env.NODE_ENV === 'production' }) + console.log('Token received and saved:', data.token.substring(0, 10) + '...'); + Cookies.set('token', data.token, { expires: 30, sameSite: 'lax' }) + localStorage.setItem('token', data.token) queryClient.setQueryData(authKeys.user(), data.user) }, }) diff --git a/src/hooks/queries/messaging.ts b/src/hooks/queries/messaging.ts new file mode 100644 index 0000000..48a7410 --- /dev/null +++ b/src/hooks/queries/messaging.ts @@ -0,0 +1,99 @@ +'use client' + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { apiFetch } from '@/lib/api-client'; +import { Conversation, Message, SendMessageData, ApiResponse } from '@/types/messaging'; +import { useEffect } from 'react'; +import echo from '@/lib/echo'; + +export const messagingKeys = { + all: ['messaging'] as const, + conversations: () => [...messagingKeys.all, 'conversations'] as const, + conversation: (id: string) => [...messagingKeys.all, 'conversation', id] as const, + messages: (conversationId: string) => [...messagingKeys.conversation(conversationId), 'messages'] as const, +}; + +export function useConversations() { + return useQuery({ + queryKey: messagingKeys.conversations(), + queryFn: async () => { + const response = await apiFetch('/conversations'); + return (response as any).data || response; + }, + }); +} + +export function useMessages(conversationId: string) { + const queryClient = useQueryClient(); + + const query = useQuery({ + queryKey: messagingKeys.messages(conversationId), + queryFn: async () => { + const response = await apiFetch(`/conversations/${conversationId}/messages`); + return (response as any).data || response; + }, + enabled: !!conversationId, + }); + + useEffect(() => { + if (!conversationId || !echo) return; + + const channel = echo.private(`conversation.${conversationId}`) + .listen('MessageSent', (e: any) => { + const newMessage = e.message; + if (!newMessage) return; + + queryClient.setQueryData(messagingKeys.messages(conversationId), (old) => { + const currentMessages = old || []; + if (currentMessages.find(m => (m.id === newMessage.id) || (m.createdAt === newMessage.createdAt && m.senderId === newMessage.senderId))) { + return currentMessages; + } + return [...currentMessages, newMessage]; + }); + + queryClient.setQueryData(messagingKeys.conversations(), (old) => { + if (!old) return old; + return old.map(conv => { + if (conv.id === conversationId) { + return { + ...conv, + lastMessage: newMessage, + updatedAt: newMessage.createdAt + }; + } + return conv; + }); + }); + }); + + return () => { + echo.leave(`conversation.${conversationId}`); + }; + }, [conversationId, queryClient]); + + return query; +} + +export function useSendMessage(conversationId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data) => { + const formData = new FormData(); + if (data.body) formData.append('body', data.body); + if (data.image) formData.append('image', data.image); + + const response = await apiFetch(`/conversations/${conversationId}/messages`, { + method: 'POST', + body: formData, + }); + return (response as any).data || response; + }, + onSuccess: (newMessage) => { + queryClient.setQueryData(messagingKeys.messages(conversationId), (old) => { + const currentMessages = old || []; + return [...currentMessages, newMessage]; + }); + }, + }); +} diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index dfc6eac..65d679b 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -3,37 +3,43 @@ import Cookies from 'js-cookie'; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api'; export async function apiFetch(endpoint: string, options: RequestInit = {}) { - const token = typeof window !== 'undefined' ? Cookies.get('token') : null; - - const headers: Record = { - 'Accept': 'application/json', - ...(token ? { 'Authorization': `Bearer ${token}` } : {}), - }; - - - // Only set Content-Type if it's NOT FormData - if (!(options.body instanceof FormData)) { - headers['Content-Type'] = 'application/json'; + let token = typeof window !== 'undefined' ? Cookies.get('token') : null; + + if (!token && typeof window !== 'undefined') { + token = localStorage.getItem('token'); } - // Convert HeadersInit to a plain object - const optionsHeaders = Object.fromEntries(new Headers(options.headers).entries()); - - // Override headers from options if provided - const finalHeaders: Record = { ...headers, ...optionsHeaders }; - - // Delete Content-Type if set to 'undefined' to let the browser handle it (for FormData) - if (finalHeaders['Content-Type'] === 'undefined') { - delete finalHeaders['Content-Type']; + // Normalisation des headers en minuscules pour éviter les doublons + const headers = new Headers(options.headers); + + headers.set('Accept', 'application/json'); + + if (token) { + headers.set('Authorization', `Bearer ${token}`); } + // Si c'est un FormData, on NE DOIT PAS mettre de Content-Type + // Sinon, on met application/json par défaut si rien n'est spécifié + if (options.body instanceof FormData) { + headers.delete('Content-Type'); + } else if (!headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + + console.log(`[apiFetch] ${options.method || 'GET'} ${endpoint}`, { + hasToken: !!token, + isFormData: options.body instanceof FormData, + contentType: headers.get('Content-Type') + }); + const response = await fetch(`${API_URL}${endpoint}`, { ...options, - headers: finalHeaders, + headers, }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); + console.error(`[apiFetch] Error ${response.status} on ${endpoint}:`, errorData); throw new Error(errorData.message || 'An error occurred'); } diff --git a/src/lib/echo.ts b/src/lib/echo.ts new file mode 100644 index 0000000..2754a1d --- /dev/null +++ b/src/lib/echo.ts @@ -0,0 +1,30 @@ +import Echo from 'laravel-echo'; +import Pusher from 'pusher-js'; +import Cookies from 'js-cookie'; + +if (typeof window !== 'undefined') { + (window as any).Pusher = Pusher; + (window as any).Pusher.logToConsole = true; +} + +const echo = typeof window !== 'undefined' + ? new Echo({ + broadcaster: 'reverb', + key: process.env.NEXT_PUBLIC_REVERB_APP_KEY, + wsHost: 'localhost', + wsPort: Number(process.env.NEXT_PUBLIC_REVERB_PORT), + wssPort: Number(process.env.NEXT_PUBLIC_REVERB_PORT), + forceTLS: false, + enabledTransports: ['ws'], + authEndpoint: `${process.env.NEXT_PUBLIC_API_URL}/broadcasting/auth`, + auth: { + headers: { + // Get latest token from either source + Authorization: `Bearer ${Cookies.get('token') || localStorage.getItem('token')}`, + Accept: 'application/json', + }, + }, + }) + : null; + +export default echo; diff --git a/src/providers/auth-provider.tsx b/src/providers/auth-provider.tsx new file mode 100644 index 0000000..3b07aba --- /dev/null +++ b/src/providers/auth-provider.tsx @@ -0,0 +1,70 @@ +'use client' + +import React, { createContext, useContext, useEffect, useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import Cookies from 'js-cookie' +import { User } from '@/types/auth' +import { apiFetch } from '@/lib/api-client' +import { authKeys } from '@/hooks/queries/auth' + +interface AuthContextType { + user: User | null + isLoading: boolean + isAuthenticated: boolean + logout: () => void +} + +const AuthContext = createContext(undefined) + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const queryClient = useQueryClient() + const [token, setToken] = useState(null) + + useEffect(() => { + // Read from both sources + const cookieToken = Cookies.get('token') + const localToken = localStorage.getItem('token') + const finalToken = cookieToken || localToken + + setToken(finalToken) + console.log('[AuthProvider] Initial token check:', finalToken ? 'Found' : 'None') + }, []) + + const { data: user, isLoading } = useQuery({ + queryKey: authKeys.user(), + queryFn: () => apiFetch('/auth/me'), + enabled: !!token, + retry: false, + staleTime: Infinity, + }) + + const logout = () => { + Cookies.remove('token') + localStorage.removeItem('token') + setToken(null) + queryClient.setQueryData(authKeys.user(), null) + queryClient.clear() + window.location.href = '/login' + } + + return ( + + {children} + + ) +} + +export const useAuth = () => { + const context = useContext(AuthContext) + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider') + } + return context +} diff --git a/src/providers/index.tsx b/src/providers/index.tsx index 8d7373c..3d69bd5 100644 --- a/src/providers/index.tsx +++ b/src/providers/index.tsx @@ -1,12 +1,14 @@ 'use client' import { QueryProvider } from './query-provider' +import { AuthProvider } from './auth-provider' export default function Providers({ children }: { children: React.ReactNode }) { return ( - {/* Ajoutez d'autres providers ici si besoin (ThemeProvider, etc.) */} - {children} + + {children} + ) } diff --git a/src/types/messaging.ts b/src/types/messaging.ts new file mode 100644 index 0000000..a84bae3 --- /dev/null +++ b/src/types/messaging.ts @@ -0,0 +1,32 @@ +import { User } from './auth'; + +export interface Conversation { + id: string; + label?: string; + isGroup: boolean; + participants?: User[]; + lastMessage?: Message; + createdAt: string; + updatedAt: string; + unread_count?: number; +} + +export interface Message { + id: string; + senderId: string; + senderName: string; + senderAvatarUrl?: string; + body?: string; + imageUrl?: string; + readAt?: string; + createdAt: string; +} + +export interface ApiResponse { + data: T; +} + +export interface SendMessageData { + body?: string; + image?: File; +}