From eaeb3627d5a1ee69f7acfcbc1938569551cfce85 Mon Sep 17 00:00:00 2001 From: wahid Date: Sun, 19 Jul 2026 00:30:16 +0530 Subject: [PATCH] fit:multer-cloudinary for file upload --- backend/package-lock.json | 215 +++++++++++++++++- backend/package.json | 4 + .../migration.sql | 10 + backend/prisma/schema.prisma | 3 +- backend/src/config/cloudinary.config.ts | 33 +++ backend/src/routes/index.ts | 3 + backend/src/routes/upload.ts | 29 +++ backend/src/socket.ts | 2 +- frontend/Types.ts | 2 + frontend/src/components/chat/Chats.tsx | 150 +++++++++++- frontend/src/components/chat/FileMessage.tsx | 74 ++++++ 11 files changed, 510 insertions(+), 15 deletions(-) create mode 100644 backend/prisma/migrations/20260718182824_add_file_url_file_type_to_chats/migration.sql create mode 100644 backend/src/config/cloudinary.config.ts create mode 100644 backend/src/routes/upload.ts create mode 100644 frontend/src/components/chat/FileMessage.tsx diff --git a/backend/package-lock.json b/backend/package-lock.json index 51baa91..6959dcb 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,12 +12,15 @@ "@prisma/client": "^6.19.3", "@socket.io/admin-ui": "^0.5.1", "@socket.io/redis-streams-adapter": "^0.3.1", + "cloudinary": "^1.41.3", "cors": "^2.8.5", "dotenv": "^17.4.2", "express": "^5.1.0", "ioredis": "^5.11.1", "jsonwebtoken": "^9.0.3", "kafkajs": "^2.2.4", + "multer": "^2.2.0", + "multer-storage-cloudinary": "^4.0.0", "prisma": "^6.19.3", "socket.io": "^4.8.3" }, @@ -25,6 +28,7 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.3", "@types/jsonwebtoken": "^9.0.10", + "@types/multer": "^2.2.0", "@types/node": "^24.13.2", "concurrently": "^9.2.0", "nodemon": "^3.1.14", @@ -704,6 +708,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "24.13.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", @@ -804,6 +818,12 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -891,6 +911,23 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1069,6 +1106,30 @@ "node": ">=12" } }, + "node_modules/cloudinary": { + "version": "1.41.3", + "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-1.41.3.tgz", + "integrity": "sha512-4o84y+E7dbif3lMns+p3UW6w6hLHEifbX/7zBJvaih1E9QNMZITENQ14GPYJC4JmhygYXsuuBb9bRA3xWEoOfg==", + "license": "MIT", + "dependencies": { + "cloudinary-core": "^2.13.0", + "core-js": "^3.30.1", + "lodash": "^4.17.21", + "q": "^1.5.1" + }, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/cloudinary-core": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/cloudinary-core/-/cloudinary-core-2.14.1.tgz", + "integrity": "sha512-57rgZSQD2cJsz1rga6M7jIDQuEAzkwvq63vTvs3/I8rNpGLyHMoKoIvBkNS0Guv5RZ9KDReJhI2LmElk4D9U1g==", + "license": "MIT", + "peerDependencies": { + "lodash": ">=4.0" + } + }, "node_modules/cluster-key-slot": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", @@ -1096,6 +1157,21 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/concurrently": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.0.tgz", @@ -1172,6 +1248,17 @@ "node": ">=6.6.0" } }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -1931,8 +2018,7 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "node_modules/lodash.includes": { "version": "4.3.0", @@ -2043,6 +2129,77 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer-storage-cloudinary": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/multer-storage-cloudinary/-/multer-storage-cloudinary-4.0.0.tgz", + "integrity": "sha512-25lm9R6o5dWrHLqLvygNX+kBOxprzpmZdnVKH4+r68WcfCt8XV6xfQaMuAg+kUE5Xmr8mJNA4gE0AcBj9FJyWA==", + "license": "MIT", + "peerDependencies": { + "cloudinary": "^1.21.0" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -2293,6 +2450,17 @@ ], "license": "MIT" }, + "node_modules/q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", + "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", + "license": "MIT", + "engines": { + "node": ">=0.6.0", + "teleport": ">=0.2.0" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -2339,6 +2507,20 @@ "destr": "^2.0.3" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -2679,6 +2861,23 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -2805,6 +3004,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", @@ -2838,6 +3043,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/backend/package.json b/backend/package.json index d77607e..5f9659b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,6 +17,7 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.3", "@types/jsonwebtoken": "^9.0.10", + "@types/multer": "^2.2.0", "@types/node": "^24.13.2", "concurrently": "^9.2.0", "nodemon": "^3.1.14", @@ -27,12 +28,15 @@ "@prisma/client": "^6.19.3", "@socket.io/admin-ui": "^0.5.1", "@socket.io/redis-streams-adapter": "^0.3.1", + "cloudinary": "^1.41.3", "cors": "^2.8.5", "dotenv": "^17.4.2", "express": "^5.1.0", "ioredis": "^5.11.1", "jsonwebtoken": "^9.0.3", "kafkajs": "^2.2.4", + "multer": "^2.2.0", + "multer-storage-cloudinary": "^4.0.0", "prisma": "^6.19.3", "socket.io": "^4.8.3" } diff --git a/backend/prisma/migrations/20260718182824_add_file_url_file_type_to_chats/migration.sql b/backend/prisma/migrations/20260718182824_add_file_url_file_type_to_chats/migration.sql new file mode 100644 index 0000000..66d6d57 --- /dev/null +++ b/backend/prisma/migrations/20260718182824_add_file_url_file_type_to_chats/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - You are about to drop the column `file` on the `chats` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "chats" DROP COLUMN "file", +ADD COLUMN "fileType" TEXT, +ADD COLUMN "fileUrl" TEXT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 13380c4..14c3f4f 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -51,7 +51,8 @@ model Chats { group_id String @db.Uuid message String? name String - file String? + fileUrl String? + fileType String? created_at DateTime @default(now()) @@index([created_at]) diff --git a/backend/src/config/cloudinary.config.ts b/backend/src/config/cloudinary.config.ts new file mode 100644 index 0000000..c3d67c3 --- /dev/null +++ b/backend/src/config/cloudinary.config.ts @@ -0,0 +1,33 @@ +import { v2 as cloudinary } from "cloudinary"; +import { CloudinaryStorage } from "multer-storage-cloudinary"; +import multer from "multer"; + +cloudinary.config({ + cloud_name:process.env.CLOUDINARY_CLOUD_NAME, + api_key: process.env.CLOUDINARY_API_KEY, + api_secret:process.env.CLOUDINARY_API_SECRET, +}) + +// multer store files direct to cloudinary +const storage=new CloudinaryStorage({ + cloudinary, + params: async(req, file)=>{ + const isImage = file.mimetype.startsWith("image/"); + const isVideo = file.mimetype.startsWith("video/"); + + return { + folder:"relay-chat", + resource_type: isImage ? "image" : isVideo ? "video" : "raw", + allowed_formats:["jpg","jpeg", "png", "gif", "webp", "pdf", "mp4", "doc", "docx"], + // Only apply image transformations to actual images + ...(isImage && { transformation:[{width:1200, crop:"limit"}] }), + }; + }, +}) + +export const upload= multer({ + storage, + limits:{fileSize: 10*1024*1024}, //10mb max +}) + + diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index 9ca2363..9f8064c 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -4,6 +4,7 @@ import authMiddleware from "../middlewares/AuthMiddlewares.js"; import ChatGroupController from "../controllers/ChatGroupController.js"; import ChatGroupUserController from "../controllers/ChatGroupUserController.js"; import ChatsController from "../controllers/ChatsController.js"; +import uploadRoutes from "./upload.js" const router=Router(); // auth routes @@ -25,4 +26,6 @@ router.post('/chat-group-users', ChatGroupUserController.store); //chats messages router.get('/chats/:groupId', ChatsController.index); +//upload route +router.use("/upload", uploadRoutes) export default router; \ No newline at end of file diff --git a/backend/src/routes/upload.ts b/backend/src/routes/upload.ts new file mode 100644 index 0000000..da98d31 --- /dev/null +++ b/backend/src/routes/upload.ts @@ -0,0 +1,29 @@ +import { type Response, type Request, Router} from "express"; + +import { upload } from "../config/cloudinary.config.js"; + + +const router=Router(); + +//post api/upload +router.post("/", upload.single("file"), (req:Request, res:Response)=>{ + try { + if(!req.file){ + return res.status(400).json({message:"no file uploaded"}); + } + + const file= req.file as Express.Multer.File & { + path:string; // cloudinary url + mimetype:string; + } + + return res.status(200).json({ + url:file.path, + type:file.mimetype, + }) + } catch (error) { + return res.status(500).json({message:"upload failed", error}); + } +}) + +export default router; \ No newline at end of file diff --git a/backend/src/socket.ts b/backend/src/socket.ts index 90dc14f..de4a5fa 100644 --- a/backend/src/socket.ts +++ b/backend/src/socket.ts @@ -35,7 +35,7 @@ export function setupSocket(io: Server) { if (socket.room) { - await produceMessage(process.env.KAFKA_TOPIC, data) + await produceMessage(process.env.KAFKA_TOPIC!, data) io.to(socket.room).emit("message", data); } }); diff --git a/frontend/Types.ts b/frontend/Types.ts index 5fbbb7f..b16a286 100644 --- a/frontend/Types.ts +++ b/frontend/Types.ts @@ -21,4 +21,6 @@ type MessageType={ group_id:string name:string created_at:string + fileUrl?: string; + fileType?: string; // image/png, application/pdf } \ No newline at end of file diff --git a/frontend/src/components/chat/Chats.tsx b/frontend/src/components/chat/Chats.tsx index 1c4f26f..4a334ed 100644 --- a/frontend/src/components/chat/Chats.tsx +++ b/frontend/src/components/chat/Chats.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { getSocket } from "@/lib/socket.config"; - +import FileMessage from "./FileMessage"; import { v4 as uuidv4 } from "uuid"; export default function Chats({ group, @@ -15,6 +15,31 @@ export default function Chats({ const [messages, setMessages] = useState>(oldMessages); const messagesEndRef = useRef(null); + + // for file upload + const[selectedFile, setSelectedFile]= useState(null); + const [previewUrl, setPreviewUrl]= useState(null); + const [uploading, setUploading]= useState(false); + const fileInputRef= useRef (null); + + const handleFileChange= (e:React.ChangeEvent)=>{ + const file= e.target.files?.[0]; + if (!file) return; + + setSelectedFile(file); + + //preview + if(file.type.startsWith("image/")){ + const reader= new FileReader(); + reader.onload=()=> setPreviewUrl(reader.result as string); + reader.readAsDataURL(file); + }else{ + setPreviewUrl(null); // non-image just show the filename + } + } + + + const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }; @@ -38,15 +63,58 @@ export default function Chats({ socket.off("message"); }; }, [socket]); - const handleSubmit = (event: React.FormEvent) => { + + //called when send clicked + const handleSubmit= async(event: React.FormEvent)=>{ event.preventDefault(); + if(!message && !selectedFile) return; + + let fileUrl:string |undefined; + let fileType: string|undefined; + + //if file selected, upload it first + if(selectedFile){ + setUploading(true); + try { + const formData=new FormData(); + formData.append("file", selectedFile); + + const res= await fetch(`${process.env.NEXT_PUBLIC_BACKEND_URL}/api/upload`, { + method:"POST", + body:formData, + }); + + if(!res.ok){ + throw new Error(`Upload failed with status ${res.status}`); + } + + const data= await res.json(); + fileUrl= data.url; + fileType=data.type; + } catch (error) { + console.error("File upload error:", error); + alert("Failed to upload file. Please try again."); + setUploading(false); + return; + } + setUploading(false); + setSelectedFile(null); + setPreviewUrl(null); + // Reset file input so the same file can be re-selected + if(fileInputRef.current) fileInputRef.current.value = ""; + } + + + const payload: MessageType = { id: uuidv4(), message: message, name: chatUser?.name ?? "Unknown", created_at: new Date().toISOString(), group_id: group.id, + fileUrl, + fileType, }; socket.emit("message", payload); setMessage(""); @@ -67,19 +135,79 @@ export default function Chats({ : "bg-linear-to-r from-gray-200 to-gray-300 text-black self-start" }`} > - {message.message} + {/* Step 9 — render text and/or file */} + <> + {message.message &&

{message.message}

} + {message.fileUrl && ( + + )} + ))} -
- setMessage(e.target.value)} - /> + {/* Step 10 — file button + preview + text input + send */} + + {/* File preview strip */} + {selectedFile && ( +
+ {previewUrl ? ( + preview + ) : ( + 📎 + )} + {selectedFile.name} + +
+ )} + +
+ {/* Hidden file input */} + + + {/* Paperclip button */} + + + {/* Text input */} + setMessage(e.target.value)} + /> + + {/* Send button */} + +
); diff --git a/frontend/src/components/chat/FileMessage.tsx b/frontend/src/components/chat/FileMessage.tsx new file mode 100644 index 0000000..1c4dbf0 --- /dev/null +++ b/frontend/src/components/chat/FileMessage.tsx @@ -0,0 +1,74 @@ +import React from "react"; + +// Map common MIME types to file extensions +const mimeToExt: Record = { + "application/pdf": ".pdf", + "application/msword": ".doc", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", + "video/mp4": ".mp4", + "image/png": ".png", + "image/jpeg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp", +}; + +function getExtension(fileType: string): string { + return mimeToExt[fileType] ?? ""; +} + +export default function FileMessage({ + fileUrl, + fileType, + fileName, +}: { + fileUrl: string; + fileType: string; + fileName?: string; +}) { + // If it's an image, render it directly + if (fileType.startsWith("image/")) { + return ( + + shared image + + ); + } + + // For PDFs and other files, fetch as blob to force download + // (the HTML download attribute is ignored on cross-origin URLs like Cloudinary) + const handleDownload = async () => { + try { + const response = await fetch(fileUrl); + const blob = await response.blob(); + // Re-create blob with correct MIME type so the OS can open it + const typedBlob = new Blob([blob], { type: fileType }); + const blobUrl = URL.createObjectURL(typedBlob); + const link = document.createElement("a"); + link.href = blobUrl; + // Build a proper filename with extension + const ext = getExtension(fileType); + link.download = fileName ?? `download${ext}`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(blobUrl); + } catch (error) { + console.error("Download failed:", error); + // Fallback: open in new tab + window.open(fileUrl, "_blank"); + } + }; + + return ( + + ); +}