feat(members): implement online status and member sidebar with API integration

This commit is contained in:
Chneemann 2026-08-30 13:09:55 +02:00
parent cc8677ae37
commit 08513015d3
No known key found for this signature in database
11 changed files with 463 additions and 196 deletions

View file

@ -6,7 +6,7 @@
import { auth } from "@/auth";
import { getUserServers } from "@/lib/services/server.service";
import { AppSidebar } from "@/components/layout/AppSidebar";
import { MemberDrawer } from "@/components/layout/MemberDrawer";
import { MemberSidebar } from "@/components/layout/MemberSidebar";
import { ServerProvider } from "@/lib/context/ServerContext";
import { redirect } from "next/navigation";
@ -36,7 +36,7 @@ export default async function AppLayout({
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
<AppSidebar servers={userServers} />
<div className="flex-1 flex min-w-0">{children}</div>
<MemberDrawer />
<MemberSidebar />
</div>
</ServerProvider>
);

View file

@ -0,0 +1,31 @@
/**
* @file app/api/servers/[serverId]/members/route.ts
* @description API route handler for retrieving all members belonging to a specific server.
*/
import { NextResponse } from "next/server";
import { getServerMembers } from "@/lib/services/member.service";
/**
* Handles HTTP GET requests to fetch members of a server.
*
* @param {Request} req - The incoming HTTP request object.
* @param {Object} context - Route context parameters.
* @param {Promise<{ serverId: string }>} context.params - Asynchronous route parameters containing the serverId.
* @returns {Promise<NextResponse>} JSON response containing the list of server members or an error message.
*/
export async function GET(
req: Request,
{ params }: { params: Promise<{ serverId: string }> },
) {
try {
const { serverId } = await params;
const members = await getServerMembers(serverId);
return NextResponse.json(members);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch server members" },
{ status: 500 },
);
}
}

View file

@ -1,112 +0,0 @@
/**
* @file components/layout/MemberDrawer.tsx
* @description Responsive drawer and sidebar component that manages the visibility, mobile overlay, click-outside dismissal, and rendering of the member list.
*/
"use client";
import { useEffect, useRef } from "react";
import { useSidebarStore } from "@/lib/stores/useSidebarStore";
import { MemberSidebar } from "@/components/sidebar/MemberSidebar";
import { X } from "lucide-react";
import { clsx } from "clsx";
/**
* Renders the desktop sidebar panel and mobile drawer for displaying channel members, handling state triggers and click-outside closing logic.
*
* @returns {JSX.Element} The rendered member drawer component for desktop and mobile views.
*/
export function MemberDrawer() {
const { isMembersOpen, closeMembers } = useSidebarStore();
const desktopSidebarRef = useRef<HTMLElement>(null);
useEffect(() => {
/**
* Handles mouse click events outside of the desktop sidebar to close it.
*
* @param {MouseEvent} event - The native DOM mouse event.
*/
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (target.closest('button[title*="Mitgliederliste"]')) return;
if (
isMembersOpen &&
desktopSidebarRef.current &&
!desktopSidebarRef.current.contains(target)
) {
closeMembers();
}
}
if (isMembersOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isMembersOpen, closeMembers]);
/**
* Internal header component for the member drawer containing title labeling and close action button.
*
* @param {Object} props - The component props.
* @param {string} props.title - The tooltip title for the close button.
* @returns {JSX.Element} The header element.
*/
const Header = ({ title }: { title: string }) => (
<div className="h-14 border-b border-surface/50 flex items-center justify-between px-4 shrink-0">
<span className="font-semibold text-xs text-muted uppercase tracking-wider">
Members
</span>
<button
type="button"
onClick={closeMembers}
className="p-1.5 rounded-md text-muted hover:text-white hover:bg-background transition-colors cursor-pointer"
title={title}
>
<X className="w-5 h-5" />
</button>
</div>
);
return (
<>
{/* Desktop View */}
<aside
ref={desktopSidebarRef}
className={clsx(
"hidden md:flex flex-col h-full bg-surface shrink-0 transition-all duration-300 ease-in-out overflow-hidden border-l border-surface/50",
isMembersOpen
? "w-60 opacity-100"
: "w-0 opacity-0 pointer-events-none border-l-0",
)}
>
<div className="w-60 flex flex-col h-full">
<Header title="Mitgliederliste einklappen" />
<div className="flex-1 overflow-y-auto">
<MemberSidebar />
</div>
</div>
</aside>
{/* Mobile Backdrop & Drawer */}
{isMembersOpen && (
<div
className="fixed inset-0 bg-black/60 z-30 md:hidden"
onClick={closeMembers}
/>
)}
<div
className={clsx(
"fixed inset-y-0 right-0 z-40 flex flex-col h-full w-screen sm:w-64 bg-surface transition-transform duration-200 ease-in-out md:hidden shadow-2xl border-l border-surface/50",
isMembersOpen ? "translate-x-0" : "translate-x-full",
)}
>
<Header title="Schließen" />
<div className="flex-1 overflow-y-auto">
<MemberSidebar />
</div>
</div>
</>
);
}

