feat(members): integrate UserProfilePopover into MemberSidebar
All checks were successful
Deploy Waveform to VPS / deploy (push) Successful in 1m9s

This commit is contained in:
Chneemann 2026-09-09 09:29:19 +02:00
parent eafe17c8c9
commit e2985c53a2
No known key found for this signature in database
4 changed files with 213 additions and 96 deletions

View file

@ -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<JSX.Element>} 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 (
<ServerProvider>
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
@ -88,7 +86,10 @@ export default async function AppLayout({
user={currentUser}
/>
<div className="flex-1 flex min-w-0">{children}</div>
<MemberSidebar />
<MemberSidebar
currentUserId={currentUserId}
userFriendships={friendships}
/>
</div>
</ServerProvider>
);

View file

@ -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 (
<div className="h-14 border-b border-surface/50 flex items-center justify-between px-4 shrink-0">
@ -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<HTMLElement>(null);
const [members, setMembers] = useState<Member[]>([]);
const [members, setMembers] = useState<User[]>([]);
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() {
</div>
);
}
return <MemberList members={members} />;
return (
<MemberList
members={members}
currentUserId={currentUserId}
userFriendships={userFriendships}
/>
);
};
return (

View file

@ -1,51 +1,134 @@
/**
* @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<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(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 (
<div
<div ref={containerRef} className="relative">
<button
ref={triggerRef}
type="button"
onClick={() => setIsProfileOpen((prev) => !prev)}
className={clsx(
"flex items-center gap-2.5 px-2 py-1.5 rounded-md hover:bg-background/50 transition-colors cursor-pointer group",
"w-full flex items-center gap-2.5 px-2 py-1.5 rounded-md hover:bg-background/50 transition-colors cursor-pointer group text-left focus:outline-none",
isOffline && "opacity-60",
)}
>
@ -55,6 +138,18 @@ export function MemberItem({ member, isOffline = false }: MemberItemProps) {
<span className="text-sm font-medium text-muted group-hover:text-white truncate">
{member.username}
</span>
</button>
{isProfileOpen && (
<UserProfilePopover
user={member}
currentUserId={currentUserId}
triggerRef={triggerRef}
friendshipStatus={getFriendshipStatus()}
onDirectMessage={handleStartConversation}
onAddFriend={handleAddFriend}
/>
)}
</div>
);
}

View file

@ -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[] }) {
</h2>
<div className="space-y-0.5">
{onlineMembers.map((member) => (
<MemberItem key={member.id} member={member} />
<MemberItem
key={member.id}
member={member}
currentUserId={currentUserId}
userFriendships={userFriendships}
/>
))}
{onlineMembers.length === 0 && (
<p className="text-xs text-muted/60 px-2 italic">
@ -47,7 +62,13 @@ export function MemberList({ members = [] }: { members?: Member[] }) {
</h2>
<div className="space-y-0.5">
{offlineMembers.map((member) => (
<MemberItem key={member.id} member={member} isOffline />
<MemberItem
key={member.id}
member={member}
currentUserId={currentUserId}
userFriendships={userFriendships}
isOffline
/>
))}
</div>
</div>