diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index a9b5b1e..5f07f57 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -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({
{children}
- +
); diff --git a/app/api/servers/[serverId]/members/route.ts b/app/api/servers/[serverId]/members/route.ts new file mode 100644 index 0000000..b780577 --- /dev/null +++ b/app/api/servers/[serverId]/members/route.ts @@ -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} 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 }, + ); + } +} diff --git a/components/layout/MemberDrawer.tsx b/components/layout/MemberDrawer.tsx deleted file mode 100644 index 1a76c5e..0000000 --- a/components/layout/MemberDrawer.tsx +++ /dev/null @@ -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(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 }) => ( -
- - Members - - -
- ); - - return ( - <> - {/* Desktop View */} - - - {/* Mobile Backdrop & Drawer */} - {isMembersOpen && ( -
- )} - -
-
-
- -
-
- - ); -} diff --git a/components/layout/MemberSidebar.tsx b/components/layout/MemberSidebar.tsx new file mode 100644 index 0000000..fee7145 --- /dev/null +++ b/components/layout/MemberSidebar.tsx @@ -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 ( +
+ + Members + + +
+ ); +} + +/** + * 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(null); + + const [members, setMembers] = useState([]); + 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 ( +
+ +
+ ); + } + return ; + }; + + return ( + <> + {/* Desktop View */} + + + {/* Mobile Backdrop & Drawer */} + {isMembersOpen && ( +
+ )} + +
+ +
{renderContent()}
+
+ + ); +} diff --git a/components/members/MemberItem.tsx b/components/members/MemberItem.tsx new file mode 100644 index 0000000..0729b73 --- /dev/null +++ b/components/members/MemberItem.tsx @@ -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 ( +
+
+
+ {member.username ? member.username.charAt(0).toUpperCase() : "?"} +
+ +
+ + {member.username || "Unbekannt"} + +
+ ); +} diff --git a/components/members/MemberList.tsx b/components/members/MemberList.tsx new file mode 100644 index 0000000..d71bae6 --- /dev/null +++ b/components/members/MemberList.tsx @@ -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 ( +
+ {/* Online Section */} +
+

+ Online — {onlineMembers.length} +

+
+ {onlineMembers.map((member) => ( + + ))} + {onlineMembers.length === 0 && ( +

+ No members online +

+ )} +
+
+ + {/* Offline Section */} +
+

+ Offline — {offlineMembers.length} +

+
+ {offlineMembers.map((member) => ( + + ))} +
+
+
+ ); +} diff --git a/components/sidebar/MemberSidebar.tsx b/components/sidebar/MemberSidebar.tsx deleted file mode 100644 index 27ce6c4..0000000 --- a/components/sidebar/MemberSidebar.tsx +++ /dev/null @@ -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 ( -
- {/* Online Section */} -
-

- Online — 1 -

-
-
-
-
- U -
- -
- - User 1 - -
-
-
- - {/* Offline Section */} -
-

- Offline — 2 -

-
-
-
-
- U -
- -
- - User 2 - -
- -
-
-
- U -
- -
- - User 3 - -
-
-
-
- ); -} diff --git a/db/schema.ts b/db/schema.ts index 5477538..ef61380 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -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]; diff --git a/lib/constants/member.styles.ts b/lib/constants/member.styles.ts new file mode 100644 index 0000000..bd8307c --- /dev/null +++ b/lib/constants/member.styles.ts @@ -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 = { + "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 = { + ONLINE: "bg-emerald-500", + IDLE: "bg-amber-500", + DND: "bg-rose-500", + OFFLINE: "bg-slate-500", +}; diff --git a/lib/context/ServerContext.tsx b/lib/context/ServerContext.tsx index 5e8badb..06fd1a9 100644 --- a/lib/context/ServerContext.tsx +++ b/lib/context/ServerContext.tsx @@ -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({ 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( null, ); + const [members, setMembers] = useState([]); return ( - + {children} ); } /** - * 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); diff --git a/lib/services/member.service.ts b/lib/services/member.service.ts new file mode 100644 index 0000000..7850471 --- /dev/null +++ b/lib/services/member.service.ts @@ -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 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 []; + } +}