View file

@ -0,0 +1,176 @@
/**
* @file components/layout/MemberSidebar.tsx
* @description Layout sidebar container handling member fetching, responsive drawers, and click-outside dismissal.
*/
"use client";
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";
/**
* 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 {
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.
*/
function MemberHeader({ title, onClose }: MemberHeaderProps) {
return (
<div className="h-14 border-b border-surface/50 flex items-center justify-between px-4 shrink-0">
<span className="font-semibold text-xs text-muted uppercase tracking-wider">
Members
</span>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-md text-muted hover:text-white hover:bg-background transition-colors cursor-pointer"
title={title}
>
<X className="w-5 h-5" />
</button>
</div>
);
}
/**
* 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() {
const { isMembersOpen, closeMembers } = useSidebarStore();
const { activeServer } = useActiveServer();
const desktopSidebarRef = useRef<HTMLElement>(null);
const [members, setMembers] = useState<Member[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (!activeServer?.id) {
setMembers([]);
return;
}
const controller = new AbortController();
async function fetchMembers() {
try {
setIsLoading(true);
const res = await fetch(`/api/servers/${activeServer?.id}/members`, {
signal: controller.signal,
});
if (res.ok) {
const data: Member[] = await res.json();
setMembers(data);
}
} catch (err: unknown) {
if (err instanceof Error && err.name !== "AbortError") {
console.error("Error loading members:", err);
}
} finally {
setIsLoading(false);
}
}
fetchMembers();
return () => {
controller.abort();
};
}, [activeServer?.id]);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (target.closest('button[title*="Mitgliederliste"]')) return;
if (
isMembersOpen &&
desktopSidebarRef.current &&
!desktopSidebarRef.current.contains(target)
) {
closeMembers();
}
}
if (isMembersOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
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.
*/
const renderContent = () => {
if (isLoading && members.length === 0) {
return (
<div className="flex items-center justify-center p-8 text-muted">
<Loader2 className="w-5 h-5 animate-spin" />
</div>
);
}
return <MemberList members={members} />;
};
return (
<>
{/* Desktop View */}
<aside
ref={desktopSidebarRef}
className={clsx(
"hidden md:flex flex-col h-full bg-surface shrink-0 transition-all duration-300 ease-in-out overflow-hidden border-l border-surface/50",
isMembersOpen
? "w-60 opacity-100"
: "w-0 opacity-0 pointer-events-none border-l-0",
)}
>
<div className="w-60 flex flex-col h-full">
<MemberHeader
title="Mitgliederliste einklappen"
onClose={closeMembers}
/>
<div className="flex-1 overflow-y-auto">{renderContent()}</div>
</div>
</aside>
{/* Mobile Backdrop & Drawer */}
{isMembersOpen && (
<div
className="fixed inset-0 bg-black/60 z-30 md:hidden"
onClick={closeMembers}
/>
)}
<div
className={clsx(
"fixed inset-y-0 right-0 z-40 flex flex-col h-full w-screen sm:w-64 bg-surface transition-transform duration-200 ease-in-out md:hidden shadow-2xl border-l border-surface/50",
isMembersOpen ? "translate-x-0" : "translate-x-full",
)}
>
<MemberHeader title="Schließen" onClose={closeMembers} />
<div className="flex-1 overflow-y-auto">{renderContent()}</div>
</div>
</>
);
}

