ui/ux crud and stuff
This commit is contained in:
parent
094582aab7
commit
0342c0d295
@ -1,5 +1,12 @@
|
|||||||
import { ChatService } from "@/services/chatService.js";
|
import { ChatService } from "@/services/chatService.js";
|
||||||
import { AuthenticatedRequest, CreateChatRoomRequest, GetMessagesQuery } from "@/types/index.js";
|
import {
|
||||||
|
AddChatRoomMemberRequest,
|
||||||
|
AuthenticatedRequest,
|
||||||
|
CreateChatRoomRequest,
|
||||||
|
GetMessagesQuery,
|
||||||
|
RemoveChatRoomMemberRequest,
|
||||||
|
UpdateChatRoomRequest,
|
||||||
|
} from "@/types/index.js";
|
||||||
import type { Response } from "express";
|
import type { Response } from "express";
|
||||||
|
|
||||||
export class ChatController {
|
export class ChatController {
|
||||||
@ -40,4 +47,55 @@ export class ChatController {
|
|||||||
const result = await ChatService.getMessages(query);
|
const result = await ChatService.getMessages(query);
|
||||||
res.status(result.success ? 200 : 500).json(result);
|
res.status(result.success ? 200 : 500).json(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async updateChatRoom(req: AuthenticatedRequest, res: Response) {
|
||||||
|
if (!req.user) {
|
||||||
|
res.status(401).json({ success: false, error: "Unauthorized" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomId = req.params.roomId;
|
||||||
|
const data: UpdateChatRoomRequest = req.body;
|
||||||
|
|
||||||
|
const result = await ChatService.updateChatRoom(req.user.userId, roomId, data);
|
||||||
|
res.status(result.success ? 200 : result.status || 400).json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteChatRoom(req: AuthenticatedRequest, res: Response) {
|
||||||
|
if (!req.user) {
|
||||||
|
res.status(401).json({ success: false, error: "Unauthorized" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomId = req.params.roomId;
|
||||||
|
|
||||||
|
const result = await ChatService.deleteChatRoom(req.user.userId, roomId);
|
||||||
|
res.status(result.success ? 200 : result.status || 500).json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async addChatRoomMember(req: AuthenticatedRequest, res: Response) {
|
||||||
|
if (!req.user) {
|
||||||
|
res.status(401).json({ success: false, error: "Unauthorized" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomId = req.params.roomId;
|
||||||
|
const data: AddChatRoomMemberRequest = req.body;
|
||||||
|
|
||||||
|
const result = await ChatService.addChatRoomMember(req.user.userId, roomId, data);
|
||||||
|
res.status(result.success ? 200 : result.status || 500).json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async removeChatRoomMember(req: AuthenticatedRequest, res: Response) {
|
||||||
|
if (!req.user) {
|
||||||
|
res.status(401).json({ success: false, error: "Unauthorized" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomId = req.params.roomId;
|
||||||
|
const data: RemoveChatRoomMemberRequest = req.body;
|
||||||
|
|
||||||
|
const result = await ChatService.removeChatRoomMember(req.user.userId, roomId, data);
|
||||||
|
res.status(result.success ? 200 : result.status || 500).json(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
backend/src/controllers/userController.ts
Normal file
17
backend/src/controllers/userController.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { UserService } from "@/services/userService";
|
||||||
|
import { AuthenticatedRequest, UpdateUserRequest } from "@/types";
|
||||||
|
import { Response } from "express";
|
||||||
|
|
||||||
|
export class UserController {
|
||||||
|
static async updateProfile(req: AuthenticatedRequest, res: Response) {
|
||||||
|
if (!req.user) {
|
||||||
|
res.status(401).json({ success: false, error: "Unauthorized" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: UpdateUserRequest = req.body;
|
||||||
|
const result = await UserService.updateProfile(req.user.userId, data);
|
||||||
|
|
||||||
|
res.status(result.success ? 200 : 400).json(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,19 +13,19 @@ const app = express();
|
|||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
const io = new Server(server, {
|
const io = new Server(server, {
|
||||||
cors: {
|
cors: {
|
||||||
methods: ["GET", "POST"],
|
methods: ["GET", "POST", "PUT", "DELETE"],
|
||||||
origin: "http://localhost:5173",
|
origin: "http://localhost:5173",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
methods: ["GET", "POST"],
|
methods: ["GET", "POST", "PUT", "DELETE"],
|
||||||
origin: "http://localhost:5173",
|
origin: "http://localhost:5173",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json({ limit: "5mb" }));
|
||||||
|
|
||||||
app.use("/api", apiRoutes);
|
app.use("/api", apiRoutes);
|
||||||
|
|
||||||
|
|||||||
@ -36,3 +36,23 @@ export const createChatRoomSchema = z.object({
|
|||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
memberUsernames: z.array(z.string()).default([]),
|
memberUsernames: z.array(z.string()).default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const updateUserSchema = z.object({
|
||||||
|
username: z.string().min(3, "Username must be at least 3 characters long").optional(),
|
||||||
|
email: z.string().email("Invalid email format").optional(),
|
||||||
|
password: z.string().min(6, "Password must be at least 6 characters long").optional(),
|
||||||
|
avatar: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateChatRoomSchema = z.object({
|
||||||
|
name: z.string().min(1, "Room name is required"),
|
||||||
|
description: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const addChatRoomMemberSchema = z.object({
|
||||||
|
username: z.string().min(1, "Username is required"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const removeChatRoomMemberSchema = z.object({
|
||||||
|
userId: z.string().min(1, "User ID is required"),
|
||||||
|
});
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
import { ChatController } from "@/controllers/chatController.js";
|
import { ChatController } from "@/controllers/chatController.js";
|
||||||
import { authenticateToken } from "@/middleware/auth.js";
|
import { authenticateToken } from "@/middleware/auth.js";
|
||||||
import { createChatRoomSchema, validate } from "@/middleware/validation.js";
|
import {
|
||||||
|
addChatRoomMemberSchema,
|
||||||
|
createChatRoomSchema,
|
||||||
|
removeChatRoomMemberSchema,
|
||||||
|
updateChatRoomSchema,
|
||||||
|
validate,
|
||||||
|
} from "@/middleware/validation.js";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@ -9,6 +15,10 @@ router.use(authenticateToken);
|
|||||||
|
|
||||||
router.get("/chat-rooms", ChatController.getChatRooms);
|
router.get("/chat-rooms", ChatController.getChatRooms);
|
||||||
router.post("/chat-rooms", validate(createChatRoomSchema), ChatController.createChatRoom);
|
router.post("/chat-rooms", validate(createChatRoomSchema), ChatController.createChatRoom);
|
||||||
|
router.put("/chat-rooms/:roomId", validate(updateChatRoomSchema), ChatController.updateChatRoom);
|
||||||
|
router.delete("/chat-rooms/:roomId", ChatController.deleteChatRoom);
|
||||||
|
router.post("/chat-rooms/:roomId/members", validate(addChatRoomMemberSchema), ChatController.addChatRoomMember);
|
||||||
|
router.delete("/chat-rooms/:roomId/members", validate(removeChatRoomMemberSchema), ChatController.removeChatRoomMember);
|
||||||
router.get("/messages/:roomId", ChatController.getMessages);
|
router.get("/messages/:roomId", ChatController.getMessages);
|
||||||
|
|
||||||
export { router as chatRoutes };
|
export { router as chatRoutes };
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { authRoutes } from "./authRoutes.js";
|
import { authRoutes } from "./authRoutes.js";
|
||||||
import { chatRoutes } from "./chatRoutes.js";
|
import { chatRoutes } from "./chatRoutes.js";
|
||||||
|
import { userRoutes } from "./userRoutes.js";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.use("/auth", authRoutes);
|
router.use("/auth", authRoutes);
|
||||||
|
router.use("/users", userRoutes);
|
||||||
router.use("/", chatRoutes);
|
router.use("/", chatRoutes);
|
||||||
|
|
||||||
export { router as apiRoutes };
|
export { router as apiRoutes };
|
||||||
|
|||||||
11
backend/src/routes/userRoutes.ts
Normal file
11
backend/src/routes/userRoutes.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { UserController } from "@/controllers/userController.js";
|
||||||
|
import { authenticateToken } from "@/middleware/auth.js";
|
||||||
|
import { updateUserSchema, validate } from "@/middleware/validation.js";
|
||||||
|
import { Router } from "express";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(authenticateToken);
|
||||||
|
router.put("/profile", validate(updateUserSchema), UserController.updateProfile);
|
||||||
|
|
||||||
|
export { router as userRoutes };
|
||||||
@ -1,5 +1,12 @@
|
|||||||
import { prisma } from "@/config/database.js";
|
import { prisma } from "@/config/database.js";
|
||||||
import { ApiResponse, CreateChatRoomRequest, GetMessagesQuery } from "@/types/index.js";
|
import {
|
||||||
|
AddChatRoomMemberRequest,
|
||||||
|
ApiResponse,
|
||||||
|
CreateChatRoomRequest,
|
||||||
|
GetMessagesQuery,
|
||||||
|
RemoveChatRoomMemberRequest,
|
||||||
|
UpdateChatRoomRequest,
|
||||||
|
} from "@/types/index.js";
|
||||||
|
|
||||||
export class ChatService {
|
export class ChatService {
|
||||||
static async getChatRooms(userId: string): Promise<ApiResponse> {
|
static async getChatRooms(userId: string): Promise<ApiResponse> {
|
||||||
@ -201,4 +208,323 @@ export class ChatService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async updateChatRoom(userId: string, roomId: string, data: UpdateChatRoomRequest): Promise<ApiResponse> {
|
||||||
|
try {
|
||||||
|
const membership = await prisma.chatRoomMember.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_roomId: {
|
||||||
|
userId,
|
||||||
|
roomId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "You are not a member of this chat room",
|
||||||
|
status: 403,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (membership.role !== "admin") {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Only admins can update chat room settings",
|
||||||
|
status: 403,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedRoom = await prisma.chatRoom.update({
|
||||||
|
where: { id: roomId },
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
members: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
isOnline: true,
|
||||||
|
lastSeen: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
messages: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: updatedRoom,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Update chat room error:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Failed to update chat room",
|
||||||
|
status: 500,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteChatRoom(userId: string, roomId: string): Promise<ApiResponse> {
|
||||||
|
try {
|
||||||
|
const membership = await prisma.chatRoomMember.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_roomId: {
|
||||||
|
userId,
|
||||||
|
roomId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "You are not a member of this chat room",
|
||||||
|
status: 403,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (membership.role !== "admin") {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Only admins can delete chat rooms",
|
||||||
|
status: 403,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.chatRoom.delete({
|
||||||
|
where: { id: roomId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { id: roomId },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Delete chat room error:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Failed to delete chat room",
|
||||||
|
status: 500,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async addChatRoomMember(userId: string, roomId: string, data: AddChatRoomMemberRequest): Promise<ApiResponse> {
|
||||||
|
try {
|
||||||
|
const membership = await prisma.chatRoomMember.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_roomId: {
|
||||||
|
userId,
|
||||||
|
roomId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "You are not a member of this chat room",
|
||||||
|
status: 403,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (membership.role !== "admin") {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Only admins can add members",
|
||||||
|
status: 403,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { username: data.username },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "User not found",
|
||||||
|
status: 404,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingMembership = await prisma.chatRoomMember.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_roomId: {
|
||||||
|
userId: user.id,
|
||||||
|
roomId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingMembership) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "User is already a member of this chat room",
|
||||||
|
status: 409,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.chatRoomMember.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
roomId,
|
||||||
|
role: "member",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedRoom = await prisma.chatRoom.findUnique({
|
||||||
|
where: { id: roomId },
|
||||||
|
include: {
|
||||||
|
members: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
isOnline: true,
|
||||||
|
lastSeen: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
messages: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: updatedRoom,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Add chat room member error:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Failed to add member to chat room",
|
||||||
|
status: 500,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async removeChatRoomMember(userId: string, roomId: string, data: RemoveChatRoomMemberRequest): Promise<ApiResponse> {
|
||||||
|
try {
|
||||||
|
const membership = await prisma.chatRoomMember.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_roomId: {
|
||||||
|
userId,
|
||||||
|
roomId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "You are not a member of this chat room",
|
||||||
|
status: 403,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (membership.role !== "admin") {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Only admins can remove members",
|
||||||
|
status: 403,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if target user is an admin
|
||||||
|
const targetMembership = await prisma.chatRoomMember.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_roomId: {
|
||||||
|
userId: data.userId,
|
||||||
|
roomId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetMembership) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "User is not a member of this chat room",
|
||||||
|
status: 404,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetMembership.role === "admin") {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Cannot remove an admin from the chat room",
|
||||||
|
status: 403,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove user from chat room
|
||||||
|
await prisma.chatRoomMember.delete({
|
||||||
|
where: {
|
||||||
|
userId_roomId: {
|
||||||
|
userId: data.userId,
|
||||||
|
roomId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get updated chat room
|
||||||
|
const updatedRoom = await prisma.chatRoom.findUnique({
|
||||||
|
where: { id: roomId },
|
||||||
|
include: {
|
||||||
|
members: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
isOnline: true,
|
||||||
|
lastSeen: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
messages: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: updatedRoom,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Remove chat room member error:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Failed to remove member from chat room",
|
||||||
|
status: 500,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
72
backend/src/services/userService.ts
Normal file
72
backend/src/services/userService.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { prisma } from "@/config/database.js";
|
||||||
|
import { ApiResponse, UpdateUserRequest } from "@/types/index.js";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
export class UserService {
|
||||||
|
static async updateProfile(userId: string, data: UpdateUserRequest): Promise<ApiResponse> {
|
||||||
|
try {
|
||||||
|
if (data.username) {
|
||||||
|
const existingUser = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
username: data.username,
|
||||||
|
id: { not: userId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Username already taken",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.email) {
|
||||||
|
const existingUser = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
email: data.email,
|
||||||
|
id: { not: userId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Email already taken",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Partial<UpdateUserRequest> = {};
|
||||||
|
if (data.username) updateData.username = data.username;
|
||||||
|
if (data.email) updateData.email = data.email;
|
||||||
|
if (data.avatar !== undefined) updateData.avatar = data.avatar;
|
||||||
|
|
||||||
|
if (data.password) {
|
||||||
|
updateData.password = await bcrypt.hash(data.password, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: updateData,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: updatedUser,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Update profile error:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Failed to update profile",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -42,6 +42,7 @@ export type ApiResponse<T = any> = {
|
|||||||
data?: T;
|
data?: T;
|
||||||
error?: string;
|
error?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
status?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreateChatRoomRequest = {
|
export type CreateChatRoomRequest = {
|
||||||
@ -50,6 +51,13 @@ export type CreateChatRoomRequest = {
|
|||||||
memberUsernames: string[];
|
memberUsernames: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UpdateUserRequest = {
|
||||||
|
username?: string;
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
avatar?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type PaginationQuery = {
|
export type PaginationQuery = {
|
||||||
page?: string;
|
page?: string;
|
||||||
limit?: string;
|
limit?: string;
|
||||||
@ -69,3 +77,16 @@ export type LoginRequest = {
|
|||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UpdateChatRoomRequest = {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AddChatRoomMemberRequest = {
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RemoveChatRoomMemberRequest = {
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
|||||||
276
frontend/src/components/ChatRoomSettingsDialog.tsx
Normal file
276
frontend/src/components/ChatRoomSettingsDialog.tsx
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import { chatAPI } from '@/lib/api';
|
||||||
|
import { useChatStore } from '@/stores/chatStore';
|
||||||
|
import type { ChatRoom, UpdateChatRoomRequest } from '@/types';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Loader2, Trash2, UserMinus, UserPlus } from 'lucide-react';
|
||||||
|
import { useState, type FC, type FormEvent } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { Label } from './ui/label';
|
||||||
|
import { Textarea } from './ui/textarea';
|
||||||
|
|
||||||
|
type ChatRoomSettingsDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
room: ChatRoom | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChatRoomSettingsDialog: FC<ChatRoomSettingsDialogProps> = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
room,
|
||||||
|
}) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { updateChatRoom, removeChatRoom } = useChatStore();
|
||||||
|
const [newMemberUsername, setNewMemberUsername] = useState('');
|
||||||
|
const [formData, setFormData] = useState<UpdateChatRoomRequest>({
|
||||||
|
name: room?.name || '',
|
||||||
|
description: room?.description || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
useState(() => {
|
||||||
|
if (room) {
|
||||||
|
setFormData({
|
||||||
|
name: room.name,
|
||||||
|
description: room.description || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateRoomMutation = useMutation({
|
||||||
|
mutationKey: ['updateChatRoom'],
|
||||||
|
mutationFn: (data: UpdateChatRoomRequest) =>
|
||||||
|
chatAPI.updateChatRoom(room?.id || '', data),
|
||||||
|
onSuccess: (response) => {
|
||||||
|
if (response.data.success) {
|
||||||
|
updateChatRoom(response.data.data);
|
||||||
|
toast.success('Chat room updated successfully');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.error || 'Failed to update chat room');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const addMemberMutation = useMutation({
|
||||||
|
mutationKey: ['addMember'],
|
||||||
|
mutationFn: (username: string) =>
|
||||||
|
chatAPI.addChatRoomMember(room?.id || '', { username }),
|
||||||
|
onSuccess: (response) => {
|
||||||
|
if (response.data.success) {
|
||||||
|
updateChatRoom(response.data.data);
|
||||||
|
setNewMemberUsername('');
|
||||||
|
toast.success('Member added successfully');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.error || 'Failed to add member');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeMemberMutation = useMutation({
|
||||||
|
mutationKey: ['removeMember'],
|
||||||
|
mutationFn: (userId: string) =>
|
||||||
|
chatAPI.removeChatRoomMember(room?.id || '', { userId }),
|
||||||
|
onSuccess: (response) => {
|
||||||
|
if (response.data.success) {
|
||||||
|
updateChatRoom(response.data.data);
|
||||||
|
toast.success('Member removed successfully');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.error || 'Failed to remove member');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteRoomMutation = useMutation({
|
||||||
|
mutationKey: ['deleteChatRoom'],
|
||||||
|
mutationFn: () => chatAPI.deleteChatRoom(room?.id || ''),
|
||||||
|
onSuccess: (response) => {
|
||||||
|
if (response.data.success) {
|
||||||
|
removeChatRoom(room?.id || '');
|
||||||
|
onOpenChange(false);
|
||||||
|
navigate('/');
|
||||||
|
toast.success('Chat room deleted successfully');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['chatRooms'] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.error || 'Failed to delete chat room');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (room) {
|
||||||
|
updateRoomMutation.mutate(formData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddMember = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (room && newMemberUsername) {
|
||||||
|
addMemberMutation.mutate(newMemberUsername);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveMember = (userId: string) => {
|
||||||
|
if (room) {
|
||||||
|
removeMemberMutation.mutate(userId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteRoom = () => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
'Are you sure you want to delete this chat room? This action cannot be undone.'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
deleteRoomMutation.mutate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!room) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Room Settings</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Room Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, name: e.target.value })
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, description: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Room description (optional)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={updateRoomMutation.isPending}>
|
||||||
|
{updateRoomMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Save Changes'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="space-y-4 pt-4 border-t">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-2">
|
||||||
|
Members ({room.members.length})
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||||
|
{room.members.map((member) => (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-blue-500 text-white flex items-center justify-center text-xs">
|
||||||
|
{member.user.avatar ? (
|
||||||
|
<img
|
||||||
|
src={member.user.avatar}
|
||||||
|
alt={member.user.username}
|
||||||
|
className="w-full h-full rounded-full"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
member.user.username.charAt(0).toUpperCase()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm">{member.user.username}</span>
|
||||||
|
{member.role === 'admin' && (
|
||||||
|
<span className="text-xs text-gray-500">(admin)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{member.role !== 'admin' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRemoveMember(member.userId)}
|
||||||
|
disabled={removeMemberMutation.isPending}
|
||||||
|
>
|
||||||
|
<UserMinus className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleAddMember} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Add member by username"
|
||||||
|
value={newMemberUsername}
|
||||||
|
onChange={(e) => setNewMemberUsername(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
disabled={!newMemberUsername || addMemberMutation.isPending}
|
||||||
|
>
|
||||||
|
<UserPlus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleDeleteRoom}
|
||||||
|
disabled={deleteRoomMutation.isPending}
|
||||||
|
>
|
||||||
|
{deleteRoomMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Deleting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete Room
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -18,6 +18,7 @@ import { useTheme } from 'next-themes';
|
|||||||
import { useState, type FC, type FormEvent } from 'react';
|
import { useState, type FC, type FormEvent } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { UserSettingsDialog } from './SettingsDialog';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import {
|
import {
|
||||||
@ -44,6 +45,7 @@ export const ChatSidebar: FC<ChatSidebarProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||||
|
const [isSettingsDialogOpen, setIsSettingsDialogOpen] = useState(false);
|
||||||
const [newRoomData, setNewRoomData] = useState<CreateChatRoomRequest>({
|
const [newRoomData, setNewRoomData] = useState<CreateChatRoomRequest>({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
@ -240,16 +242,25 @@ export const ChatSidebar: FC<ChatSidebarProps> = ({
|
|||||||
{room.members.slice(0, 3).map((member) => (
|
{room.members.slice(0, 3).map((member) => (
|
||||||
<div
|
<div
|
||||||
key={member.id}
|
key={member.id}
|
||||||
className={`w-4 h-4 rounded-full border border-white dark:border-gray-800 flex items-center justify-center text-xs font-medium ${
|
className="relative w-4 h-4"
|
||||||
onlineUsers.has(member.userId)
|
|
||||||
? 'bg-green-500 text-white'
|
|
||||||
: 'bg-gray-300 text-gray-600 dark:bg-gray-500 dark:text-gray-300'
|
|
||||||
}`}
|
|
||||||
title={`${member.user.username} - ${
|
title={`${member.user.username} - ${
|
||||||
onlineUsers.has(member.userId) ? 'Online' : 'Offline'
|
onlineUsers.has(member.userId) ? 'Online' : 'Offline'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{member.user.username.charAt(0).toUpperCase()}
|
<div className="w-4 h-4 rounded-full border border-white dark:border-gray-800 flex items-center justify-center text-xs font-medium bg-gray-300 dark:bg-gray-600 overflow-hidden">
|
||||||
|
{member.user.avatar ? (
|
||||||
|
<img
|
||||||
|
src={member.user.avatar}
|
||||||
|
alt={member.user.username}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
member.user.username.charAt(0).toUpperCase()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{onlineUsers.has(member.userId) && (
|
||||||
|
<div className="absolute bottom-0 right-0 w-1.5 h-1.5 bg-green-500 border border-white dark:border-gray-800 rounded-full z-10"></div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{room.members.length > 3 && (
|
{room.members.length > 3 && (
|
||||||
@ -267,6 +278,7 @@ export const ChatSidebar: FC<ChatSidebarProps> = ({
|
|||||||
<Popover open={isUserMenuOpen} onOpenChange={setIsUserMenuOpen}>
|
<Popover open={isUserMenuOpen} onOpenChange={setIsUserMenuOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<div className="flex items-center space-x-3 cursor-pointer p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
<div className="flex items-center space-x-3 cursor-pointer p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
<div className="relative">
|
||||||
<div className="w-10 h-10 rounded-full bg-blue-500 text-white flex items-center justify-center">
|
<div className="w-10 h-10 rounded-full bg-blue-500 text-white flex items-center justify-center">
|
||||||
{user?.avatar ? (
|
{user?.avatar ? (
|
||||||
<img
|
<img
|
||||||
@ -278,6 +290,8 @@ export const ChatSidebar: FC<ChatSidebarProps> = ({
|
|||||||
<User className="h-6 w-6" />
|
<User className="h-6 w-6" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="absolute bottom-0 right-0 w-3 h-3 bg-green-500 border-2 border-white dark:border-gray-800 rounded-full"></div>
|
||||||
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="font-medium text-sm dark:text-white">
|
<p className="font-medium text-sm dark:text-white">
|
||||||
{user?.username}
|
{user?.username}
|
||||||
@ -330,7 +344,7 @@ export const ChatSidebar: FC<ChatSidebarProps> = ({
|
|||||||
className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-white flex items-center gap-2"
|
className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-white flex items-center gap-2"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsUserMenuOpen(false);
|
setIsUserMenuOpen(false);
|
||||||
// settings functionality TODO
|
setIsSettingsDialogOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-4 w-4" />
|
||||||
@ -347,6 +361,10 @@ export const ChatSidebar: FC<ChatSidebarProps> = ({
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
<UserSettingsDialog
|
||||||
|
open={isSettingsDialogOpen}
|
||||||
|
onOpenChange={setIsSettingsDialogOpen}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,13 +1,15 @@
|
|||||||
import { useChatStore } from '@/stores/chatStore';
|
import { useChatStore } from '@/stores/chatStore';
|
||||||
import { Settings, Users } from 'lucide-react';
|
import { Settings, Users } from 'lucide-react';
|
||||||
import { type FC } from 'react';
|
import { useState, type FC } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { ChatRoomSettingsDialog } from './ChatRoomSettingsDialog';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
|
|
||||||
export const NavBar: FC = () => {
|
export const NavBar: FC = () => {
|
||||||
const { chatRooms, onlineUsers } = useChatStore();
|
const { chatRooms, onlineUsers } = useChatStore();
|
||||||
const { roomId } = useParams<{ roomId: string }>();
|
const { roomId } = useParams<{ roomId: string }>();
|
||||||
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
|
|
||||||
const currentRoom = chatRooms.find((room) => room.id === roomId);
|
const currentRoom = chatRooms.find((room) => room.id === roomId);
|
||||||
|
|
||||||
@ -24,14 +26,18 @@ export const NavBar: FC = () => {
|
|||||||
{currentRoom ? (
|
{currentRoom ? (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h1 className="text-lg font-semibold dark:text-white">{currentRoom.name}</h1>
|
<h1 className="text-lg font-semibold dark:text-white">
|
||||||
|
{currentRoom.name}
|
||||||
|
</h1>
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
<Users className="h-3 w-3 mr-1" />
|
<Users className="h-3 w-3 mr-1" />
|
||||||
{getOnlineMembersCount()} / {currentRoom.members.length} online
|
{getOnlineMembersCount()} / {currentRoom.members.length} online
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{currentRoom.description && (
|
{currentRoom.description && (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-300">{currentRoom.description}</p>
|
<p className="text-sm text-gray-500 dark:text-gray-300">
|
||||||
|
{currentRoom.description}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -40,11 +46,23 @@ export const NavBar: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{currentRoom && (
|
{currentRoom && (
|
||||||
<Button size="sm" variant="ghost">
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setIsSettingsOpen(true)}
|
||||||
|
>
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{currentRoom && (
|
||||||
|
<ChatRoomSettingsDialog
|
||||||
|
open={isSettingsOpen}
|
||||||
|
onOpenChange={setIsSettingsOpen}
|
||||||
|
room={currentRoom}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
220
frontend/src/components/SettingsDialog.tsx
Normal file
220
frontend/src/components/SettingsDialog.tsx
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
import { userAPI } from '@/lib/api';
|
||||||
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
import { useChatStore } from '@/stores/chatStore';
|
||||||
|
import type { UpdateUserRequest } from '@/types';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { Camera, Loader2 } from 'lucide-react';
|
||||||
|
import { useState, type FC, type FormEvent } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { Label } from './ui/label';
|
||||||
|
|
||||||
|
type UserSettingsDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UserSettingsDialog: FC<UserSettingsDialogProps> = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}) => {
|
||||||
|
const { user, updateUser } = useAuthStore();
|
||||||
|
const { updateUserInRooms } = useChatStore();
|
||||||
|
const [formData, setFormData] = useState<UpdateUserRequest>({
|
||||||
|
username: user?.username || '',
|
||||||
|
email: user?.email || '',
|
||||||
|
password: '',
|
||||||
|
avatar: user?.avatar,
|
||||||
|
});
|
||||||
|
const [previewAvatar, setPreviewAvatar] = useState<string | null>(
|
||||||
|
user?.avatar || null
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateProfileMutation = useMutation({
|
||||||
|
mutationFn: userAPI.updateProfile,
|
||||||
|
onSuccess: (response) => {
|
||||||
|
if (response.data.success && response.data.data) {
|
||||||
|
const updatedUser = response.data.data;
|
||||||
|
updateUser(updatedUser);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
updateUserInRooms(user.id, updatedUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('Profile updated successfully');
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(
|
||||||
|
'Failed to update profile: ' +
|
||||||
|
(error.response?.data?.error || 'Unknown error')
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const updateData: Partial<UpdateUserRequest> = {};
|
||||||
|
if (formData.username && formData.username !== user?.username) {
|
||||||
|
updateData.username = formData.username;
|
||||||
|
}
|
||||||
|
if (formData.email && formData.email !== user?.email) {
|
||||||
|
updateData.email = formData.email;
|
||||||
|
}
|
||||||
|
if (formData.password) {
|
||||||
|
updateData.password = formData.password;
|
||||||
|
}
|
||||||
|
if (formData.avatar !== user?.avatar) {
|
||||||
|
updateData.avatar = formData.avatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updateData).length > 0) {
|
||||||
|
updateProfileMutation.mutate(updateData);
|
||||||
|
} else {
|
||||||
|
toast.info('No changes to save');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (file.size > 1024 * 1024) {
|
||||||
|
toast.error('Image too large. Please select an image under 1MB.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const base64 = event.target?.result as string;
|
||||||
|
setPreviewAvatar(base64);
|
||||||
|
setFormData({ ...formData, avatar: base64 });
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveAvatar = () => {
|
||||||
|
setPreviewAvatar(null);
|
||||||
|
setFormData({ ...formData, avatar: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Profile</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 py-4">
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="w-24 h-24 rounded-full bg-gray-200 flex items-center justify-center overflow-hidden border-2 border-gray-300">
|
||||||
|
{previewAvatar ? (
|
||||||
|
<img
|
||||||
|
src={previewAvatar}
|
||||||
|
alt="Avatar preview"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-2xl font-semibold text-gray-500">
|
||||||
|
{formData.username?.charAt(0).toUpperCase() || '?'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
htmlFor="avatar-upload"
|
||||||
|
className="absolute bottom-0 right-0 bg-blue-500 hover:bg-blue-600 text-white p-1.5 rounded-full cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<Camera className="h-4 w-4" />
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="avatar-upload"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{previewAvatar && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRemoveAvatar}
|
||||||
|
>
|
||||||
|
Remove Photo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username">Username</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, username: e.target.value })
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, email: e.target.value })
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">
|
||||||
|
New Password (leave empty to keep current)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, password: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={updateProfileMutation.isPending}>
|
||||||
|
{updateProfileMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Save Changes'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,9 +1,13 @@
|
|||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
import type {
|
import type {
|
||||||
|
AddChatRoomMemberRequest,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
CreateChatRoomRequest,
|
CreateChatRoomRequest,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
|
RemoveChatRoomMemberRequest,
|
||||||
|
UpdateChatRoomRequest,
|
||||||
|
UpdateUserRequest,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
import axios, { type AxiosResponse } from 'axios';
|
import axios, { type AxiosResponse } from 'axios';
|
||||||
|
|
||||||
@ -50,6 +54,30 @@ export const chatAPI = {
|
|||||||
limit = 50
|
limit = 50
|
||||||
): Promise<AxiosResponse<ApiResponse>> =>
|
): Promise<AxiosResponse<ApiResponse>> =>
|
||||||
api.get(`/messages/${roomId}?page=${page}&limit=${limit}`),
|
api.get(`/messages/${roomId}?page=${page}&limit=${limit}`),
|
||||||
|
// Add these new endpoints
|
||||||
|
updateChatRoom: (
|
||||||
|
roomId: string,
|
||||||
|
data: UpdateChatRoomRequest
|
||||||
|
): Promise<AxiosResponse<ApiResponse>> =>
|
||||||
|
api.put(`/chat-rooms/${roomId}`, data),
|
||||||
|
deleteChatRoom: (roomId: string): Promise<AxiosResponse<ApiResponse>> =>
|
||||||
|
api.delete(`/chat-rooms/${roomId}`),
|
||||||
|
addChatRoomMember: (
|
||||||
|
roomId: string,
|
||||||
|
data: AddChatRoomMemberRequest
|
||||||
|
): Promise<AxiosResponse<ApiResponse>> =>
|
||||||
|
api.post(`/chat-rooms/${roomId}/members`, data),
|
||||||
|
removeChatRoomMember: (
|
||||||
|
roomId: string,
|
||||||
|
data: RemoveChatRoomMemberRequest
|
||||||
|
): Promise<AxiosResponse<ApiResponse>> =>
|
||||||
|
api.delete(`/chat-rooms/${roomId}/members`, { data }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const userAPI = {
|
||||||
|
updateProfile: (
|
||||||
|
data: UpdateUserRequest
|
||||||
|
): Promise<AxiosResponse<ApiResponse>> => api.put('/users/profile', data),
|
||||||
};
|
};
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { ChatRoom, Message, MessageReaction } from '@/types';
|
import type { ChatRoom, Message, MessageReaction, User } from '@/types';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { subscribeWithSelector } from 'zustand/middleware';
|
import { subscribeWithSelector } from 'zustand/middleware';
|
||||||
|
|
||||||
@ -11,6 +11,8 @@ type ChatState = {
|
|||||||
|
|
||||||
setChatRooms: (rooms: ChatRoom[]) => void;
|
setChatRooms: (rooms: ChatRoom[]) => void;
|
||||||
addChatRoom: (room: ChatRoom) => void;
|
addChatRoom: (room: ChatRoom) => void;
|
||||||
|
updateChatRoom: (room: ChatRoom) => void;
|
||||||
|
removeChatRoom: (roomId: string) => void;
|
||||||
setSelectedRoom: (roomId: string | null) => void;
|
setSelectedRoom: (roomId: string | null) => void;
|
||||||
setMessages: (roomId: string, messages: Message[]) => void;
|
setMessages: (roomId: string, messages: Message[]) => void;
|
||||||
addMessage: (message: Message) => void;
|
addMessage: (message: Message) => void;
|
||||||
@ -24,6 +26,7 @@ type ChatState = {
|
|||||||
addTypingUser: (roomId: string, username: string) => void;
|
addTypingUser: (roomId: string, username: string) => void;
|
||||||
removeTypingUser: (roomId: string, username: string) => void;
|
removeTypingUser: (roomId: string, username: string) => void;
|
||||||
updateMessageSeen: (messageId: string, seenBy: string[]) => void;
|
updateMessageSeen: (messageId: string, seenBy: string[]) => void;
|
||||||
|
updateUserInRooms: (userId: string, userData: Partial<User>) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useChatStore = create<ChatState>()(
|
export const useChatStore = create<ChatState>()(
|
||||||
@ -41,6 +44,7 @@ export const useChatStore = create<ChatState>()(
|
|||||||
addChatRoom: (room) => {
|
addChatRoom: (room) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
chatRooms: [room, ...state.chatRooms],
|
chatRooms: [room, ...state.chatRooms],
|
||||||
|
selectedRoomId: room.id,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -147,5 +151,45 @@ export const useChatStore = create<ChatState>()(
|
|||||||
return { messages: newMessages };
|
return { messages: newMessages };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateUserInRooms: (userId: string, userData: Partial<User>) => {
|
||||||
|
set((state) => {
|
||||||
|
const updatedRooms = state.chatRooms.map((room) => ({
|
||||||
|
...room,
|
||||||
|
members: room.members.map((member) =>
|
||||||
|
member.userId === userId
|
||||||
|
? {
|
||||||
|
...member,
|
||||||
|
user: { ...member.user, ...userData },
|
||||||
|
}
|
||||||
|
: member
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { chatRooms: updatedRooms };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updateChatRoom: (room) => {
|
||||||
|
set((state) => ({
|
||||||
|
chatRooms: state.chatRooms.map((r) => (r.id === room.id ? room : r)),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
removeChatRoom: (roomId) => {
|
||||||
|
set((state) => {
|
||||||
|
const newMessages = { ...state.messages };
|
||||||
|
delete newMessages[roomId];
|
||||||
|
|
||||||
|
return {
|
||||||
|
chatRooms: state.chatRooms.filter((r) => r.id !== roomId),
|
||||||
|
selectedRoomId:
|
||||||
|
state.selectedRoomId === roomId ? null : state.selectedRoomId,
|
||||||
|
messages: newMessages,
|
||||||
|
onlineUsers: state.onlineUsers,
|
||||||
|
typingUsers: state.typingUsers,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|||||||
@ -67,8 +67,28 @@ export type RegisterRequest = {
|
|||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UpdateUserRequest = {
|
||||||
|
username?: string;
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
avatar?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type CreateChatRoomRequest = {
|
export type CreateChatRoomRequest = {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
memberUsernames: string[];
|
memberUsernames: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UpdateChatRoomRequest = {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AddChatRoomMemberRequest = {
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RemoveChatRoomMemberRequest = {
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user