feat: messages images and components

This commit is contained in:
Leon Morival 2026-04-12 14:56:43 +02:00
parent 028487d708
commit 860dadf82c
17 changed files with 708 additions and 37 deletions

View File

@ -7,7 +7,10 @@
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.99.0", "@tanstack/react-query": "^5.99.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"laravel-echo": "^2.3.4",
"lucide-react": "^1.8.0",
"next": "16.2.3", "next": "16.2.3",
"pusher-js": "^8.5.0",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
}, },
@ -187,6 +190,8 @@
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], "@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=="], "@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=="], "@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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],

View File

@ -11,7 +11,10 @@
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.99.0", "@tanstack/react-query": "^5.99.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"laravel-echo": "^2.3.4",
"lucide-react": "^1.8.0",
"next": "16.2.3", "next": "16.2.3",
"pusher-js": "^8.5.0",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4"
}, },

49
src/app/messages/page.tsx Normal file
View File

@ -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<Conversation | null>(null);
return (
<div className="flex h-screen bg-gray-50 dark:bg-neutral-950 overflow-hidden">
{/* Sidebar / List */}
<div className={`w-full md:w-80 lg:w-96 h-full flex-shrink-0 border-r border-gray-100 dark:border-neutral-800 ${selectedConversation ? 'hidden md:flex' : 'flex'}`}>
<ConversationList
onSelect={setSelectedConversation}
activeId={selectedConversation?.id}
/>
</div>
{/* Main Chat Area */}
<div className={`flex-1 h-full flex flex-col ${!selectedConversation ? 'hidden md:flex' : 'flex'}`}>
{selectedConversation ? (
<div className="flex-1 h-full relative overflow-hidden">
{/* Back button for mobile */}
<button
onClick={() => setSelectedConversation(null)}
className="md:hidden absolute top-4 left-4 z-50 p-2 bg-white dark:bg-neutral-900 rounded-full shadow-md border border-gray-100 dark:border-neutral-800"
>
</button>
<ChatWindow conversation={selectedConversation} />
</div>
) : (
<div className="flex-1 h-full flex flex-col items-center justify-center text-center p-8">
<div className="w-20 h-20 bg-gray-100 dark:bg-neutral-900 rounded-full flex items-center justify-center mb-6">
<MessageSquare className="w-10 h-10 text-gray-300 dark:text-neutral-700" />
</div>
<h2 className="text-2xl font-bold mb-2">Vos messages</h2>
<p className="text-gray-500 max-w-xs">
Sélectionnez une conversation pour commencer à discuter ou créez-en une nouvelle.
</p>
</div>
)}
</div>
</div>
);
}

View File