View file

@ -0,0 +1,79 @@
/**
* @file components/members/MemberItem.tsx
* @description Single member item displaying avatar, username, and status badge.
*/
import type { UserStatus } from "@/db/schema";
import {
MEMBER_COLOR_CLASSES,
MEMBER_STATUS_COLOR_CLASSES,
} from "@/lib/constants/member.styles";
import { clsx } from "clsx";
/**
* 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 {
member: Member;
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) {
const userBg = member.color && MEMBER_COLOR_CLASSES[member.color];
const statusBg = MEMBER_STATUS_COLOR_CLASSES[member.status];
return (
<div
className={clsx(
"flex items-center gap-2.5 px-2 py-1.5 rounded-md hover:bg-background/50 transition-colors cursor-pointer group",
isOffline && "opacity-60",
)}
>
<div className="relative shrink-0">
<div
className={clsx(
"w-8 h-8 rounded-full flex items-center justify-center font-bold text-xs text-white",
userBg,
)}
>
{member.username ? member.username.charAt(0).toUpperCase() : "?"}
</div>
<span
className={clsx(
"absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full ring-2 ring-surface",
statusBg,
)}
/>
</div>
<span className="text-sm font-medium text-muted group-hover:text-white truncate">
{member.username || "Unbekannt"}
</span>
</div>
);
}

View file

@ -0,0 +1,56 @@
/**
* @file components/members/MemberList.tsx
* @description Renders categorized online and offline member lists.
*/
import { useMemo } from "react";
import { MemberItem, type Member } from "@/components/members/MemberItem";
/**
* 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[] }) {
const { onlineMembers, offlineMembers } = useMemo(() => {
return {
onlineMembers: members.filter((m) => m.status !== "OFFLINE"),
offlineMembers: members.filter((m) => m.status === "OFFLINE"),
};
}, [members]);
return (
<div className="w-full bg-surface p-3 shrink-0 h-full overflow-y-auto space-y-4">
{/* Online Section */}
<div>
<h2 className="text-xs font-semibold text-muted uppercase tracking-wider px-2 mb-1.5">
Online {onlineMembers.length}
</h2>
<div className="space-y-0.5">
{onlineMembers.map((member) => (
<MemberItem key={member.id} member={member} />
))}
{onlineMembers.length === 0 && (
<p className="text-xs text-muted/60 px-2 italic">
No members online
</p>
)}
</div>
</div>
{/* Offline Section */}
<div>
<h2 className="text-xs font-semibold text-muted uppercase tracking-wider px-2 mb-1.5">
Offline {offlineMembers.length}
</h2>
<div className="space-y-0.5">
{offlineMembers.map((member) => (
<MemberItem key={member.id} member={member} isOffline />
))}
</div>
</div>
</div>
);
}

View file

