diff --git a/client/src/api/auth.ts b/client/src/api/auth.ts index 8f1a0a4..c67bfc7 100644 --- a/client/src/api/auth.ts +++ b/client/src/api/auth.ts @@ -8,7 +8,6 @@ export const signUp = async (credentials: SignUpCredentials): Promise => { try { await api.post("/auth/register", credentials); } catch (error) { - console.error(error); throw error; } }; @@ -17,7 +16,6 @@ export const signIn = async (credentials: LoginCredentials): Promise => { try { await api.post("/auth/login", credentials); } catch (error) { - console.error(error); throw error; } }; @@ -26,7 +24,6 @@ export const logOut = async (): Promise => { try { await api.post("/auth/logout"); } catch (error) { - console.error(error); throw error; } }; diff --git a/client/src/api/message.ts b/client/src/api/message.ts index 216a0e4..e31ca73 100644 --- a/client/src/api/message.ts +++ b/client/src/api/message.ts @@ -2,13 +2,8 @@ import api from "@/config/axiosConfig"; import type { Message } from "@/config/schema/Message"; export const getMessagesOfRoom = async (roomId: string): Promise => { - try { - const response = await api.get(`/messages/${roomId}`, { - withCredentials: true, - }); - return response.data.messages; - } catch (error) { - console.error(error); - throw error; - } + const response = await api.get(`/messages/${roomId}`, { + withCredentials: true, + }); + return response.data.messages; }; diff --git a/client/src/api/otp.ts b/client/src/api/otp.ts index 8ef854e..9d45be7 100644 --- a/client/src/api/otp.ts +++ b/client/src/api/otp.ts @@ -26,4 +26,3 @@ export const verifyOTP = async ( throw error; } }; - diff --git a/client/src/api/room_resource.ts b/client/src/api/room_resource.ts new file mode 100644 index 0000000..0be2ae9 --- /dev/null +++ b/client/src/api/room_resource.ts @@ -0,0 +1,52 @@ +import api from "@/config/axiosConfig"; +import type { Resource } from "@/config/schema/Resource"; + +export const createResource = async ( + roomId: string, + sectionId: string, + data: { title: string; link: string } +): Promise => { + try { + const response = await api.post( + `/rooms/${roomId}/sections/${sectionId}/create`, + data, + { withCredentials: true } + ); + return response.data; + } catch (error) { + throw error; + } +}; + +export const updateResource = async ( + roomId: string, + sectionId: string, + resourceId: string, + data: { title: string; link: string } +): Promise => { + try { + const response = await api.put( + `/rooms/${roomId}/sections/${sectionId}/${resourceId}`, + data, + { withCredentials: true } + ); + return response.data; + } catch (error) { + throw error; + } +}; + +export const deleteResource = async ( + roomId: string, + sectionId: string, + resourceId: string +): Promise => { + try { + await api.delete( + `/rooms/${roomId}/sections/${sectionId}/${resourceId}`, + { withCredentials: true } + ); + } catch (error) { + throw error; + } +}; diff --git a/client/src/api/room_section.ts b/client/src/api/room_section.ts new file mode 100644 index 0000000..0c3e9f6 --- /dev/null +++ b/client/src/api/room_section.ts @@ -0,0 +1,48 @@ +import api from "@/config/axiosConfig"; +import type { Section } from "@/config/schema/Section"; + +export const createSection = async ( + roomId: string, + title: string +): Promise
=> { + try { + const response = await api.post( + `/rooms/${roomId}/sections/create`, + { title }, + { withCredentials: true } + ); + return response.data; + } catch (error) { + throw error; + } +}; + +export const updateSection = async ( + roomId: string, + sectionId: string, + title: string +): Promise
=> { + try { + const response = await api.put( + `/rooms/${roomId}/sections/${sectionId}`, + { title }, + { withCredentials: true } + ); + return response.data; + } catch (error) { + throw error; + } +}; + +export const deleteSection = async ( + roomId: string, + sectionId: string +): Promise => { + try { + await api.delete(`/rooms/${roomId}/sections/${sectionId}`, { + withCredentials: true, + }); + } catch (error) { + throw error; + } +}; diff --git a/client/src/api/user.ts b/client/src/api/user.ts index ffe4e9a..6b10a0d 100644 --- a/client/src/api/user.ts +++ b/client/src/api/user.ts @@ -11,7 +11,7 @@ export const fetchCurrentUser = async (): Promise => { } }; -export const updateUser = async (formData: FormData): Promise => { +export const updateUser = async (formData: FormData): Promise => { try { const response = await api.post("/user/update", formData, { withCredentials: true, diff --git a/client/src/components/Main/MainContent.tsx b/client/src/components/Main/MainContent.tsx index 9649817..4e6c2e9 100644 --- a/client/src/components/Main/MainContent.tsx +++ b/client/src/components/Main/MainContent.tsx @@ -1,5 +1,4 @@ import { useState, useEffect } from "react"; -import { BookOpen } from "lucide-react"; import type { StudyRoom } from "@/config/schema/StudyRoom"; import { RoomInfoPanel } from "@/components/Room/RoomInfoPanel"; import { WelcomePlaceholder } from "@/components/common/WelcomePlaceHolder"; @@ -9,6 +8,7 @@ import ChatPanel from "../chat/ChatPanel"; import { MainContentHeader } from "./MainContentHeader"; import Whiteboard from "../whiteboard/Whiteboard"; import { useSocketMessages } from "@/hooks/useSocketMessages"; +import ResourceHubPanel from "../resourcehub/ResourceHubPanel"; interface MainContentProps { selectedRoom: StudyRoom | null; @@ -78,15 +78,7 @@ export function MainContent({ )} - {mainContentTab === "resourceHub" && ( - - } - title="Resource Hub" - description="All uploaded study materials and shared links will show up here." - /> - )} + {mainContentTab === "resourceHub" && } {mainContentTab === "settings" && canEdit && (
@@ -96,23 +88,3 @@ export function MainContent({
); } - -function Placeholder({ - icon, - title, - description, -}: { - icon: React.ReactNode; - title: string; - description: string; -}) { - return ( -
-
- {icon} -

{title}

-

{description}

-
-
- ); -} diff --git a/client/src/components/Main/MainContentHeader.tsx b/client/src/components/Main/MainContentHeader.tsx index 699f028..77932fa 100644 --- a/client/src/components/Main/MainContentHeader.tsx +++ b/client/src/components/Main/MainContentHeader.tsx @@ -13,7 +13,7 @@ export function MainContentHeader({ setMainContentTab: ( tab: "chat" | "info" | "whiteboard" | "resourceHub" | "settings" ) => void; - canEdit: boolean; + canEdit: Boolean; }) { const tabs = [ { key: "chat", label: "Chat", icon: }, diff --git a/client/src/components/Room/RoomInfoPanel.tsx b/client/src/components/Room/RoomInfoPanel.tsx index d27c626..01935a9 100644 --- a/client/src/components/Room/RoomInfoPanel.tsx +++ b/client/src/components/Room/RoomInfoPanel.tsx @@ -75,7 +75,7 @@ export const RoomInfoPanel = ({ return (
  • = () => { }, [message]); const handleSubmit = (e: React.FormEvent) => { + if (!userData) return; e.preventDefault(); const trimmedMessage = message.trim(); if (!trimmedMessage || !selectedRoom) return; @@ -38,10 +39,9 @@ const ChatInput: React.FC = () => { content: trimmedMessage, room: selectedRoom._id, sender: { - _id: userData?._id, - username: userData?.username, + _id: userData._id, name: userData?.name, - profileImage: userData?.profileImage, + profileImage: userData.profileImage, }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), diff --git a/client/src/components/chat/ChatPanel.tsx b/client/src/components/chat/ChatPanel.tsx index dacf2f1..ab98390 100644 --- a/client/src/components/chat/ChatPanel.tsx +++ b/client/src/components/chat/ChatPanel.tsx @@ -3,58 +3,65 @@ import { useSelector, useDispatch } from "react-redux"; import MessageBubble from "./MessageBubble"; import type { RootState } from "@/redux/store"; import { getMessagesOfRoom } from "@/api/message"; -import type { Message } from "@/config/schema/Message"; -import { setInitialMessages } from "@/redux/slices/roomSlice"; +import { setMessages } from "@/redux/slices/roomSlice"; import ChatInput from "./ChatInput"; +import type { Message } from "@/config/schema/Message"; const EMPTY_MESSAGES: Message[] = []; const ChatPanel: React.FC = () => { const messagesEndRef = useRef(null); const dispatch = useDispatch(); - const [isLoading, setIsLoading] = useState(false); + const [isFetchingInitial, setIsFetchingInitial] = useState(false); const [error, setError] = useState(null); const selectedRoom = useSelector( (state: RootState) => state.room.selectedRoom ); - const messages: Message[] = useSelector((state: RootState) => - selectedRoom - ? state.room.messages[selectedRoom._id] || EMPTY_MESSAGES - : EMPTY_MESSAGES + + const messages: Message[] = useSelector( + () => selectedRoom?.messages || EMPTY_MESSAGES ); + const { userData } = useSelector((state: RootState) => state.user); useEffect(() => { if (selectedRoom) { - const fetchInitialMessages = async () => { - setIsLoading(true); + const hasMessages = + selectedRoom.messages && selectedRoom.messages.length > 0; + + if (!hasMessages) { + setIsFetchingInitial(true); + } + + const fetchMessages = async () => { setError(null); try { const fetchedMessages = await getMessagesOfRoom(selectedRoom._id); dispatch( - setInitialMessages({ + setMessages({ roomId: selectedRoom._id, messages: fetchedMessages, }) ); - } catch (err) { + } catch { setError("Failed to load messages."); } finally { - setIsLoading(false); + setIsFetchingInitial(false); } }; - fetchInitialMessages(); + + fetchMessages(); } }, [selectedRoom, dispatch]); useEffect(() => { - if (!isLoading && messages.length > 0) { + if (messages.length > 0) { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); } - }, [messages, isLoading]); + }, [messages.length]); - if (!selectedRoom && !isLoading) { + if (!selectedRoom && !isFetchingInitial) { return (
    @@ -73,24 +80,22 @@ const ChatPanel: React.FC = () => { return (
    - {isLoading && ( + {isFetchingInitial && messages.length === 0 && (
    )} - {!isLoading && error && ( + {!isFetchingInitial && error && messages.length === 0 && (

    {error}

    )} - {!isLoading && !error && messages.length === 0 && ( + {!isFetchingInitial && !error && messages.length === 0 && (

    No messages yet. Start the conversation!

    )} - {!isLoading && - !error && - messages.length > 0 && + {messages.length > 0 && messages.map((msg) => { const isOwnMessage = msg.sender._id === userData?._id; const messageKey = diff --git a/client/src/components/chat/MessageBubble.tsx b/client/src/components/chat/MessageBubble.tsx index 3e09fa5..d4de8e1 100644 --- a/client/src/components/chat/MessageBubble.tsx +++ b/client/src/components/chat/MessageBubble.tsx @@ -1,6 +1,6 @@ -import type { Message } from "@/config/schema/Message"; import React from "react"; import { User } from "lucide-react"; +import type { Message } from "@/config/schema/Message"; interface MessageBubbleProps { message: Message; @@ -17,9 +17,7 @@ const MessageBubble: React.FC = ({ ? "bg-emerald-500 text-white px-4 py-2 max-w-xs break-words rounded-tl-xl rounded-tr-xl rounded-bl-xl rounded-br-md" : "bg-emerald-200 text-emerald-900 px-4 py-2 max-w-xs break-words rounded-tl-xl rounded-tr-xl rounded-br-xl rounded-bl-md"; - const senderName = isOwnMessage - ? "You" - : message.sender?.name || message.sender?.username || "Unknown"; + const senderName = isOwnMessage ? "You" : message.sender?.name || "Unknown"; const time = new Date(message.createdAt || Date.now()).toLocaleTimeString( [], diff --git a/client/src/components/resourcehub/ResourceHubPanel.tsx b/client/src/components/resourcehub/ResourceHubPanel.tsx new file mode 100644 index 0000000..ff8c249 --- /dev/null +++ b/client/src/components/resourcehub/ResourceHubPanel.tsx @@ -0,0 +1,147 @@ +import React, { useState } from "react"; +import type { RootState } from "@/redux/store"; +import { useSelector, useDispatch } from "react-redux"; +import { Plus, BookOpen } from "lucide-react"; +import type { StudyRoom } from "@/config/schema/StudyRoom"; +import type { ModalState } from "./ResourceModalProps"; +import ResourceModal from "./ResourceModal"; +import SectionList from "./SectionList"; +import { useResourcePermissions } from "@/hooks/useResourcePermissions"; +import { useResourceHandlers } from "@/hooks/useResourceHandlers"; +import { ConfirmModal } from "../common/ConfirmModal"; + +const ResourceHubPanel: React.FC = () => { + const [modalState, setModalState] = useState(null); + const [confirmModal, setConfirmModal] = useState<{ + isOpen: boolean; + type: "section" | "resource"; + id: string; + sectionId?: string; + } | null>(null); + const dispatch = useDispatch(); + + const selectedRoom = useSelector( + (state: RootState) => state.room.selectedRoom + ) as StudyRoom | null; + + const { canEdit } = useResourcePermissions(selectedRoom); + const handlers = useResourceHandlers( + selectedRoom, + dispatch, + setModalState, + setConfirmModal + ); + + if (!selectedRoom) { + return ( +
    +
    + +

    + No room selected +

    +
    +
    + ); + } + + const resourceHub = selectedRoom.resourceHub || []; + + return ( +
    +
    +
    +
    +
    +
    + +
    +
    +

    + Resource Hub +

    +

    + {resourceHub.length}{" "} + {resourceHub.length === 1 ? "section" : "sections"} +

    +
    +
    + {canEdit && ( + + )} +
    +
    +
    + +
    + + setModalState({ type: "edit-section", section }) + } + onDeleteSection={handlers.handleDeleteSection} + onAddResource={(sectionId) => + setModalState({ type: "add-resource", sectionId }) + } + onEditResource={(sectionId, resource) => + setModalState({ type: "edit-resource", sectionId, resource }) + } + onDeleteResource={handlers.handleDeleteResource} + /> +
    + + {modalState && ( + setModalState(null)} + onAddSection={handlers.handleAddSection} + onUpdateSection={handlers.handleUpdateSection} + onAddResource={handlers.handleAddResource} + onUpdateResource={handlers.handleUpdateResource} + /> + )} + + { + if (confirmModal?.type === "section") { + handlers.confirmDeleteSection(confirmModal.id); + } else if ( + confirmModal?.type === "resource" && + confirmModal.sectionId + ) { + handlers.confirmDeleteResource( + confirmModal.sectionId, + confirmModal.id + ); + } + }} + onCancel={() => setConfirmModal(null)} + /> +
    + ); +}; + +export default ResourceHubPanel; diff --git a/client/src/components/resourcehub/ResourceItem.tsx b/client/src/components/resourcehub/ResourceItem.tsx new file mode 100644 index 0000000..c1769bf --- /dev/null +++ b/client/src/components/resourcehub/ResourceItem.tsx @@ -0,0 +1,80 @@ +import React from "react"; +import { ExternalLink, Edit2, Trash2 } from "lucide-react"; +import type { Resource } from "@/config/schema/Resource"; + +interface ResourceItemProps { + resource: Resource; + canEdit: boolean; + onEdit: () => void; + onDelete: () => void; +} + +const ResourceItem: React.FC = ({ + resource, + canEdit, + onEdit, + onDelete, +}) => { + const handleLinkClick = (e: React.MouseEvent) => { + e.stopPropagation(); + }; + + const getFullLink = (link: string) => { + if (!link) return "#"; + if (/^https?:\/\//i.test(link)) return link; + return `https://${link}`; + }; + + return ( +
    + +
    + +
    +
    + + {resource.title} + + + {resource.link} + +
    +
    + + {canEdit && ( +
    + + +
    + )} +
    + ); +}; + +export default ResourceItem; diff --git a/client/src/components/resourcehub/ResourceList.tsx b/client/src/components/resourcehub/ResourceList.tsx new file mode 100644 index 0000000..db51a91 --- /dev/null +++ b/client/src/components/resourcehub/ResourceList.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { FileText } from "lucide-react"; +import type { Resource } from "@/config/schema/Resource"; +import ResourceItem from "./ResourceItem"; + +interface ResourceListProps { + resources: Resource[]; + canEdit: boolean; + onEdit: (resource: Resource) => void; + onDelete: (resourceId: string) => void; +} + +const ResourceList: React.FC = ({ + resources, + canEdit, + onEdit, + onDelete, +}) => { + if (resources.length === 0) { + return ( +
    +
    + +
    +

    + No resources in this section yet +

    + {canEdit && ( +

    + Click the + button above to add one +

    + )} +
    + ); + } + + return ( +
    + {resources.map((resource, index) => ( + onEdit(resource)} + onDelete={() => onDelete(resource._id)} + /> + ))} +
    + ); +}; + +export default ResourceList; \ No newline at end of file diff --git a/client/src/components/resourcehub/ResourceModal.tsx b/client/src/components/resourcehub/ResourceModal.tsx new file mode 100644 index 0000000..7ae693e --- /dev/null +++ b/client/src/components/resourcehub/ResourceModal.tsx @@ -0,0 +1,286 @@ +import React, { useState, useEffect } from "react"; +import { X, Save, FolderPlus, Link as LinkIcon } from "lucide-react"; +import type { ModalState } from "./ResourceModalProps"; + +interface ResourceModalProps { + initialState: ModalState; + onClose: () => void; + onAddSection: (title: string) => void; + onUpdateSection: (sectionId: string, title: string) => void; + onAddResource: (sectionId: string, title: string, link: string) => void; + onUpdateResource: ( + sectionId: string, + resourceId: string, + title: string, + link: string + ) => void; +} + +const ResourceModal: React.FC = ({ + initialState, + onClose, + onAddSection, + onUpdateSection, + onAddResource, + onUpdateResource, +}) => { + const [title, setTitle] = useState(""); + const [link, setLink] = useState(""); + const [errors, setErrors] = useState<{ title?: string; link?: string }>({}); + + useEffect(() => { + if (initialState?.type === "edit-section" && initialState.section) { + setTitle(initialState.section.title); + } else if ( + initialState?.type === "edit-resource" && + initialState.resource + ) { + setTitle(initialState.resource.title); + setLink(initialState.resource.link); + } + }, [initialState]); + + const validateForm = () => { + const newErrors: { title?: string; link?: string } = {}; + + if (!title.trim()) { + newErrors.title = "Title is required"; + } + + if ( + (initialState?.type === "add-resource" || + initialState?.type === "edit-resource") && + !link.trim() + ) { + newErrors.link = "Link is required"; + } + + if ( + (initialState?.type === "add-resource" || + initialState?.type === "edit-resource") && + link.trim() && + !isValidUrl(link) + ) { + newErrors.link = "Please enter a valid URL"; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const isValidUrl = (urlString: string) => { + try { + new URL(urlString.startsWith("http") ? urlString : `http://${urlString}`); + return true; + } catch { + return false; + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) return; + + if (initialState?.type === "add-section") { + onAddSection(title.trim()); + } else if (initialState?.type === "edit-section" && initialState.section) { + onUpdateSection(initialState.section._id, title.trim()); + } else if ( + initialState?.type === "add-resource" && + initialState.sectionId + ) { + onAddResource(initialState.sectionId, title.trim(), link.trim()); + } else if ( + initialState?.type === "edit-resource" && + initialState.sectionId && + initialState.resource + ) { + onUpdateResource( + initialState.sectionId, + initialState.resource._id, + title.trim(), + link.trim() + ); + } + }; + + const getModalTitle = () => { + switch (initialState?.type) { + case "add-section": + return "Add New Section"; + case "edit-section": + return "Edit Section"; + case "add-resource": + return "Add New Resource"; + case "edit-resource": + return "Edit Resource"; + default: + return "Modal"; + } + }; + + const getModalIcon = () => { + switch (initialState?.type) { + case "add-section": + case "edit-section": + return ; + case "add-resource": + case "edit-resource": + return ; + default: + return null; + } + }; + + const isResourceModal = + initialState?.type === "add-resource" || + initialState?.type === "edit-resource"; + + return ( +
    +
    + {/* Header */} +
    +
    +
    + {getModalIcon()} +
    +
    +

    + {getModalTitle()} +

    +

    + {isResourceModal + ? "Enter the resource details below" + : "Enter the section name"} +

    +
    +
    + +
    + + {/* Form */} +
    + {/* Title Input */} +
    + + { + setTitle(e.target.value); + if (errors.title) setErrors({ ...errors, title: undefined }); + }} + placeholder={ + isResourceModal + ? "e.g., Documentation" + : "e.g., Programming Resources" + } + className={`w-full px-4 py-3 bg-gray-50 dark:bg-gray-900 border-2 ${ + errors.title + ? "border-red-500 dark:border-red-500" + : "border-gray-200 dark:border-gray-700 focus:border-emerald-500 dark:focus:border-emerald-500" + } rounded-xl focus:outline-none focus:ring-4 focus:ring-emerald-500/20 transition-all text-gray-900 dark:text-gray-100`} + autoFocus + /> + {errors.title && ( +

    + + {errors.title} +

    + )} +
    + + {/* Link Input (only for resources) */} + {isResourceModal && ( +
    + + { + setLink(e.target.value); + if (errors.link) setErrors({ ...errors, link: undefined }); + }} + placeholder="https://example.com" + className={`w-full px-4 py-3 bg-gray-50 dark:bg-gray-900 border-2 ${ + errors.link + ? "border-red-500 dark:border-red-500" + : "border-gray-200 dark:border-gray-700 focus:border-emerald-500 dark:focus:border-emerald-500" + } rounded-xl focus:outline-none focus:ring-4 focus:ring-emerald-500/20 transition-all text-gray-900 dark:text-gray-100`} + /> + {errors.link && ( +

    + + {errors.link} +

    + )} +
    + )} + + {/* Action Buttons */} +
    + + +
    +
    +
    + + +
    + ); +}; + +export default ResourceModal; diff --git a/client/src/components/resourcehub/ResourceModalProps.ts b/client/src/components/resourcehub/ResourceModalProps.ts new file mode 100644 index 0000000..4a98757 --- /dev/null +++ b/client/src/components/resourcehub/ResourceModalProps.ts @@ -0,0 +1,24 @@ +import type { Section } from "@/config/schema/Section"; +import type { Resource } from "@/config/schema/Resource"; + +export type ModalState = + | { type: "add-section" } + | { type: "edit-section"; section: Section } + | { type: "add-resource"; sectionId: string } + | { type: "edit-resource"; sectionId: string; resource: Resource } + | null; + + +export interface ResourceModalProps { + initialState: ModalState; + onClose: () => void; + onAddSection: (title: string) => void; + onUpdateSection: (sectionId: string, title: string) => void; + onAddResource: (sectionId: string, title: string, link: string) => void; + onUpdateResource: ( + sectionId: string, + resourceId: string, + title: string, + link: string + ) => void; +} diff --git a/client/src/components/resourcehub/SectionItem.tsx b/client/src/components/resourcehub/SectionItem.tsx new file mode 100644 index 0000000..1b6e03a --- /dev/null +++ b/client/src/components/resourcehub/SectionItem.tsx @@ -0,0 +1,103 @@ +import React, { useState } from "react"; +import { Plus, Trash2, Edit2, ChevronDown, Folder } from "lucide-react"; +import type { Section } from "@/config/schema/Section"; +import type { Resource } from "@/config/schema/Resource"; +import ResourceList from "./ResourceList"; + +interface SectionItemProps { + section: Section; + canEdit: boolean; + onEdit: () => void; + onDelete: () => void; + onAddResource: () => void; + onEditResource: (resource: Resource) => void; + onDeleteResource: (resourceId: string) => void; +} + +const SectionItem: React.FC = ({ + section, + canEdit, + onEdit, + onDelete, + onAddResource, + onEditResource, + onDeleteResource, +}) => { + const [isExpanded, setIsExpanded] = useState(true); + + return ( +
    + {/* Section Header */} +
    setIsExpanded(!isExpanded)} + > +
    +
    + +
    +
    +

    + {section.title} +

    +

    + {section.resources.length}{" "} + {section.resources.length === 1 ? "resource" : "resources"} +

    +
    +
    + +
    + {canEdit && ( +
    e.stopPropagation()}> + + + +
    + )} + +
    +
    + + {/* Section Content */} +
    +
    + +
    +
    +
    + ); +}; + +export default SectionItem; diff --git a/client/src/components/resourcehub/SectionList.tsx b/client/src/components/resourcehub/SectionList.tsx new file mode 100644 index 0000000..d767070 --- /dev/null +++ b/client/src/components/resourcehub/SectionList.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import { FolderOpen } from "lucide-react"; +import type { Section } from "@/config/schema/Section"; +import type { Resource } from "@/config/schema/Resource"; +import SectionItem from "./SectionItem"; + +interface SectionListProps { + sections: Section[]; + canEdit: boolean; + onEditSection: (section: Section) => void; + onDeleteSection: (sectionId: string) => void; + onAddResource: (sectionId: string) => void; + onEditResource: (sectionId: string, resource: Resource) => void; + onDeleteResource: (sectionId: string, resourceId: string) => void; +} + +const SectionList: React.FC = ({ + sections, + canEdit, + onEditSection, + onDeleteSection, + onAddResource, + onEditResource, + onDeleteResource, +}) => { + if (sections.length === 0) { + return ( +
    +
    +
    + +
    +

    + No Resources Yet +

    +

    + Start building your resource library +

    + {canEdit && ( +

    + Click "Add Section" above to get started +

    + )} +
    +
    + ); + } + + return ( +
    + {sections.map((section, index) => ( + onEditSection(section)} + onDelete={() => onDeleteSection(section._id)} + onAddResource={() => onAddResource(section._id)} + onEditResource={(resource) => onEditResource(section._id, resource)} + onDeleteResource={(resourceId) => + onDeleteResource(section._id, resourceId) + } + /> + ))} +
    + ); +}; + +export default SectionList; \ No newline at end of file diff --git a/client/src/config/schema/Member.ts b/client/src/config/schema/Member.ts new file mode 100644 index 0000000..112fe3e --- /dev/null +++ b/client/src/config/schema/Member.ts @@ -0,0 +1,11 @@ +export interface Member { + _id: string; + name: string; + email: string; + profileImage?: string; +} + +export interface RoomMember { + user: Member; + isAdmin: boolean; +} diff --git a/client/src/config/schema/Message.ts b/client/src/config/schema/Message.ts index 8245de8..fbc1819 100644 --- a/client/src/config/schema/Message.ts +++ b/client/src/config/schema/Message.ts @@ -1,13 +1,14 @@ export interface Message { _id: string; content: string; - sender: { - _id?: string; - username?: string; - name?: string; - profileImage?: string; - } ; + sender: Message_Sender; room: string; - createdAt?: string; - updatedAt?: string; -} \ No newline at end of file + createdAt: string; + updatedAt: string; +} + +export interface Message_Sender { + _id: string; + name: string; + profileImage?: string; +} diff --git a/client/src/config/schema/Resource.ts b/client/src/config/schema/Resource.ts new file mode 100644 index 0000000..9b3ffc7 --- /dev/null +++ b/client/src/config/schema/Resource.ts @@ -0,0 +1,5 @@ +export interface Resource { + _id: string; + title: string; + link: string; +} diff --git a/client/src/config/schema/Section.ts b/client/src/config/schema/Section.ts new file mode 100644 index 0000000..8237edc --- /dev/null +++ b/client/src/config/schema/Section.ts @@ -0,0 +1,7 @@ +import type { Resource } from "./Resource"; + +export interface Section { + _id: string; + title: string; + resources: Resource[]; +} diff --git a/client/src/config/schema/StudyRoom.ts b/client/src/config/schema/StudyRoom.ts index 1ddd799..c7dfe73 100644 --- a/client/src/config/schema/StudyRoom.ts +++ b/client/src/config/schema/StudyRoom.ts @@ -1,33 +1,17 @@ -export interface Member { - user: { - _id: string; - name: string; - profileImage?: string; - email: string; - }; - isAdmin: boolean; - _id?: string; -} - -export interface Section { - title: string; - resources: any[]; - _id?: string; -} +import type { Member, RoomMember } from "./Member"; +import type { Message } from "./Message"; +import type { Section } from "./Section"; export interface StudyRoom { _id: string; name: string; - description?: string; + description: string; isPrivate: boolean; - owner: { - _id: string; - name: string; - profileImage?: string; - }; - members: Member[]; roomImage: string; - whiteboardState?: string; + owner: Member; + members: RoomMember[]; + whiteBoardState: string; + messages?: Message[]; resourceHub: Section[]; createdAt: string; updatedAt: string; diff --git a/client/src/config/schema/User.ts b/client/src/config/schema/User.ts index 118646a..8b70cd4 100644 --- a/client/src/config/schema/User.ts +++ b/client/src/config/schema/User.ts @@ -1,11 +1,12 @@ export interface User { _id: string; - username: string; + name: string; email: string; - name: string, - profileImage?: string; + username: string; bio?: string; isVerified: Boolean; deleted: Boolean; createdAt: string; -} \ No newline at end of file + updatedAt: string; + profileImage?: string; +} diff --git a/client/src/config/socket.ts b/client/src/config/socket.ts index f8d7af1..9d69a9c 100644 --- a/client/src/config/socket.ts +++ b/client/src/config/socket.ts @@ -1,6 +1,6 @@ import { io, Socket } from "socket.io-client"; -const SOCKET_URL = "https://studi-io-5w1z.onrender.com/"; +const SOCKET_URL = "http://localhost:8000/"; export const socket: Socket = io(SOCKET_URL, { autoConnect: false, diff --git a/client/src/hooks/useResourceHandlers.ts b/client/src/hooks/useResourceHandlers.ts new file mode 100644 index 0000000..f4d6b64 --- /dev/null +++ b/client/src/hooks/useResourceHandlers.ts @@ -0,0 +1,259 @@ +import type { Dispatch as ReduxDispatch } from "@reduxjs/toolkit"; +import type { Dispatch, SetStateAction } from "react"; +import toast from "react-hot-toast"; +import type { StudyRoom } from "@/config/schema/StudyRoom"; +import type { Section } from "@/config/schema/Section"; +import type { Resource } from "@/config/schema/Resource"; +import { + addSection as addSectionAction, + updateSection as updateSectionAction, + deleteSection as deleteSectionAction, + addResource as addResourceAction, + updateResource as updateResourceAction, + deleteResource as deleteResourceAction, +} from "@/redux/slices/roomSlice"; +import { + createSection, + deleteSection, + updateSection, +} from "@/api/room_section"; +import { + createResource, + deleteResource, + updateResource, +} from "@/api/room_resource"; + +type ConfirmModalState = { + isOpen: boolean; + type: "section" | "resource"; + id: string; + sectionId?: string; +} | null; + +export const useResourceHandlers = ( + selectedRoom: StudyRoom | null, + dispatch: ReduxDispatch, + setModalState: (state: any) => void, + setConfirmModal: Dispatch> +) => { + const handleAddSection = async (title: string) => { + if (!selectedRoom) return; + const tempId = `temp-section-${Date.now()}`; + const newSection: Section = { _id: tempId, title, resources: [] }; + + dispatch( + addSectionAction({ roomId: selectedRoom._id, section: newSection }) + ); + setModalState(null); + + try { + const savedSection = await createSection(selectedRoom._id, title); + dispatch( + deleteSectionAction({ roomId: selectedRoom._id, sectionId: tempId }) + ); + dispatch( + addSectionAction({ roomId: selectedRoom._id, section: savedSection }) + ); + toast.success("Section added!"); + } catch { + dispatch( + deleteSectionAction({ roomId: selectedRoom._id, sectionId: tempId }) + ); + toast.error("Failed to add section"); + } + }; + + const handleUpdateSection = async (sectionId: string, title: string) => { + if (!selectedRoom) return; + const section = selectedRoom.resourceHub?.find((s) => s._id === sectionId); + if (!section) return; + + const updatedSection: Section = { ...section, title }; + dispatch( + updateSectionAction({ roomId: selectedRoom._id, section: updatedSection }) + ); + setModalState(null); + + try { + const savedSection = await updateSection( + selectedRoom._id, + sectionId, + title + ); + dispatch( + updateSectionAction({ roomId: selectedRoom._id, section: savedSection }) + ); + toast.success("Section updated!"); + } catch { + dispatch(updateSectionAction({ roomId: selectedRoom._id, section })); + toast.error("Failed to update section"); + } + }; + + const handleDeleteSection = async (sectionId: string) => { + if (!selectedRoom) return; + setConfirmModal({ isOpen: true, type: "section", id: sectionId }); + }; + + const confirmDeleteSection = async (sectionId: string) => { + if (!selectedRoom) return; + + const section = selectedRoom.resourceHub?.find((s) => s._id === sectionId); + if (!section) return; + + dispatch(deleteSectionAction({ roomId: selectedRoom._id, sectionId })); + setConfirmModal(null); + + try { + await deleteSection(selectedRoom._id, sectionId); + toast.success("Section deleted!"); + } catch { + dispatch(addSectionAction({ roomId: selectedRoom._id, section })); + toast.error("Failed to delete section"); + } + }; + + const handleAddResource = async ( + sectionId: string, + title: string, + link: string + ) => { + if (!selectedRoom) return; + const tempId = `temp-res-${Date.now()}`; + const newResource: Resource = { _id: tempId, title, link }; + + dispatch( + addResourceAction({ + roomId: selectedRoom._id, + sectionId, + resource: newResource, + }) + ); + setModalState(null); + + try { + const savedResource = await createResource(selectedRoom._id, sectionId, { + title, + link, + }); + dispatch( + deleteResourceAction({ + roomId: selectedRoom._id, + sectionId, + resourceId: tempId, + }) + ); + dispatch( + addResourceAction({ + roomId: selectedRoom._id, + sectionId, + resource: savedResource, + }) + ); + toast.success("Resource added!"); + } catch { + dispatch( + deleteResourceAction({ + roomId: selectedRoom._id, + sectionId, + resourceId: tempId, + }) + ); + toast.error("Failed to add resource"); + } + }; + + const handleUpdateResource = async ( + sectionId: string, + resourceId: string, + title: string, + link: string + ) => { + if (!selectedRoom) return; + const section = selectedRoom.resourceHub?.find((s) => s._id === sectionId); + const resource = section?.resources.find((r) => r._id === resourceId); + if (!resource) return; + + const updatedResource: Resource = { _id: resourceId, title, link }; + dispatch( + updateResourceAction({ + roomId: selectedRoom._id, + sectionId, + resource: updatedResource, + }) + ); + setModalState(null); + + try { + const savedResource = await updateResource( + selectedRoom._id, + sectionId, + resourceId, + { title, link } + ); + dispatch( + updateResourceAction({ + roomId: selectedRoom._id, + sectionId, + resource: savedResource, + }) + ); + toast.success("Resource updated!"); + } catch { + dispatch( + updateResourceAction({ roomId: selectedRoom._id, sectionId, resource }) + ); + toast.error("Failed to update resource"); + } + }; + + const handleDeleteResource = async ( + sectionId: string, + resourceId: string + ) => { + if (!selectedRoom) return; + setConfirmModal({ + isOpen: true, + type: "resource", + id: resourceId, + sectionId, + }); + }; + + const confirmDeleteResource = async ( + sectionId: string, + resourceId: string + ) => { + if (!selectedRoom) return; + + const section = selectedRoom.resourceHub?.find((s) => s._id === sectionId); + const resource = section?.resources.find((r) => r._id === resourceId); + if (!resource) return; + + dispatch( + deleteResourceAction({ roomId: selectedRoom._id, sectionId, resourceId }) + ); + setConfirmModal(null); + + try { + await deleteResource(selectedRoom._id, sectionId, resourceId); + toast.success("Resource deleted!"); + } catch { + dispatch( + addResourceAction({ roomId: selectedRoom._id, sectionId, resource }) + ); + toast.error("Failed to delete resource"); + } + }; + + return { + handleAddSection, + handleUpdateSection, + handleDeleteSection, + confirmDeleteSection, + handleAddResource, + handleUpdateResource, + handleDeleteResource, + confirmDeleteResource, + }; +}; diff --git a/client/src/hooks/useResourcePermissions.ts b/client/src/hooks/useResourcePermissions.ts new file mode 100644 index 0000000..105ed9a --- /dev/null +++ b/client/src/hooks/useResourcePermissions.ts @@ -0,0 +1,20 @@ +import { useMemo } from "react"; +import { useSelector } from "react-redux"; +import type { RootState } from "@/redux/store"; +import type { StudyRoom } from "@/config/schema/StudyRoom"; + +export const useResourcePermissions = (selectedRoom: StudyRoom | null) => { + const { userData } = useSelector((state: RootState) => state.user); + + const { canEdit } = useMemo(() => { + if (!selectedRoom || !userData) return { canEdit: false }; + const isOwner = selectedRoom.owner._id === userData._id; + const currentUserMemberInfo = selectedRoom.members.find( + (m) => m.user._id === userData._id + ); + const isAdmin = currentUserMemberInfo?.isAdmin || false; + return { canEdit: isOwner || isAdmin }; + }, [selectedRoom, userData]); + + return { canEdit }; +}; \ No newline at end of file diff --git a/client/src/hooks/useSocketMessages.tsx b/client/src/hooks/useSocketMessages.ts similarity index 100% rename from client/src/hooks/useSocketMessages.tsx rename to client/src/hooks/useSocketMessages.ts index ee1eda1..87d52e9 100644 --- a/client/src/hooks/useSocketMessages.tsx +++ b/client/src/hooks/useSocketMessages.ts @@ -3,8 +3,8 @@ import { useDispatch, useSelector } from "react-redux"; import { addMessage } from "@/redux/slices/roomSlice"; import { socket } from "@/config/socket"; import type { RootState } from "@/redux/store"; -import type { Message } from "@/config/schema/Message"; import toast from "react-hot-toast"; +import type { Message } from "@/config/schema/Message"; export const useSocketMessages = () => { const dispatch = useDispatch(); diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index c11aa77..5258d45 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -1,4 +1,3 @@ -// SettingsPage.tsx import { logOut } from "@/api/auth"; import { AccountModal } from "@/components/Settings/AccountModal"; import { AppearanceModal } from "@/components/Settings/AppearanceModal"; diff --git a/client/src/redux/slices/roomSlice.ts b/client/src/redux/slices/roomSlice.ts index c3aedb4..059b13c 100644 --- a/client/src/redux/slices/roomSlice.ts +++ b/client/src/redux/slices/roomSlice.ts @@ -1,11 +1,12 @@ import type { Message } from "@/config/schema/Message"; -import type { Member, StudyRoom } from "@/config/schema/StudyRoom"; +import type { Resource } from "@/config/schema/Resource"; +import type { Section } from "@/config/schema/Section"; +import type { StudyRoom } from "@/config/schema/StudyRoom"; import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; interface RoomState { rooms: StudyRoom[]; selectedRoom: StudyRoom | null; - messages: { [roomId: string]: Message[] }; isLoading: boolean; error: string | null; } @@ -13,7 +14,6 @@ interface RoomState { const initialState: RoomState = { rooms: [], selectedRoom: null, - messages: {}, isLoading: false, error: null, }; @@ -50,7 +50,6 @@ const roomSlice = createSlice({ if (state.selectedRoom?._id === action.payload) { state.selectedRoom = null; } - delete state.messages[action.payload]; state.error = null; }, @@ -58,124 +57,309 @@ const roomSlice = createSlice({ state.selectedRoom = action.payload; }, - addMemberToRoom: ( + setMessages: ( state, - action: PayloadAction<{ roomId: string; member: Member }> + action: PayloadAction<{ roomId: string; messages: Message[] }> ) => { - const room = state.rooms.find((r) => r._id === action.payload.roomId); + const { roomId, messages } = action.payload; + + const room = state.rooms.find((r) => r._id === roomId); if (room) { - room.members.push(action.payload.member); + room.messages = messages; } - if (state.selectedRoom?._id === action.payload.roomId) { - state.selectedRoom.members.push(action.payload.member); + + if (state.selectedRoom?._id === roomId) { + state.selectedRoom.messages = messages; } }, - removeMemberFromRoom: ( + addMessage: ( state, - action: PayloadAction<{ roomId: string; userId: string }> + action: PayloadAction<{ message: Message; currentUserId?: string }> ) => { - const room = state.rooms.find((r) => r._id === action.payload.roomId); - if (room) { - room.members = room.members.filter( - (m) => m.user._id !== action.payload.userId + const { message, currentUserId } = action.payload; + const roomId = message.room; + + const updateRoomMessages = (room: StudyRoom) => { + if (!room.messages) { + room.messages = []; + } + + if (currentUserId && currentUserId === message.sender._id) { + const tempIndex = room.messages.findIndex( + (m) => + m._id?.startsWith("temp-") && + m.sender._id === message.sender._id && + m.content === message.content + ); + + if (tempIndex !== -1) { + room.messages[tempIndex] = message; + return; + } + } + + const existingIndex = room.messages.findIndex( + (m) => m._id === message._id ); + if (existingIndex === -1) { + room.messages.push(message); + } + }; + + const room = state.rooms.find((r) => r._id === roomId); + if (room) { + updateRoomMessages(room); } - if (state.selectedRoom?._id === action.payload.roomId) { - state.selectedRoom.members = state.selectedRoom.members.filter( - (m) => m.user._id !== action.payload.userId - ); + + if (state.selectedRoom?._id === roomId) { + updateRoomMessages(state.selectedRoom); } }, - setInitialMessages( + + updateMessage: ( state, - action: PayloadAction<{ roomId: string; messages: Message[] }> - ) { - state.messages[action.payload.roomId] = action.payload.messages; + action: PayloadAction<{ roomId: string; message: Message }> + ) => { + const { roomId, message } = action.payload; + + const updateRoomMessage = (room: StudyRoom) => { + if (!room.messages) return; + + const messageIndex = room.messages.findIndex( + (m) => m._id === message._id + ); + if (messageIndex !== -1) { + room.messages[messageIndex] = message; + } + }; + + const room = state.rooms.find((r) => r._id === roomId); + if (room) { + updateRoomMessage(room); + } + + if (state.selectedRoom?._id === roomId) { + updateRoomMessage(state.selectedRoom); + } }, - updateWhiteboardState: ( + deleteMessage: ( state, - action: PayloadAction<{ roomId: string; whiteboardState: string }> + action: PayloadAction<{ roomId: string; messageId: string }> ) => { - const room = state.rooms.find((r) => r._id === action.payload.roomId); + const { roomId, messageId } = action.payload; + + const deleteRoomMessage = (room: StudyRoom) => { + if (!room.messages) return; + room.messages = room.messages.filter((m) => m._id !== messageId); + }; + + const room = state.rooms.find((r) => r._id === roomId); if (room) { - room.whiteboardState = action.payload.whiteboardState; + deleteRoomMessage(room); } - if (state.selectedRoom?._id === action.payload.roomId) { - state.selectedRoom.whiteboardState = action.payload.whiteboardState; + + if (state.selectedRoom?._id === roomId) { + deleteRoomMessage(state.selectedRoom); } }, - setMessages: ( + addSection: ( state, - action: PayloadAction<{ roomId: string; messages: Message[] }> + action: PayloadAction<{ roomId: string; section: Section }> ) => { - state.messages[action.payload.roomId] = action.payload.messages; + const { roomId, section } = action.payload; + + const room = state.rooms.find((r) => r._id === roomId); + if (room) { + if (!room.resourceHub) { + room.resourceHub = []; + } + room.resourceHub.push(section); + } + + if (state.selectedRoom?._id === roomId) { + if (!state.selectedRoom.resourceHub) { + state.selectedRoom.resourceHub = []; + } + state.selectedRoom.resourceHub.push(section); + } + + state.error = null; }, - addMessage: ( + updateSection: ( state, - action: PayloadAction<{ message: Message; currentUserId?: string }> + action: PayloadAction<{ roomId: string; section: Section }> ) => { - const { message, currentUserId } = action.payload; - const roomId = message.room; + const { roomId, section } = action.payload; - if (!state.messages[roomId]) { - state.messages[roomId] = []; + const room = state.rooms.find((r) => r._id === roomId); + if (room && room.resourceHub) { + const sectionIndex = room.resourceHub.findIndex( + (s) => s._id === section._id + ); + if (sectionIndex !== -1) { + room.resourceHub[sectionIndex] = section; + } } - if (currentUserId && currentUserId === message.sender._id) { - const messages = state.messages[roomId]; + if ( + state.selectedRoom?._id === roomId && + state.selectedRoom.resourceHub + ) { + const sectionIndex = state.selectedRoom.resourceHub.findIndex( + (s) => s._id === section._id + ); + if (sectionIndex !== -1) { + state.selectedRoom.resourceHub[sectionIndex] = section; + } + } + + state.error = null; + }, + + deleteSection: ( + state, + action: PayloadAction<{ roomId: string; sectionId: string }> + ) => { + const { roomId, sectionId } = action.payload; - const tempIndex = messages.findIndex( - (m) => - m._id?.startsWith("temp-") && - m.sender._id === message.sender._id && - m.content === message.content + const room = state.rooms.find((r) => r._id === roomId); + if (room && room.resourceHub) { + room.resourceHub = room.resourceHub.filter((s) => s._id !== sectionId); + } + + if ( + state.selectedRoom?._id === roomId && + state.selectedRoom.resourceHub + ) { + state.selectedRoom.resourceHub = state.selectedRoom.resourceHub.filter( + (s) => s._id !== sectionId ); + } - if (tempIndex !== -1) { - messages[tempIndex] = message; - return; + state.error = null; + }, + + addResource: ( + state, + action: PayloadAction<{ + roomId: string; + sectionId: string; + resource: Resource; + }> + ) => { + const { roomId, sectionId, resource } = action.payload; + + const room = state.rooms.find((r) => r._id === roomId); + if (room && room.resourceHub) { + const section = room.resourceHub.find((s) => s._id === sectionId); + if (section) { + if (!Array.isArray(section.resources)) { + section.resources = []; + } + section.resources.push(resource); + } + } + + if ( + state.selectedRoom?._id === roomId && + state.selectedRoom.resourceHub + ) { + const section = state.selectedRoom.resourceHub.find( + (s) => s._id === sectionId + ); + if (section) { + if (!Array.isArray(section.resources)) { + section.resources = []; + } + section.resources.push(resource); } } - state.messages[roomId].push(message); + state.error = null; }, - updateMessage: ( + updateResource: ( state, action: PayloadAction<{ roomId: string; - messageId: string; - content: string; + sectionId: string; + resource: Resource; }> ) => { - const { roomId, messageId, content } = action.payload; - const messages = state.messages[roomId]; - if (messages) { - const index = messages.findIndex((m) => m._id === messageId); - if (index !== -1) { - messages[index].content = content; - messages[index].updatedAt = new Date().toISOString(); + const { roomId, sectionId, resource } = action.payload; + + const room = state.rooms.find((r) => r._id === roomId); + if (room && room.resourceHub) { + const section = room.resourceHub.find((s) => s._id === sectionId); + if (section && Array.isArray(section.resources)) { + const resourceIndex = section.resources.findIndex( + (r) => r._id === resource._id + ); + if (resourceIndex !== -1) { + section.resources[resourceIndex] = resource; + } + } + } + + if ( + state.selectedRoom?._id === roomId && + state.selectedRoom.resourceHub + ) { + const section = state.selectedRoom.resourceHub.find( + (s) => s._id === sectionId + ); + if (section && Array.isArray(section.resources)) { + const resourceIndex = section.resources.findIndex( + (r) => r._id === resource._id + ); + if (resourceIndex !== -1) { + section.resources[resourceIndex] = resource; + } } } + + state.error = null; }, - deleteMessage: ( + deleteResource: ( state, - action: PayloadAction<{ roomId: string; messageId: string }> + action: PayloadAction<{ + roomId: string; + sectionId: string; + resourceId: string; + }> ) => { - const { roomId, messageId } = action.payload; - const messages = state.messages[roomId]; - if (messages) { - state.messages[roomId] = messages.filter((m) => m._id !== messageId); + const { roomId, sectionId, resourceId } = action.payload; + + const room = state.rooms.find((r) => r._id === roomId); + if (room && room.resourceHub) { + const section = room.resourceHub.find((s) => s._id === sectionId); + if (section && Array.isArray(section.resources)) { + section.resources = section.resources.filter( + (r) => r._id !== resourceId + ); + } } - }, - clearMessages: (state, action: PayloadAction) => { - delete state.messages[action.payload]; + if ( + state.selectedRoom?._id === roomId && + state.selectedRoom.resourceHub + ) { + const section = state.selectedRoom.resourceHub.find( + (s) => s._id === sectionId + ); + if (section && Array.isArray(section.resources)) { + section.resources = section.resources.filter( + (r) => r._id !== resourceId + ); + } + } + + state.error = null; }, setLoading: (state, action: PayloadAction) => { @@ -190,14 +374,6 @@ const roomSlice = createSlice({ clearError: (state) => { state.error = null; }, - - clearRooms: (state) => { - state.rooms = []; - state.selectedRoom = null; - state.messages = {}; - state.error = null; - state.isLoading = false; - }, }, }); @@ -207,19 +383,19 @@ export const { updateRoom, deleteRoom, setSelectedRoom, - addMemberToRoom, - removeMemberFromRoom, - updateWhiteboardState, setMessages, addMessage, - setInitialMessages, updateMessage, deleteMessage, - clearMessages, + addSection, + updateSection, + deleteSection, + addResource, + updateResource, + deleteResource, setLoading, setError, clearError, - clearRooms, } = roomSlice.actions; export default roomSlice.reducer; diff --git a/server/controllers/resource.controller.js b/server/controllers/resource.controller.js new file mode 100644 index 0000000..8f10a36 --- /dev/null +++ b/server/controllers/resource.controller.js @@ -0,0 +1,88 @@ +const StudyRoom = require("../models/studyRoom.model.js"); + +const addResource = async (req, res) => { + try { + console.log(req.body) + const room = req.room; + const section = req.section; + const { title, link } = req.body; + + if (!title || title.trim() === "" || !link) { + return res + .status(400) + .json({ message: "Resource title and link required" }); + } + + const newResource = { + title: title.trim(), + link: link, + }; + + section.resources.push(newResource); + + await room.save(); + + const addedResource = section.resources[section.resources.length - 1]; + return res.status(201).json(addedResource); + } catch (error) { + console.error("Error adding resource to section: ", error); + return res.status(500).json({ message: "Internal server error" }); + } +}; + +const getAllResources = async (req, res) => { + try { + const resources = req.section.resources; + return res.status(200).json(resources); + } catch (error) { + console.error("Error getting resources: ", error); + return res.status(500).json({ message: "Internal server error" }); + } +}; + +const updateResource = async (req, res) => { + try { + const resourceId = req.params.resourceId; + const room = req.room; + const section = req.section; + const { title, link } = req.body; + if (!title || title.trim() === "" || !link) { + return res.status(400).json({ message: "Title and link are required" }); + } + const resource = section.resources.find((r) => r._id.equals(resourceId)); + if (!resource) { + return res.status(404).json({ message: "Resource not found" }); + } + if (title && title.trim() !== "") resource.title = title.trim(); + if (link) resource.link = link; + await room.save(); + return res.status(200).json(resource); + } catch (error) { + console.error("Error updating resource: ", error); + return res.status(500).json({ message: "Internal server error" }); + } +}; + +const deleteResource = async (req, res) => { + try { + const resourceId = req.params.resourceId; + let section = req.section; + const room = req.room; + const resource = section.resources.find((r) => r._id.equals(resourceId)); + if (!resource) { + return res.status(404).json({ message: "Resource not found" }); + } + section.resources.pull(resourceId); + await room.save(); + return res.status(200).json({ message: "Resource deleted successfully!" }); + } catch (error) { + console.error("Error deleting resource: ", error); + return res.status(500).json({ message: "Internal server error" }); + } +}; +module.exports = { + addResource, + getAllResources, + updateResource, + deleteResource, +}; diff --git a/server/controllers/section.controller.js b/server/controllers/section.controller.js new file mode 100644 index 0000000..b2aee25 --- /dev/null +++ b/server/controllers/section.controller.js @@ -0,0 +1,97 @@ +const StudyRoom = require("../models/studyRoom.model.js"); +const mongoose = require("mongoose"); +const addSection = async (req, res) => { + try { + const room = req.room; + const { title } = req.body; + if (!mongoose.Types.ObjectId.isValid(room._id)) { + return res.status(400).json({ message: "Invalid Room ID format" }); + } + const user = req.user; + if (!title || title.trim() === "") { + return res.status(400).json({ message: "Section title is required" }); + } + + const isOwner = room.owner.equals(user._id); + const memberInfo = room.members.find( + (m) => m.user && m.user.equals(user._id) + ); + const isAdmin = memberInfo?.isAdmin || false; + + if (!isOwner || !isAdmin) { + return res.status(403).json({ + message: "Permission denied: Only owner and admins are allowed", + }); + } + + const newSection = { + title: title.trim(), + resources: [], + }; + + room.resourceHub.push(newSection); + + await room.save(); + + const addedSection = room.resourceHub[room.resourceHub.length - 1]; + return res.status(201).json(addedSection); + } catch (error) { + console.error("Error creating new Section: " + error); + return res.status(500).json({ message: "Internal server error" }); + } +}; + +const getSections = async (req, res) => { + const sections = req.room.resourceHub; + return res.status(200).json(sections); +}; + +const updateSection = async (req, res) => { + try { + const sectionId = req.params.sectionId; + + const { title } = req.body; + + if (!title || title.trim() === "") { + return res.status(400).json({ message: "Title is required" }); + } + + const room = req.room; + + const section = room.resourceHub.find((st) => st._id.equals(sectionId)); + + if (!section) { + return res.status(404).json({ message: "Section not founf" }); + } + section.title = title; + + await room.save(); + + const updatedSection = room.resourceHub.find((st) => + st._id.equals(sectionId) + ); + return res.status(200).json(updatedSection); + } catch (error) { + console.error("Error updating the resources: ", error); + return res.status(500).json({ message: "Internal server error" }); + } +}; + +const deleteSection = async (req, res) => { + try { + const sectionId = req.params.sectionId; + const room = req.room; + const section = room.resourceHub.find((st) => st._id.equals(sectionId)); + if (!section) { + return res.status(404).json({ message: "Section not founf" }); + } + room.resourceHub.pull(sectionId); + await room.save(); + return res.status(200).json({ message: "Section deleted successfully!" }); + } catch (error) { + console.error("Error deleting the resources: ", error); + return res.status(500).json({ message: "Internal server error" }); + } +}; + +module.exports = { addSection, getSections, updateSection , deleteSection }; diff --git a/server/controllers/studyroom.controller.js b/server/controllers/studyroom.controller.js index b08fdd8..ad86f18 100644 --- a/server/controllers/studyroom.controller.js +++ b/server/controllers/studyroom.controller.js @@ -44,8 +44,8 @@ const getAllRooms = async (req, res) => { const rooms = await StudyRoom.find({ "members.user": userId, }) - .populate("owner", "name profileImage") - .populate("members.user", "name profileImage") + .populate("owner", "name email profileImage") + .populate("members.user", "name email profileImage") .sort({ createdAt: -1 }); return res.status(200).json(rooms); @@ -64,8 +64,8 @@ const getPublicRoom = async (req, res) => { }; const rooms = await StudyRoom.find(query) - .populate("owner", "name profileImage") - .populate("members.user", "name profileImage") + .populate("owner", "name email profileImage") + .populate("members.user", "name email profileImage") .sort({ createdAt: -1 }); return res.status(200).json(rooms); } catch (error) { @@ -105,14 +105,9 @@ const getRoomInfo = async (req, res) => { const joinPublicRoom = async (req, res) => { try { - const roomId = req.params.id; - const userId = req.user._id; - - const room = await StudyRoom.findById(roomId); + const room = req.room; + const user = req.user; - if (!room) { - return res.status(404).json({ message: "Room not found" }); - } if (room.isPrivate) { return res .status(403) @@ -120,7 +115,7 @@ const joinPublicRoom = async (req, res) => { } const alreadyMember = room.members.some((member) => - member.user.equals(userId) + member.user.equals(user._id) ); if (alreadyMember) { @@ -132,7 +127,7 @@ const joinPublicRoom = async (req, res) => { }); } - room.members.push({ user: userId, isAdmin: false }); + room.members.push({ user: user._id, isAdmin: false }); await room.save(); await room.populate("owner", "name profileImage email"); @@ -153,7 +148,7 @@ const joinPublicRoom = async (req, res) => { const updateRoomInfo = async (req, res) => { try { - const roomId = req.params.id; + const room = req.room; const { name, description, isPrivate } = req.body; @@ -162,13 +157,7 @@ const updateRoomInfo = async (req, res) => { .status(400) .json({ message: "At least one field is required" }); } - - const room = await StudyRoom.findById(roomId); - - if (!room) { - return res.status(404).json({ message: "Room not found" }); - } - + if (name && room.name !== name) { room.name = name; } @@ -191,8 +180,8 @@ const updateRoomInfo = async (req, res) => { await room.save(); let updatedRoom = await StudyRoom.findById(roomId) - .populate("owner", "name profileImage") - .populate("members.user", "name profileImage"); + .populate("owner", "name email profileImage") + .populate("members.user", "name email profileImage"); return res.status(200).json(updatedRoom); } catch (error) { diff --git a/server/middleware/validation.js b/server/middleware/authMiddleware.js similarity index 89% rename from server/middleware/validation.js rename to server/middleware/authMiddleware.js index 145e611..ba011b3 100644 --- a/server/middleware/validation.js +++ b/server/middleware/authMiddleware.js @@ -2,7 +2,7 @@ const jwt = require("jsonwebtoken"); const User = require("../models/user.model"); require("dotenv").config(); -const validate = async (req, res, next) => { +const authMiddleware = async (req, res, next) => { const token = req.cookies?.accessToken; if (!token) { @@ -27,4 +27,4 @@ const validate = async (req, res, next) => { }); }; -module.exports = validate; +module.exports = authMiddleware; diff --git a/server/middleware/roomMiddleware.js b/server/middleware/roomMiddleware.js new file mode 100644 index 0000000..53f5b86 --- /dev/null +++ b/server/middleware/roomMiddleware.js @@ -0,0 +1,20 @@ +const StudyRoom = require("../models/studyRoom.model.js"); +const mongoose = require('mongoose') +const validateRoom = async (req, res, next) => { + const roomId = req.params.id; + if (!mongoose.Types.ObjectId.isValid(roomId)) { + return res.status(400).json({ message: "Invalid Room ID format" }); + } + try { + const room = await StudyRoom.findById(roomId); + if (!room) { + return res.status(404).json({ message: "Room not found" }); + } + req.room = room; + next(); + } catch (error) { + console.error("Error validating room: ", error); + return res.status(500).json({ message: "Internal server error" }); + } +}; +module.exports = validateRoom; diff --git a/server/middleware/sectionMiddleware.js b/server/middleware/sectionMiddleware.js new file mode 100644 index 0000000..0dc5ded --- /dev/null +++ b/server/middleware/sectionMiddleware.js @@ -0,0 +1,24 @@ +const mongoose = require("mongoose"); + +const validateSection = async (req, res, next) => { + const sectionId = req.params.sectionId; + const room = req.room; + if (!mongoose.Types.ObjectId.isValid(sectionId)) { + return res.status(400).json({ message: "Invalid Section ID format" }); + } + try { + const section = room.resourceHub.find( + (s) => s._id.toString() === sectionId + ); + if (!section) { + return res.status(404).json({ message: "Section not found" }); + } + req.section = section; + next(); + } catch (error) { + console.error("Error validating Section: ", error); + return res.status(500).json({ message: "Internal server error" }); + } +}; + +module.exports = validateSection; diff --git a/server/models/section.model.js b/server/models/section.model.js index e8e8b4f..9aff51d 100644 --- a/server/models/section.model.js +++ b/server/models/section.model.js @@ -9,4 +9,4 @@ const sectionSchema = new mongoose.Schema({ resources: [resourceSchema], }); -module.exports = sectionSchema; \ No newline at end of file +module.exports = sectionSchema; diff --git a/server/routers/message.route.js b/server/routers/message.route.js index 6da43a7..cb4df54 100644 --- a/server/routers/message.route.js +++ b/server/routers/message.route.js @@ -1,9 +1,9 @@ const express = require("express"); -const validate = require("../middleware/validation"); +const authMiddleware = require("../middleware/authMiddleware"); const { getMessagesForRoom } = require("../controllers/message.controller"); const router = express.Router(); -router.get("/:id", validate, getMessagesForRoom); +router.get("/:id", authMiddleware, getMessagesForRoom); module.exports = router; \ No newline at end of file diff --git a/server/routers/resource.route.js b/server/routers/resource.route.js new file mode 100644 index 0000000..1f2fba2 --- /dev/null +++ b/server/routers/resource.route.js @@ -0,0 +1,24 @@ +const express = require("express"); + +const router = express.Router({ mergeParams: true }); + +const { + addResource, + getAllResources, + updateResource, + deleteResource, +} = require("../controllers/resource.controller"); + +const validateSection = require("../middleware/sectionMiddleware"); + +router.use(validateSection); + +router.post("/create", addResource); + +router.get("/", getAllResources); + +router.put("/:resourceId", updateResource); + +router.delete("/:resourceId", deleteResource); + +module.exports = router; diff --git a/server/routers/room.route.js b/server/routers/room.route.js index f68e388..88f2991 100644 --- a/server/routers/room.route.js +++ b/server/routers/room.route.js @@ -1,5 +1,5 @@ const express = require("express"); -const validate = require("../middleware/validation"); +const authMiddleware = require("../middleware/authMiddleware.js"); const { createStudyRoom, getAllRooms, @@ -10,26 +10,32 @@ const { deleteRoom, } = require("../controllers/studyroom.controller"); const upload = require("../config/multer"); +const sectionRoutes = require("./section.route.js"); +const validateRoom = require("../middleware/roomMiddleware.js"); const router = express.Router(); -router.post("/create", validate, createStudyRoom); +router.use(authMiddleware); -router.delete("/:id" , validate , deleteRoom) +router.get("/public", getPublicRoom); -router.get("/", validate, getAllRooms); +router.post("/create", createStudyRoom); -router.get("/public", validate, getPublicRoom); +router.get("/", getAllRooms); -router.get("/:id", validate, getRoomInfo); +router.use("/:id/sections", validateRoom, sectionRoutes); -router.post("/join/:id", validate, joinPublicRoom); +router.get("/:id", getRoomInfo); -router.post( - "/update/:id", - validate, +router.post("/:id/join", validateRoom, joinPublicRoom); + +router.put( + "/:id/update", + validateRoom, upload.single("roomImage"), updateRoomInfo ); +router.delete("/:id", validateRoom, deleteRoom); + module.exports = router; diff --git a/server/routers/section.route.js b/server/routers/section.route.js new file mode 100644 index 0000000..d8d7d3e --- /dev/null +++ b/server/routers/section.route.js @@ -0,0 +1,21 @@ +const express = require("express"); +const { + addSection, + getSections, + updateSection, + deleteSection, +} = require("../controllers/section.controller"); +const resourceRoutes = require("./resource.route"); +const router = express.Router({ mergeParams: true }); + +router.get("/", getSections); + +router.post("/create", addSection); + +router.put("/:sectionId", updateSection); + +router.delete("/:sectionId", deleteSection); + +router.use("/:sectionId", resourceRoutes); + +module.exports = router; diff --git a/server/routers/user.route.js b/server/routers/user.route.js index 09cd9d9..a925f0b 100644 --- a/server/routers/user.route.js +++ b/server/routers/user.route.js @@ -6,16 +6,17 @@ const { changePassword, changePasswordWithCurrent, } = require("../controllers/user.controller"); -const validate = require("../middleware/validation"); +const authMiddleware = require("../middleware/authMiddleware"); const router = express.Router(); -router.post("/update", validate, upload.single("profileImage"), updateUser); -router.get("/profile", validate, getProfile); -router.post("/change-password", changePassword); -router.post( +router.use(authMiddleware) + +router.put("/update", upload.single("profileImage"), updateUser); +router.get("/profile", getProfile); +router.put("/change-password", changePassword); +router.put( "/change-password-with-current", - validate, changePasswordWithCurrent ); module.exports = router;