@ -1,15 +1,14 @@
'use client' 'use client'
import { useUser, useLogout } from '@/hooks/queries/auth'
import Link from 'next/link' import Link from 'next/link'
import Button from '@/components/common/Button' import Button from '@/components/common/Button'
import Card from '@/components/common/Card' import Card from '@/components/common/Card'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useAuth } from '@/providers/auth-provider'
export default function Home() { export default function Home() {
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
const { data: user, isLoading } = useUser() const { user, isLoading, logout } = useAuth()
const logout = useLogout()
useEffect(() => { useEffect(() => {
setMounted(true) setMounted(true)
@ -36,7 +35,7 @@ export default function Home() {
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
{user.avatarUrl && ( {user.avatarUrl && (
<img <img
src={user?.avatarUrl} src={user.avatarUrl.startsWith('http') ? user.avatarUrl : `http://localhost:8003/storage/${user.avatarUrl}`}
alt={user.name} alt={user.name}
className="w-24 h-24 rounded-full object-cover mb-4 border-2 border-black dark:border-white" className="w-24 h-24 rounded-full object-cover mb-4 border-2 border-black dark:border-white"
/> />
@ -45,14 +44,20 @@ export default function Home() {
<p className="text-gray-600 dark:text-neutral-400">{user.email}</p> <p className="text-gray-600 dark:text-neutral-400">{user.email}</p>
</div> </div>
<Button <div className="flex justify-center gap-4">
variant="secondary" <Link href="/messages">
onClick={() => logout.mutate()} <Button variant="primary">
disabled={logout.isPending} Accéder aux messages
className="text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20" </Button>
> </Link>
{logout.isPending ? 'Déconnexion...' : 'Se déconnecter'} <Button
</Button> variant="secondary"
onClick={() => logout()}
className="text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
>
Se déconnecter
</Button>
</div>
</div> </div>
) : ( ) : (
<div className="space-y-6"> <div className="space-y-6">

View File

@ -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 (
<div className="p-4 border-b border-gray-100 dark:border-neutral-800 flex justify-between items-center shrink-0 bg-white dark:bg-black">
<div className="flex items-center">
<div className="w-10 h-10 rounded-full bg-gray-100 dark:bg-neutral-900 flex items-center justify-center overflow-hidden border border-gray-100 dark:border-neutral-800">
{otherParticipant?.avatarUrl ? (
<img src={otherParticipant.avatarUrl} alt="" className="w-full h-full object-cover" />
) : (
<span className="text-xs font-bold text-gray-400">
{(conversation.label || otherParticipant?.name || '?').substring(0, 2).toUpperCase()}
</span>
)}
</div>
<div className="ml-3">
<h3 className="font-semibold text-sm leading-none">
{conversation.label || otherParticipant?.name || 'Conversation'}
</h3>
<p className="text-[10px] text-green-500 mt-1 uppercase font-bold tracking-wider">En ligne</p>
</div>
</div>
<Button variant="secondary" className="p-2 h-auto rounded-full bg-transparent border-0 hover:bg-gray-100 dark:hover:bg-neutral-900">
<MoreVertical className="w-5 h-5 text-gray-400" />
</Button>
</div>
);
}

View File

@ -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<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className="p-4 border-t border-gray-100 dark:border-neutral-800 shrink-0 bg-white dark:bg-black">
{previewUrl && (
<div className="mb-4 relative inline-block">
<img src={previewUrl} alt="Preview" className="h-24 w-auto rounded-lg border border-gray-200 dark:border-neutral-800" />
<button
onClick={removeImage}
className="absolute -top-2 -right-2 p-1 bg-red-500 text-white rounded-full shadow-lg hover:bg-red-600 transition-colors"
>
<X className="w-3 h-3" />
</button>
</div>
)}
<form onSubmit={handleSubmit} className="flex gap-2 items-center">
<input
type="file"
accept="image/*"
className="hidden"
ref={fileInputRef}
onChange={handleImageSelect}
/>
<div className="flex gap-1">
<Button
variant="secondary"
type="button"
className={`p-2 h-auto bg-transparent border-0 hover:bg-gray-100 dark:hover:bg-neutral-900 ${selectedImage ? 'text-indigo-500' : ''}`}
onClick={() => fileInputRef.current?.click()}
>
<ImageIcon className="w-5 h-5 text-gray-400" />
</Button>
<Button variant="secondary" type="button" className="p-2 h-auto bg-transparent border-0 hover:bg-gray-100 dark:hover:bg-neutral-900">
<Paperclip className="w-5 h-5 text-gray-400" />
</Button>
</div>
<Input
key="chat-input"
value={content}
onChange={(e) => 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}
/>
<Button
disabled={(!content.trim() && !selectedImage) || disabled}
type="submit"
className="p-2 h-10 w-10 flex items-center justify-center rounded-full"
>
<Send className="w-4 h-4" />
</Button>
</form>
</div>
);
}

View File

@ -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 (
<div className="flex-1 flex items-center justify-center bg-white dark:bg-black">
<p className="text-gray-500 animate-pulse">Chargement des messages...</p>
</div>
);
const messageList = Array.isArray(messages) ? messages : [];
return (
<div className="flex flex-col h-full max-h-full bg-white dark:bg-black overflow-hidden">
<ChatHeader conversation={conversation} currentUser={currentUser} />
<MessageList messages={messageList} currentUser={currentUser} />
<ChatInput
onSend={handleSend}
disabled={sendMessage.isPending}
/>
</div>
);
}

View File

@ -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 <div className="p-4">Chargement des conversations...</div>;
// Fix: If conversations is not an array (though useQuery should handle it now with response.data)
const conversationList = Array.isArray(conversations) ? conversations : [];
return (
<div className="flex flex-col h-full bg-white dark:bg-black border-r border-gray-100 dark:border-neutral-800">
<div className="p-4 border-b border-gray-100 dark:border-neutral-800">
<h2 className="text-xl font-bold mb-4">Messages</h2>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
placeholder="Rechercher..."
className="pl-10"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{conversationList.map((conv) => {
const otherParticipant = conv.participants?.find(p => p.id !== currentUser?.id);
const isActive = activeId === conv.id;
return (
<button
key={conv.id}
onClick={() => onSelect(conv)}
className={`w-full flex items-center p-4 hover:bg-gray-50 dark:hover:bg-neutral-900 transition-colors border-b border-gray-50 dark:border-neutral-900 last:border-0 ${isActive ? 'bg-gray-50 dark:bg-neutral-900' : ''}`}
>
<div className="relative">
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-neutral-800 flex items-center justify-center overflow-hidden border border-gray-100 dark:border-neutral-700">
{otherParticipant?.avatarUrl ? (
<img src={otherParticipant.avatarUrl} alt="" className="w-full h-full object-cover" />
) : (
<span className="text-sm font-bold text-gray-500">
{(conv.label || otherParticipant?.name || '?').substring(0, 2).toUpperCase()}
</span>
)}
</div>
{conv.unread_count && conv.unread_count > 0 ? (
<span className="absolute -top-1 -right-1 w-5 h-5 bg-black dark:bg-white text-white dark:text-black text-[10px] font-bold rounded-full flex items-center justify-center">
{conv.unread_count}
</span>
) : null}
</div>
<div className="ml-4 flex-1 text-left">
<div className="flex justify-between items-baseline">
<h3 className="font-semibold text-sm truncate max-w-[140px]">
{conv.label || otherParticipant?.name || 'Sans nom'}
</h3>
<span className="text-[10px] text-gray-400 uppercase">
{conv.lastMessage?.createdAt ? new Date(conv.lastMessage.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''}
</span>
</div>
<p className="text-xs text-gray-500 dark:text-neutral-400 truncate max-w-[200px]">
{conv.lastMessage?.body || 'Aucun message'}
</p>
</div>
</button>
);
})}
</div>
</div>
);
}

View File

@ -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 (
<div className={`flex ${isMe ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[70%] space-y-2 ${isMe ? 'items-end' : 'items-start'}`}>
<div className={`p-3 rounded-2xl text-sm shadow-sm ${
isMe
? 'bg-black text-white rounded-br-none'
: 'bg-gray-100 dark:bg-neutral-900 text-gray-900 dark:text-white rounded-bl-none border border-gray-200 dark:border-neutral-800'
}`}>
{message.imageUrl && (
<div className="mb-2 rounded-lg overflow-hidden border border-white/10">
<img
src={message.imageUrl}
alt="Shared image"
className="max-w-full h-auto object-cover hover:scale-105 transition-transform cursor-pointer"
onClick={() => window.open(message.imageUrl, '_blank')}
/>
</div>
)}
{message.body && <p className="break-words">{message.body}</p>}
<p className={`text-[10px] mt-1 ${isMe ? 'text-gray-400 text-right' : 'text-gray-500 text-left'}`}>
{new Date(message.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
</div>
</div>
);
}

View File

@ -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<HTMLDivElement>(null);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
return (
<div
ref={scrollRef}
className="flex-1 overflow-y-auto p-4 space-y-4 scroll-smooth"
>
{messages.map((msg, index) => (
<MessageItem
key={msg.id || index}
message={msg}
currentUser={currentUser}
/>
))}
</div>
);
}

View File

@ -27,7 +27,9 @@ export function useLogin() {
body: JSON.stringify(credentials), body: JSON.stringify(credentials),
}), }),
onSuccess: (data) => { 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) queryClient.setQueryData(authKeys.user(), data.user)
}, },
}) })
@ -54,7 +56,9 @@ export function useRegister() {
}) })
}, },
onSuccess: (data) => { 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) queryClient.setQueryData(authKeys.user(), data.user)
}, },
}) })

View File

@ -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<Conversation[]>({
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<Message[]>({
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<Message[]>(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<Conversation[]>(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<Message, Error, SendMessageData>({
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<Message[]>(messagingKeys.messages(conversationId), (old) => {
const currentMessages = old || [];
return [...currentMessages, newMessage];
});
},
});
}

View File

@ -3,37 +3,43 @@ import Cookies from 'js-cookie';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api'; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api';
export async function apiFetch(endpoint: string, options: RequestInit = {}) { export async function apiFetch(endpoint: string, options: RequestInit = {}) {
const token = typeof window !== 'undefined' ? Cookies.get('token') : null; let token = typeof window !== 'undefined' ? Cookies.get('token') : null;
const headers: Record<string, string> = { if (!token && typeof window !== 'undefined') {
'Accept': 'application/json', token = localStorage.getItem('token');
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
};
// Only set Content-Type if it's NOT FormData
if (!(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
} }
// Convert HeadersInit to a plain object // Normalisation des headers en minuscules pour éviter les doublons
const optionsHeaders = Object.fromEntries(new Headers(options.headers).entries()); const headers = new Headers(options.headers);
// Override headers from options if provided headers.set('Accept', 'application/json');
const finalHeaders: Record<string, string> = { ...headers, ...optionsHeaders };
if (token) {
// Delete Content-Type if set to 'undefined' to let the browser handle it (for FormData) headers.set('Authorization', `Bearer ${token}`);
if (finalHeaders['Content-Type'] === 'undefined') {
delete finalHeaders['Content-Type'];
} }
// 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}`, { const response = await fetch(`${API_URL}${endpoint}`, {
...options, ...options,
headers: finalHeaders, headers,
}); });
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
console.error(`[apiFetch] Error ${response.status} on ${endpoint}:`, errorData);
throw new Error(errorData.message || 'An error occurred'); throw new Error(errorData.message || 'An error occurred');
} }

30
src/lib/echo.ts Normal file
View File

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

View File

@ -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<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const queryClient = useQueryClient()
const [token, setToken] = useState<string | null>(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<User | null>({
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 (
<AuthContext.Provider
value={{
user: user ?? null,
isLoading,
isAuthenticated: !!user,
logout,
}}
>
{children}
</AuthContext.Provider>
)
}
export const useAuth = () => {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}

View File

@ -1,12 +1,14 @@
'use client' 'use client'
import { QueryProvider } from './query-provider' import { QueryProvider } from './query-provider'
import { AuthProvider } from './auth-provider'
export default function Providers({ children }: { children: React.ReactNode }) { export default function Providers({ children }: { children: React.ReactNode }) {
return ( return (
<QueryProvider> <QueryProvider>
{/* Ajoutez d'autres providers ici si besoin (ThemeProvider, etc.) */} <AuthProvider>
{children} {children}
</AuthProvider>
</QueryProvider> </QueryProvider>
) )
} }

32
src/types/messaging.ts Normal file
View File

@ -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<T> {
data: T;
}
export interface SendMessageData {
body?: string;
image?: File;
}