@ -1,67 +0,0 @@
/**
* @file components/sidebar/MemberSidebar.tsx
* @description Sidebar component displaying lists of online and offline channel members with avatar and status indicators.
*/
/**
* Renders the member sidebar showing categorized online and offline user statuses.
*
* @returns {JSX.Element} The rendered member sidebar interface.
*/
export function MemberSidebar() {
return (
<div className="w-full bg-surface p-3 shrink-0 h-full overflow-y-auto space-y-4">
{/* Online Section */}
<div>
<h2 className="text-xs font-semibold text-muted uppercase tracking-wider px-2 mb-1.5">
Online 1
</h2>
<div className="space-y-0.5">
<div className="flex items-center gap-2.5 px-2 py-1.5 rounded-md hover:bg-background/50 transition-colors cursor-pointer group">
<div className="relative shrink-0">
<div className="w-8 h-8 rounded-full bg-indigo-500 flex items-center justify-center font-bold text-xs text-white">
U
</div>
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-emerald-500 ring-2 ring-surface" />
</div>
<span className="text-sm font-medium text-muted group-hover:text-white truncate">
User 1
</span>
</div>
</div>
</div>
{/* Offline Section */}
<div>
<h2 className="text-xs font-semibold text-muted uppercase tracking-wider px-2 mb-1.5">
Offline 2
</h2>
<div className="space-y-0.5 opacity-65">
<div className="flex items-center gap-2.5 px-2 py-1.5 rounded-md hover:bg-background/50 transition-colors cursor-pointer group">
<div className="relative shrink-0">
<div className="w-8 h-8 rounded-full bg-slate-600 flex items-center justify-center font-bold text-xs text-white">
U
</div>
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-slate-500 ring-2 ring-surface" />
</div>
<span className="text-sm font-medium text-muted group-hover:text-white truncate">
User 2
</span>
</div>
<div className="flex items-center gap-2.5 px-2 py-1.5 rounded-md hover:bg-background/50 transition-colors cursor-pointer group">
<div className="relative shrink-0">
<div className="w-8 h-8 rounded-full bg-slate-600 flex items-center justify-center font-bold text-xs text-white">
U
</div>
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-slate-500 ring-2 ring-surface" />
</div>
<span className="text-sm font-medium text-muted group-hover:text-white truncate">
User 3
</span>
</div>
</div>
</div>
</div>
);
}

View file

@ -22,6 +22,16 @@ import {
*/
export const roleEnum = pgEnum("role", ["OWNER", "ADMIN", "MEMBER"]);
/**
* Enum representing online status of a user.
*/
export const userStatusEnum = pgEnum("user_status", [
"ONLINE",
"OFFLINE",
"IDLE",
"DND",
]);
// ==========================================
// Tables
// ==========================================
@ -35,6 +45,8 @@ export const users = pgTable("users", {
email: varchar("email", { length: 255 }).notNull().unique(),
password: text("password").notNull(),
color: varchar("color", { length: 50 }).default("bg-indigo-500").notNull(),
status: userStatusEnum("status").default("OFFLINE").notNull(),
lastSeenAt: timestamp("last_seen_at").defaultNow().notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
@ -160,3 +172,4 @@ export type Server = typeof servers.$inferSelect;
export type Member = typeof members.$inferSelect;
export type Channel = typeof channels.$inferSelect;
export type Message = typeof messages.$inferSelect;
export type UserStatus = (typeof userStatusEnum.enumValues)[number];

View file

@ -0,0 +1,35 @@
/**
* @file lib/constants/member.styles.ts
*/
import type { UserStatus } from "@/db/schema";
/**
* Shared Tailwind CSS class constants mapping available member background color keys to their respective CSS utility classes.
*/
export const MEMBER_COLOR_CLASSES: Record<string, string> = {
"bg-indigo-500": "bg-indigo-500",
"bg-emerald-500": "bg-emerald-500",
"bg-rose-500": "bg-rose-500",
"bg-amber-500": "bg-amber-500",
"bg-sky-500": "bg-sky-500",
"bg-violet-500": "bg-violet-500",
"bg-fuchsia-500": "bg-fuchsia-500",
"bg-cyan-500": "bg-cyan-500",
};
/**
* List of available color option class names for member icon selection.
* Dynamically generated from MEMBER_COLOR_OPTIONS to avoid duplicate maintenance.
*/
export const MEMBER_COLOR_OPTIONS = Object.keys(MEMBER_COLOR_CLASSES);
/**
* Mapping of user status keys to their respective Tailwind CSS indicator background colors.
*/
export const MEMBER_STATUS_COLOR_CLASSES: Record<UserStatus, string> = {
ONLINE: "bg-emerald-500",
IDLE: "bg-amber-500",
DND: "bg-rose-500",
OFFLINE: "bg-slate-500",
};

View file

@ -1,6 +1,6 @@
/**
* @file lib/context/ServerContext.tsx
* @description Context for managing active server state across sidebars and mobile drawers.
* @description Context for managing active server state and members across sidebars and mobile drawers.
*/
"use client";
@ -14,49 +14,69 @@ import type { Server, Channel } from "@/db/schema";
export type ServerWithChannels = Server & { channels: Channel[] };
/**
* Interface defining the shape of the ServerContext state and update functions.
* Represents a member within a server.
*
* @interface ServerMember
* @property {string} id - The unique identifier of the server member.
* @property {string} name - The display name of the server member.
* @property {boolean} [isOnline] - Optional flag indicating whether the member is currently online.
*/
export interface ServerMember {
id: string;
name: string;
isOnline?: boolean;
}
/**
* Interface defining the shape of the ServerContext state and update handlers.
*
* @interface ServerContextType
* @property {ServerWithChannels | null} activeServer - The currently selected active server, or null if no server is active.
* @property {(server: ServerWithChannels | null) => void} setActiveServer - Callback function to update the active server state.
* @property {ServerWithChannels | null} activeServer - The currently active server instance with its associated channels.
* @property {(server: ServerWithChannels | null) => void} setActiveServer - State setter function for updating the active server.
* @property {ServerMember[]} members - The list of members belonging to the active server.
* @property {(members: ServerMember[]) => void} setMembers - State setter function for updating the server members list.
*/
interface ServerContextType {
activeServer: ServerWithChannels | null;
setActiveServer: (server: ServerWithChannels | null) => void;
members: ServerMember[];
setMembers: (members: ServerMember[]) => void;
}
/**
* React Context instance for providing and consuming active server state.
*/
const ServerContext = createContext<ServerContextType>({
activeServer: null,
setActiveServer: () => {},
members: [],
setMembers: () => {},
});
/**
* Context provider component that wraps the tree to manage and broadcast active server state.
* Provider component that wraps the application layout to provide global access to active server state and member listings.
*
* @param {Object} props - The component props.
* @param {React.ReactNode} props.children - The child components to be rendered within the context provider context.
* @returns {JSX.Element} The rendered context provider wrapper.
* @param {Object} props - React component properties.
* @param {React.ReactNode} props.children - The child components wrapped by the provider.
* @returns {JSX.Element} The rendered React provider wrapping the child elements.
*/
export function ServerProvider({ children }: { children: React.ReactNode }) {
const [activeServer, setActiveServer] = useState<ServerWithChannels | null>(
null,
);
const [members, setMembers] = useState<ServerMember[]>([]);
return (
<ServerContext.Provider value={{ activeServer, setActiveServer }}>
<ServerContext.Provider
value={{ activeServer, setActiveServer, members, setMembers }}
>
{children}
</ServerContext.Provider>
);
}
/**
* Custom hook to consume the ServerContext values.
* Custom hook to access the current ServerContext state.
*
* @returns {ServerContextType} The active server context state and setter method.
* @throws {Error} Throws an error if used outside of a ServerProvider wrapper.
* @throws {Error} Throws an error if used outside of a `ServerProvider`.
* @returns {ServerContextType} The server context value containing active server state and member management functions.
*/
export function useActiveServer() {
const context = useContext(ServerContext);

View file

@ -0,0 +1,36 @@
/**
* @file lib/services/member.service.ts
* @description Service module providing data access functions for server members using Drizzle ORM.
*/
import { db } from "@/db";
import { members, users } from "@/db/schema";
import { eq } from "drizzle-orm";
/**
* Retrieves all members belonging to a specific server, including user profile details.
*
* @async
* @function getServerMembers
* @param {string} serverId - The unique identifier of the server to fetch members for.
* @returns {Promise<Array<{ id: string, username: string, color: string | null, status: string | null }>>} Array of member user profiles, or an empty array if an error occurs.
*/
export async function getServerMembers(serverId: string) {
try {
const result = await db
.select({
id: users.id,
username: users.username,
color: users.color,
status: users.status,
})
.from(members)
.innerJoin(users, eq(members.userId, users.id))
.where(eq(members.serverId, serverId));
return result;
} catch (error) {
console.error("Error fetching server members:", error);
return [];
}
}