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 * @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"; import { auth } from "@/auth";
@ -12,16 +12,9 @@ import { AppSidebar } from "@/components/layout/AppSidebar";
import { MemberSidebar } from "@/components/layout/MemberSidebar"; import { MemberSidebar } from "@/components/layout/MemberSidebar";
import { ServerProvider } from "@/lib/context/ServerContext"; import { ServerProvider } from "@/lib/context/ServerContext";
import { redirect } from "next/navigation"; 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. */
* 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.
*/
export default async function AppLayout({ export default async function AppLayout({
children, children,
}: { }: {
@ -79,6 +72,11 @@ export default async function AppLayout({
}; };
}); });
// Loading Friendships data
const [friendships] = await Promise.all([
getUserFriendships(session.user.id),
]);
return ( return (
<ServerProvider> <ServerProvider>
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground"> <div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
@ -88,7 +86,10 @@ export default async function AppLayout({
user={currentUser} user={currentUser}
/> />
<div className="flex-1 flex min-w-0">{children}</div> <div className="flex-1 flex min-w-0">{children}</div>
<MemberSidebar /> <MemberSidebar
currentUserId={currentUserId}
userFriendships={friendships}
/>
</div> </div>
</ServerProvider> </ServerProvider>
); );

View file

@ -1,6 +1,6 @@
/** /**
* @file components/layout/MemberSidebar.tsx * @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"; "use client";
@ -9,28 +9,27 @@ import { useEffect, useRef, useState } from "react";
import { useSidebarStore } from "@/lib/stores/useSidebarStore"; import { useSidebarStore } from "@/lib/stores/useSidebarStore";
import { useActiveServer } from "@/lib/context/ServerContext"; import { useActiveServer } from "@/lib/context/ServerContext";
import { MemberList } from "@/components/members/MemberList"; import { MemberList } from "@/components/members/MemberList";
import type { Member } from "@/components/members/MemberItem";
import { X, Loader2 } from "lucide-react"; import { X, Loader2 } from "lucide-react";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { User } from "@/db/schema";
/** /** Properties for the MemberHeader component. */
* 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.
*/
interface MemberHeaderProps { interface MemberHeaderProps {
title: string; title: string;
onClose: () => void; onClose: () => void;
} }
/** /** Properties for the MemberSidebar component. */
* Renders the header section of the member sidebar with a title and close button. interface MemberSidebarProps {
* currentUserId: string;
* @param {MemberHeaderProps} props - Component properties. userFriendships: Array<{
* @returns {JSX.Element} The rendered member header element. 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) { function MemberHeader({ title, onClose }: MemberHeaderProps) {
return ( return (
<div className="h-14 border-b border-surface/50 flex items-center justify-between px-4 shrink-0"> <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. */
* Displays the list of members for the active server in a responsive sidebar or drawer layout. export function MemberSidebar({
* currentUserId,
* @returns {JSX.Element} The rendered member sidebar component. userFriendships = [],
*/ }: MemberSidebarProps) {
export function MemberSidebar() {
const { isMembersOpen, closeMembers } = useSidebarStore(); const { isMembersOpen, closeMembers } = useSidebarStore();
const { activeServer } = useActiveServer(); const { activeServer } = useActiveServer();
const desktopSidebarRef = useRef<HTMLElement>(null); const desktopSidebarRef = useRef<HTMLElement>(null);
const [members, setMembers] = useState<Member[]>([]); const [members, setMembers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
useEffect(() => { useEffect(() => {
@ -78,7 +76,7 @@ export function MemberSidebar() {
}); });
if (res.ok) { if (res.ok) {
const data: Member[] = await res.json(); const data: User[] = await res.json();
setMembers(data); setMembers(data);
} }
} catch (err: unknown) { } catch (err: unknown) {
@ -117,11 +115,7 @@ export function MemberSidebar() {
return () => document.removeEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isMembersOpen, closeMembers]); }, [isMembersOpen, closeMembers]);
/** /** Renders either a loading spinner or the populated member list depending on state. */
* Renders either a loading spinner or the populated member list depending on state.
*
* @returns {JSX.Element} The active content component.
*/
const renderContent = () => { const renderContent = () => {
if (isLoading && members.length === 0) { if (isLoading && members.length === 0) {
return ( return (
@ -130,7 +124,13 @@ export function MemberSidebar() {
</div> </div>
); );
} }
return <MemberList members={members} />; return (
<MemberList
members={members}
currentUserId={currentUserId}
userFriendships={userFriendships}
/>
);
}; };
return ( return (

View file

@ -1,60 +1,155 @@
/** /**
* @file components/members/MemberItem.tsx * @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 { clsx } from "clsx";
import { UserAvatar } from "../ui/UserAvatar"; import { UserAvatar } from "../ui/UserAvatar";
import { UserProfilePopover } from "../ui/UserProfilePopover";
import { useActiveServer } from "@/lib/context/ServerContext";
import { useSidebarStore } from "@/lib/stores/useSidebarStore";
/** /** Props for the MemberItem component. */
* 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.
*/
interface MemberItemProps { interface MemberItemProps {
member: Member; member: User;
currentUserId: string;
userFriendships: Array<{
senderId: string;
receiverId: string;
status: string;
}>;
isOffline?: boolean; isOffline?: boolean;
} }
/** /** Renders an individual member item with an interactive user profile popover. */
* MemberItem component to display a user's avatar, online status indicator, and username. export function MemberItem({
* member,
* @param {MemberItemProps} props - Component properties. currentUserId,
* @returns {JSX.Element} The rendered member item. userFriendships = [],
*/ isOffline = false,
export function MemberItem({ member, isOffline = false }: MemberItemProps) { }: 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 ( return (
<div <div ref={containerRef} className="relative">
className={clsx( <button
"flex items-center gap-2.5 px-2 py-1.5 rounded-md hover:bg-background/50 transition-colors cursor-pointer group", ref={triggerRef}
isOffline && "opacity-60", type="button"
onClick={() => setIsProfileOpen((prev) => !prev)}
className={clsx(
"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",
)}
>
<div className="relative shrink-0">
<UserAvatar user={member} size="md" />
</div>
<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 className="relative shrink-0">
<UserAvatar user={member} size="md" />
</div>
<span className="text-sm font-medium text-muted group-hover:text-white truncate">
{member.username}
</span>
</div> </div>
); );
} }

View file

@ -1,19 +1,29 @@
/** /**
* @file components/members/MemberList.tsx * @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 { useMemo } from "react";
import { MemberItem, type Member } from "@/components/members/MemberItem"; import { MemberItem } from "@/components/members/MemberItem";
import { User } from "@/db/schema";
/** /** Props for the MemberList component. */
* Renders categorized lists of online and offline members with user counters. interface MemberListProps {
* members: User[];
* @param {Object} props - Component properties. currentUserId: string;
* @param {Member[]} [props.members=[]] - Array of member objects to group and render. userFriendships: Array<{
* @returns {JSX.Element} The rendered member list container. senderId: string;
*/ receiverId: string;
export function MemberList({ members = [] }: { members?: Member[] }) { status: string;
}>;
}
/** Renders online and offline community members in distinct sections. */
export function MemberList({
members = [],
currentUserId,
userFriendships = [],
}: MemberListProps) {
const { onlineMembers, offlineMembers } = useMemo(() => { const { onlineMembers, offlineMembers } = useMemo(() => {
return { return {
onlineMembers: members.filter((m) => m.status !== "OFFLINE"), onlineMembers: members.filter((m) => m.status !== "OFFLINE"),
@ -30,7 +40,12 @@ export function MemberList({ members = [] }: { members?: Member[] }) {
</h2> </h2>
<div className="space-y-0.5"> <div className="space-y-0.5">
{onlineMembers.map((member) => ( {onlineMembers.map((member) => (
<MemberItem key={member.id} member={member} /> <MemberItem
key={member.id}
member={member}
currentUserId={currentUserId}
userFriendships={userFriendships}
/>
))} ))}
{onlineMembers.length === 0 && ( {onlineMembers.length === 0 && (
<p className="text-xs text-muted/60 px-2 italic"> <p className="text-xs text-muted/60 px-2 italic">
@ -47,7 +62,13 @@ export function MemberList({ members = [] }: { members?: Member[] }) {
</h2> </h2>
<div className="space-y-0.5"> <div className="space-y-0.5">
{offlineMembers.map((member) => ( {offlineMembers.map((member) => (
<MemberItem key={member.id} member={member} isOffline /> <MemberItem
key={member.id}
member={member}
currentUserId={currentUserId}
userFriendships={userFriendships}
isOffline
/>
))} ))}
</div> </div>
</div> </div>