feat(members): integrate UserProfilePopover into MemberSidebar
All checks were successful
Deploy Waveform to VPS / deploy (push) Successful in 1m9s
All checks were successful
Deploy Waveform to VPS / deploy (push) Successful in 1m9s
This commit is contained in:
parent
eafe17c8c9
commit
e2985c53a2
4 changed files with 213 additions and 96 deletions
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue