diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 5508dd4..9e1fffd 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -1,6 +1,6 @@ /** * @file app/(app)/layout.tsx - * @description Main application layout component that handles user authentication guards, parallel data loading for servers and direct messages, and wraps the app with navigation sidebars and server context. + * @description Main application layout component that handles authentication, parallel data fetching for servers and conversations, and global sidebar state. */ import { auth } from "@/auth"; @@ -12,16 +12,9 @@ import { AppSidebar } from "@/components/layout/AppSidebar"; import { MemberSidebar } from "@/components/layout/MemberSidebar"; import { ServerProvider } from "@/lib/context/ServerContext"; import { redirect } from "next/navigation"; +import { getUserFriendships } from "@/lib/services/friends.service"; -/** - * Renders the primary application layout with authentication checks, database fetching, and sidebar structure. - * - * @async - * @function AppLayout - * @param {Object} props - The layout properties. - * @param {React.ReactNode} props.children - The nested child route content to be rendered within the layout. - * @returns {Promise} The rendered application layout container. - */ +/** Renders the primary application layout with authentication checks, database fetching, and sidebar structure. */ export default async function AppLayout({ children, }: { @@ -79,6 +72,11 @@ export default async function AppLayout({ }; }); + // Loading Friendships data + const [friendships] = await Promise.all([ + getUserFriendships(session.user.id), + ]); + return (
@@ -88,7 +86,10 @@ export default async function AppLayout({ user={currentUser} />
{children}
- +
); diff --git a/components/layout/MemberSidebar.tsx b/components/layout/MemberSidebar.tsx index fee7145..990a932 100644 --- a/components/layout/MemberSidebar.tsx +++ b/components/layout/MemberSidebar.tsx @@ -1,6 +1,6 @@ /** * @file components/layout/MemberSidebar.tsx - * @description Layout sidebar container handling member fetching, responsive drawers, and click-outside dismissal. + * @description Member sidebar component displaying server members with desktop sliding panel and mobile drawer functionality. */ "use client"; @@ -9,28 +9,27 @@ import { useEffect, useRef, useState } from "react"; import { useSidebarStore } from "@/lib/stores/useSidebarStore"; import { useActiveServer } from "@/lib/context/ServerContext"; import { MemberList } from "@/components/members/MemberList"; -import type { Member } from "@/components/members/MemberItem"; import { X, Loader2 } from "lucide-react"; import { clsx } from "clsx"; +import { User } from "@/db/schema"; -/** - * Properties for the MemberHeader component. - * - * @interface MemberHeaderProps - * @property {string} title - The tooltip or accessibility title for the close button. - * @property {() => void} onClose - Callback function triggered when the close button is clicked. - */ +/** Properties for the MemberHeader component. */ interface MemberHeaderProps { title: string; onClose: () => void; } -/** - * Renders the header section of the member sidebar with a title and close button. - * - * @param {MemberHeaderProps} props - Component properties. - * @returns {JSX.Element} The rendered member header element. - */ +/** Properties for the MemberSidebar component. */ +interface MemberSidebarProps { + currentUserId: string; + userFriendships: Array<{ + senderId: string; + receiverId: string; + status: string; + }>; +} + +/** Renders the header section of the member sidebar with a title and close button. */ function MemberHeader({ title, onClose }: MemberHeaderProps) { return (
@@ -49,17 +48,16 @@ function MemberHeader({ title, onClose }: MemberHeaderProps) { ); } -/** - * Displays the list of members for the active server in a responsive sidebar or drawer layout. - * - * @returns {JSX.Element} The rendered member sidebar component. - */ -export function MemberSidebar() { +/** Displays the list of members for the active server in a responsive sidebar or drawer layout. */ +export function MemberSidebar({ + currentUserId, + userFriendships = [], +}: MemberSidebarProps) { const { isMembersOpen, closeMembers } = useSidebarStore(); const { activeServer } = useActiveServer(); const desktopSidebarRef = useRef(null); - const [members, setMembers] = useState([]); + const [members, setMembers] = useState([]); const [isLoading, setIsLoading] = useState(false); useEffect(() => { @@ -78,7 +76,7 @@ export function MemberSidebar() { }); if (res.ok) { - const data: Member[] = await res.json(); + const data: User[] = await res.json(); setMembers(data); } } catch (err: unknown) { @@ -117,11 +115,7 @@ export function MemberSidebar() { return () => document.removeEventListener("mousedown", handleClickOutside); }, [isMembersOpen, closeMembers]); - /** - * Renders either a loading spinner or the populated member list depending on state. - * - * @returns {JSX.Element} The active content component. - */ + /** Renders either a loading spinner or the populated member list depending on state. */ const renderContent = () => { if (isLoading && members.length === 0) { return ( @@ -130,7 +124,13 @@ export function MemberSidebar() {
); } - return ; + return ( + + ); }; return ( diff --git a/components/members/MemberItem.tsx b/components/members/MemberItem.tsx index 4b66387..06d9968 100644 --- a/components/members/MemberItem.tsx +++ b/components/members/MemberItem.tsx @@ -1,60 +1,155 @@ /** * @file components/members/MemberItem.tsx - * @description Single member item displaying avatar, username, and status badge. + * @description Member item component rendering user details with popover toggle for profile views and direct actions. */ -import type { UserStatus } from "@/db/schema"; +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import type { User } from "@/db/schema"; import { clsx } from "clsx"; import { UserAvatar } from "../ui/UserAvatar"; +import { UserProfilePopover } from "../ui/UserProfilePopover"; +import { useActiveServer } from "@/lib/context/ServerContext"; +import { useSidebarStore } from "@/lib/stores/useSidebarStore"; -/** - * Interface representing a member user. - * - * @interface Member - * @property {string} id - Unique identifier for the member. - * @property {string} username - Display name of the member. - * @property {string} [color] - Color identifier for the member avatar. - * @property {UserStatus} status - Current online status of the member. - */ -export interface Member { - id: string; - username: string; - color: string; - status: UserStatus; -} - -/** - * Properties for the MemberItem component. - * - * @interface MemberItemProps - * @property {Member} member - The member object containing user details. - * @property {boolean} [isOffline=false] - Optional flag indicating whether the member should be displayed as offline. - */ +/** Props for the MemberItem component. */ interface MemberItemProps { - member: Member; + member: User; + currentUserId: string; + userFriendships: Array<{ + senderId: string; + receiverId: string; + status: string; + }>; isOffline?: boolean; } -/** - * MemberItem component to display a user's avatar, online status indicator, and username. - * - * @param {MemberItemProps} props - Component properties. - * @returns {JSX.Element} The rendered member item. - */ -export function MemberItem({ member, isOffline = false }: MemberItemProps) { +/** Renders an individual member item with an interactive user profile popover. */ +export function MemberItem({ + member, + currentUserId, + userFriendships = [], + isOffline = false, +}: MemberItemProps) { + const router = useRouter(); + const { setActiveServer } = useActiveServer(); + const { closeMembers } = useSidebarStore(); + + const [isProfileOpen, setIsProfileOpen] = useState(false); + const containerRef = useRef(null); + const triggerRef = useRef(null); + + // Close popover on click outside or ESC key press + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setIsProfileOpen(false); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setIsProfileOpen(false); + } + }; + + if (isProfileOpen) { + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("keydown", handleKeyDown); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [isProfileOpen]); + + /** Determines the friendship status between the logged-in user and this member. */ + const getFriendshipStatus = () => { + if (!member.id || member.id === currentUserId || !userFriendships) { + return null; + } + + const friendship = userFriendships.find( + (f) => + (f.senderId === member.id && f.receiverId === currentUserId) || + (f.receiverId === member.id && f.senderId === currentUserId), + ); + + return friendship ? friendship.status : null; + }; + + /** Initiates or navigates to a direct message conversation with the target member. */ + const handleStartConversation = async (recipientId: string) => { + try { + const res = await fetch("/api/dm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ recipientId }), + }); + + if (res.ok) { + const conversation = await res.json(); + setActiveServer(null); + closeMembers(); + router.push(`/dm/${conversation.id}`); + } + } catch (error) { + console.error("Failed to start conversation:", error); + } + }; + + /** Sends a friend request to the specified username. */ + const handleAddFriend = async (username: string) => { + try { + const res = await fetch("/api/friends", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username }), + }); + + if (res.ok) { + router.refresh(); + } + } catch (error) { + console.error("Error sending friend request:", error); + } + }; + return ( -
+ + + {isProfileOpen && ( + )} - > -
- -
- - {member.username} -
); } diff --git a/components/members/MemberList.tsx b/components/members/MemberList.tsx index d71bae6..345de73 100644 --- a/components/members/MemberList.tsx +++ b/components/members/MemberList.tsx @@ -1,19 +1,29 @@ /** * @file components/members/MemberList.tsx - * @description Renders categorized online and offline member lists. + * @description Renders categorized lists of online and offline members with their friendship statuses. */ import { useMemo } from "react"; -import { MemberItem, type Member } from "@/components/members/MemberItem"; +import { MemberItem } from "@/components/members/MemberItem"; +import { User } from "@/db/schema"; -/** - * Renders categorized lists of online and offline members with user counters. - * - * @param {Object} props - Component properties. - * @param {Member[]} [props.members=[]] - Array of member objects to group and render. - * @returns {JSX.Element} The rendered member list container. - */ -export function MemberList({ members = [] }: { members?: Member[] }) { +/** Props for the MemberList component. */ +interface MemberListProps { + members: User[]; + currentUserId: string; + userFriendships: Array<{ + senderId: string; + receiverId: string; + status: string; + }>; +} + +/** Renders online and offline community members in distinct sections. */ +export function MemberList({ + members = [], + currentUserId, + userFriendships = [], +}: MemberListProps) { const { onlineMembers, offlineMembers } = useMemo(() => { return { onlineMembers: members.filter((m) => m.status !== "OFFLINE"), @@ -30,7 +40,12 @@ export function MemberList({ members = [] }: { members?: Member[] }) {
{onlineMembers.map((member) => ( - + ))} {onlineMembers.length === 0 && (

@@ -47,7 +62,13 @@ export function MemberList({ members = [] }: { members?: Member[] }) {

{offlineMembers.map((member) => ( - + ))}