feat(servers): implement dynamic server & channel sidebar navigation and layout integration

This commit is contained in:
Chneemann 2026-08-29 17:24:45 +02:00
parent 1d56f9ef1f
commit 4dc68e92a7
No known key found for this signature in database
24 changed files with 1084 additions and 190 deletions

View file

@ -1,57 +1,43 @@
/**
* @file app/(app)/layout.tsx
* @description Main application layout component that manages responsive sidebars and drawer overlays.
* @description Root application layout wrapping sidebars and main workspace content within the ServerProvider context.
*/
"use client";
import { useSidebarStore } from "@/lib/stores/useSidebarStore";
import { ServerSidebar } from "@/components/sidebar/ServerSidebar";
import { ChannelSidebar } from "@/components/sidebar/ChannelSidebar";
import { MemberSidebar } from "@/components/sidebar/MemberSidebar";
import { UserProfile } from "@/components/sidebar/UserProfile";
import { MobileDrawer } from "@/components/ui/MobileDrawer";
import { auth } from "@/auth";
import { getUserServers } from "@/lib/services/server.service";
import { AppSidebar } from "@/components/layout/AppSidebar";
import { MemberDrawer } from "@/components/layout/MemberDrawer";
import { ServerProvider } from "@/lib/context/ServerContext";
import { redirect } from "next/navigation";
/**
* Client component serving as the primary application layout, handling the server, channel, and member sidebars with mobile drawer support.
* Server component layout wrapper for authenticated application views.
* Handles session verification, fetches user servers, and renders global layout components.
*
* @param {Object} props - The component props.
* @param {React.ReactNode} props.children - The child layout or page content to render in the central main view.
* @returns {JSX.Element} The application layout structure with responsive sidebars.
* @param {React.ReactNode} props.children - The child page content to render inside the main viewport layout.
* @returns {Promise<JSX.Element>} The rendered application layout hierarchy with provider contexts.
*/
export default function AppLayout({ children }: { children: React.ReactNode }) {
const { isNavOpen, isMembersOpen, closeAll } = useSidebarStore();
export default async function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
if (!session?.user?.id) {
redirect("/login");
}
const userServers = await getUserServers(session.user.id);
return (
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
{/* Left Navigation: Server + Channels + User Profile */}
<MobileDrawer
isOpen={isNavOpen}
onClose={closeAll}
side="left"
breakpoint="md"
>
<div className="flex flex-col h-full shrink-0">
<div className="flex flex-1 min-h-0">
<ServerSidebar />
<ChannelSidebar />
</div>
<UserProfile />
</div>
</MobileDrawer>
{/* Center: Main Area */}
<div className="flex-1 flex min-w-0">{children}</div>
{/* Right: List of Members */}
<MobileDrawer
isOpen={isMembersOpen}
onClose={closeAll}
side="right"
breakpoint="xl"
>
<MemberSidebar />
</MobileDrawer>
</div>
<ServerProvider>
<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 />
</div>
</ServerProvider>
);
}

View file

@ -1,28 +1,28 @@
/**
* @file app/(app)/page.tsx
* @description Main dashboard page displaying the primary chat view with header, message area, and input control.
* @description Main application layout page featuring a dynamic header and default welcome screen.
*/
import { ChatHeader } from "@/components/chat/ChatHeader";
import { ChatInput } from "@/components/chat/ChatInput";
import { AppHeader } from "@/components/layout/AppHeader";
/**
* Renders the main application dashboard containing the general chat interface.
* Renders the default application page with the header and central welcome message.
*
* @returns {Promise<JSX.Element>} The rendered application dashboard page.
* @returns {JSX.Element} The rendered application page view.
*/
export default async function ApplicationDashboardPage() {
export default function AppPage() {
return (
<main className="flex-1 flex flex-col h-full min-w-0 bg-background">
<ChatHeader />
<div className="flex flex-col h-full w-full bg-background p-4">
{/* Dynamic Header */}
<AppHeader />
<div className="flex-1 overflow-y-auto p-4 space-y-4">
<div className="flex items-center justify-center h-full text-neutral-500 text-sm">
This is the beginning of the #general channel.
</div>
{/* Main Content */}
<div className="flex flex-col items-center justify-center h-full text-center">
<h2 className="text-xl font-bold text-white mb-2">Welcome back!</h2>
<p className="text-muted max-w-sm">
Select a server from the left side or start a chat with your friends.
</p>
</div>
<ChatInput />
</main>
</div>
);
}

View file

@ -0,0 +1,47 @@
/**
* @file app/servers/[serverId]/channels/[channelId]/page.tsx
* @description Page component for viewing a specific channel and its messages.
*/
import { notFound } from "next/navigation";
import { AppHeader } from "@/components/layout/AppHeader";
import { ChatInput } from "@/components/chat/ChatInput";
import { ChatMessages } from "@/components/chat/ChatMessages";
import { getChannelById } from "@/lib/services/channel.service";
import { getChannelMessages } from "@/lib/services/message.service";
/**
* Server component that fetches and renders a channel's details and message feed based on route parameters.
*
* @param {Object} props - The component props.
* @param {Promise<{ channelId: string }>} props.params - Async route parameters containing the channel ID.
* @returns {Promise<JSX.Element>} The rendered channel page view or triggers a 404 notFound error.
*/
export default async function ChannelPage({
params,
}: {
params: Promise<{ channelId: string }>;
}) {
const { channelId } = await params;
// Paralleles Laden von Kanal-Daten und Nachrichten
const [channel, channelMessages] = await Promise.all([
getChannelById(channelId),
getChannelMessages(channelId),
]);
if (!channel) return notFound();
return (
<div className="flex p-4 flex-col h-full bg-background">
{/* Header */}
<AppHeader title={channel.name} showMembersButton />
{/* Messages Feed */}
<ChatMessages channelName={channel.name} messages={channelMessages} />
{/* Input Field */}
<ChatInput />
</div>
);
}

View file

@ -0,0 +1,50 @@
/**
* @file app/(app)/servers/[serverId]/layout.tsx
* @description Server layout component synchronizing active server state and guarding server route access.
*/
import { auth } from "@/auth";
import { getServerWithChannels } from "@/lib/services/server.service";
import { ServerStateSync } from "@/lib/providers/ServerStateSync";
import { redirect } from "next/navigation";
/**
* Properties for the ServerLayout component.
*
* @interface ServerLayoutProps
* @property {React.ReactNode} children - Child elements to be rendered within the layout context.
* @property {Promise<{ serverId: string }>} params - Asynchronous route parameters containing the active serverId.
*/
interface ServerLayoutProps {
children: React.ReactNode;
params: Promise<{ serverId: string }>;
}
/**
* Server component that verifies user authentication, retrieves the target server with its channels, synchronizes server state, and wraps child routes.
*
* @param {ServerLayoutProps} props - The component props.
* @returns {Promise<JSX.Element>} The rendered server layout tree.
*/
export default async function ServerLayout({
children,
params,
}: ServerLayoutProps) {
const { serverId } = await params;
const session = await auth();
if (!session?.user?.id) redirect("/login");
const server = await getServerWithChannels(serverId, session.user.id);
if (!server) redirect("/");
return (
<div className="flex h-full w-full min-w-0">
<ServerStateSync server={server} />
<div className="flex-1 flex flex-col h-full bg-background min-w-0">
{children}
</div>
</div>
);
}

View file

@ -0,0 +1,40 @@
/**
* @file app/servers/[serverId]/page.tsx
* @description Page component redirecting to the first available channel of a server.
*/
import { auth } from "@/auth";
import { getServerWithChannels } from "@/lib/services/server.service";
import { redirect } from "next/navigation";
export default async function ServerPage({
params,
}: {
params: Promise<{ serverId: string }>;
}) {
const { serverId } = await params;
const session = await auth();
if (!session?.user?.id) redirect("/login");
const server = await getServerWithChannels(serverId, session.user.id);
if (!server) redirect("/");
// Falls doch jemand direkt /servers/[serverId] aufruft
if (server.channels.length > 0) {
redirect(`/servers/${serverId}/channels/${server.channels[0].id}`);
}
return (
<div className="flex flex-col items-center justify-center h-full text-center p-6">
<h2 className="text-xl font-bold text-white mb-2">
Welcome to {server.name}!
</h2>
<p className="text-muted max-w-sm">
No channels have been created on this server yet. Create a channel to
chat.
</p>
</div>
);
}

View file

@ -1,45 +0,0 @@
/**
* @file components/chat/ChatHeader.tsx
* @description Client component rendering the top header of the chat interface with sidebar toggles and current channel details.
*/
"use client";
import { Menu, Hash, Users } from "lucide-react";
import { useSidebarStore } from "@/lib/stores/useSidebarStore";
/**
* Renders the top navigation header for the chat view, providing toggle triggers for mobile navigation and member list sidebars.
*
* @returns {JSX.Element} The rendered chat header component.
*/
export function ChatHeader() {
const { toggleNav, toggleMembers } = useSidebarStore();
return (
<header className="h-12 border-b border-neutral-800 flex items-center justify-between px-4 bg-background shrink-0">
<div className="flex items-center gap-3 font-semibold">
<button
onClick={toggleNav}
className="md:hidden text-muted hover:text-foreground focus:outline-none cursor-pointer"
aria-label="Toggle Navigation"
>
<Menu className="w-5 h-5" />
</button>
<div className="flex items-center gap-1.5">
<Hash className="w-5 h-5 text-muted" />
<span className="text-foreground">general</span>
</div>
</div>
<button
onClick={toggleMembers}
className="xl:hidden text-muted hover:text-foreground focus:outline-none cursor-pointer"
aria-label="Toggle Members"
>
<Users className="w-5 h-5" />
</button>
</header>
);
}

View file

@ -10,7 +10,7 @@
*/
export function ChatInput() {
return (
<div className="p-3 md:p-4 bg-background shrink-0">
<div className=" bg-background shrink-0">
<div className="bg-surface border border-surface rounded-lg p-2.5 flex items-center focus-within:ring-1 focus-within:ring-accent transition-all">
<input
type="text"

View file

@ -0,0 +1,64 @@
/**
* @file components/chat/ChatItem.tsx
* @description Single message row component.
*/
"use client";
import type { Message, Member, User } from "@/db/schema";
/**
* Composite message type extending base database Message with populated member and user relation.
*/
export type MessageWithMember = Message & {
member: Member & {
user: User;
};
};
/**
* Renders an individual chat message row displaying user avatar, sender name, timestamp, and text content.
*
* @param {Object} props - The component props.
* @param {MessageWithMember} props.message - The message object containing member and user relational data.
* @returns {JSX.Element} The rendered single chat message item.
*/
export function ChatItem({ message }: { message: MessageWithMember }) {
const user = message.member?.user;
const fullName = user ? user.username.trim() : "Deleted Member";
const initial = user?.username?.charAt(0) || "?";
const formattedTime = new Date(message.createdAt).toLocaleTimeString(
"de-DE",
{
hour: "2-digit",
minute: "2-digit",
},
);
return (
<div className="flex items-start gap-3 group p-2 rounded-xl hover:bg-surface transition-colors">
{/* Avatar */}
<div
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold text-white shrink-0 ${
user?.color || "bg-indigo-500"
}`}
>
{initial}
</div>
{/* Message Header & Content */}
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="font-semibold text-white text-sm hover:underline cursor-pointer">
{fullName}
</span>
<span className="text-xs text-muted">{formattedTime}</span>
</div>
<p className="text-foreground text-sm leading-relaxed wrap-break-words">
{message.content}
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,47 @@
/**
* @file components/chat/ChatMessages.tsx
* @description Message history container component displaying initial channel greeting and rendering a list of individual chat messages.
*/
"use client";
import { ChatItem, type MessageWithMember } from "./ChatItem";
/**
* Properties for the ChatMessages component.
*
* @interface ChatMessagesProps
* @property {string} channelName - The name of the active chat channel to display in the header greeting.
* @property {MessageWithMember[]} messages - Array of message objects, each containing message details and associated member information.
*/
interface ChatMessagesProps {
channelName: string;
messages: MessageWithMember[];
}
/**
* Renders the scrollable message list along with a welcoming channel header.
*
* @param {ChatMessagesProps} props - The component props.
* @returns {JSX.Element} The rendered chat messages container.
*/
export function ChatMessages({ channelName, messages }: ChatMessagesProps) {
return (
<div className="flex-1 overflow-y-auto flex flex-col justify-end">
<div className="mb-4 border-b border-surface/50">
<h2 className="text-2xl font-bold text-white">
Welcome to #{channelName}!
</h2>
<p className="text-muted text-sm">
This is the beginning of the channel #{channelName}.
</p>
</div>
<div className="space-y-1 mb-4">
{messages.map((message) => (
<ChatItem key={message.id} message={message} />
))}
</div>
</div>
);
}

View file

@ -0,0 +1,84 @@
/**
* @file components/layout/AppHeader.tsx
* @description Unified application header supporting general views, chat channels, and DM views.
*/
"use client";
import { useSidebarStore } from "@/lib/stores/useSidebarStore";
import { PanelLeftOpen, PanelLeftClose, Users, Hash } from "lucide-react";
/**
* Properties for the AppHeader component.
*
* @interface AppHeaderProps
* @property {string} [title] - Optional channel or page title to display in the header.
* @property {boolean} [showMembersButton=false] - Flag indicating whether to display the member list toggle button.
*/
interface AppHeaderProps {
title?: string;
showMembersButton?: boolean;
}
/**
* Renders the application header bar with navigation controls, dynamic page titles, and member list toggle capability.
*
* @param {AppHeaderProps} props - The component props.
* @param {string} [props.title] - Optional channel or page title to display.
* @param {boolean} [props.showMembersButton=false] - Whether to show the button toggling the right sidebar/member panel.
* @returns {JSX.Element} The header component visual structure.
*/
export function AppHeader({
title,
showMembersButton = false,
}: AppHeaderProps) {
const { isNavOpen, toggleNav, toggleMembers } = useSidebarStore();
const hasContent = !isNavOpen || !!title || showMembersButton;
return (
<div
className={`flex items-center justify-between bg-background shrink-0 pb-2 ${
hasContent ? "border-b border-muted/50" : ""
}`}
>
<div className="flex items-center gap-2">
{/* Toggle button for navigation */}
<button
type="button"
onClick={toggleNav}
title={isNavOpen ? "Collapse navigation" : "Expand Navigation"}
className={`p-1.5 rounded-md text-muted hover:text-white hover:bg-surface transition-colors cursor-pointer ${
isNavOpen ? "md:hidden" : "block"
}`}
>
{isNavOpen ? (
<PanelLeftClose className="w-5 h-5" />
) : (
<PanelLeftOpen className="w-5 h-5" />
)}
</button>
{/* Dynamic Title (Channel/Page Name) */}
{title && (
<div className="flex items-center gap-1.5 ml-1">
<Hash className="w-4 h-4 text-muted" />
<h1 className="font-bold text-white text-base truncate">{title}</h1>
</div>
)}
</div>
{/* Button for the member bar */}
{showMembersButton && (
<button
type="button"
onClick={toggleMembers}
title="Mitgliederliste umschalten"
className="p-1.5 rounded-md text-muted hover:text-white hover:bg-surface transition-colors cursor-pointer"
>
<Users className="w-5 h-5" />
</button>
)}
</div>
);
}

View file

@ -0,0 +1,65 @@
/**
* @file components/layout/AppSidebar.tsx
* @description Responsive sidebar wrapper for server and channel sidebars with collapsible desktop support.
*/
"use client";
import { useSidebarStore } from "@/lib/stores/useSidebarStore";
import { ServerSidebar } from "@/components/sidebar/ServerSidebar";
import { ChannelSidebar } from "@/components/sidebar/ChannelSidebar";
import { UserPanel } from "@/components/sidebar/UserPanel";
import type { ServerWithChannels } from "@/lib/context/ServerContext";
import { clsx } from "clsx";
/**
* Renders the responsive application sidebar containing server navigation, channel lists, and user profile.
*
* @param {Object} props - The component props.
* @param {ServerWithChannels[]} props.servers - Array of server objects with channels to display in the server navigation bar.
* @returns {JSX.Element} The rendered mobile overlay and responsive sidebar structure.
*/
export function AppSidebar({ servers }: { servers: ServerWithChannels[] }) {
const { isNavOpen, closeAll } = useSidebarStore();
return (
<>
{/* 1. DESKTOP VIEW (md:flex) - Collapses flexibly via transition/width */}
<aside
className={clsx(
"hidden md:flex flex-col h-full bg-surface shrink-0 transition-all duration-300 ease-in-out overflow-hidden border-r border-background",
isNavOpen ? "w-78 opacity-100" : "w-0 opacity-0 pointer-events-none",
)}
>
<div className="flex flex-1 min-h-0 w-78">
<ServerSidebar servers={servers} />
<ChannelSidebar />
</div>
<div className="w-78">
<UserPanel />
</div>
</aside>
{/* 2. MOBILE VIEW (md:hidden) - Functions as a slide-out drawer */}
{isNavOpen && (
<div
className="fixed inset-0 bg-black/60 z-30 md:hidden"
onClick={closeAll}
/>
)}
<div
className={clsx(
"fixed inset-y-0 left-0 z-40 flex flex-col h-full w-full bg-surface transition-transform duration-200 ease-in-out md:hidden",
isNavOpen ? "translate-x-0" : "-translate-x-full",
)}
>
<div className="flex flex-1 min-h-0 w-full">
<ServerSidebar servers={servers} />
<ChannelSidebar />
</div>
<UserPanel />
</div>
</>
);
}

View file

@ -0,0 +1,112 @@
/**
* @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

@ -1,39 +1,88 @@
/**
* @file components/sidebar/ChannelSidebar.tsx
* @description Sidebar component for server navigation, rendering channel lists.
* @description Sidebar component listing channels for the active server, or rendering direct messages when no server is active.
*/
import { Hash } from "lucide-react";
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useActiveServer } from "@/lib/context/ServerContext";
import { DirectMessageSidebar } from "@/components/sidebar/DirectMessageSidebar";
import { useSidebarStore } from "@/lib/stores/useSidebarStore";
import { PanelLeftClose } from "lucide-react";
/**
* Navigation sidebar displaying server details and text channels.
* Renders the channel sidebar for the active server or defaults to the direct message view.
* Handles responsive sidebar toggling and highlights active channels based on URL parameters.
*
* @returns {JSX.Element} The rendered channel sidebar element.
* @returns {JSX.Element} The rendered channel sidebar or direct message sidebar component.
*/
export function ChannelSidebar() {
return (
<aside className="w-60 bg-surface flex flex-col border-r border-background shrink-0 h-full">
<header className="h-12 border-b border-background flex items-center px-4 font-semibold text-foreground shrink-0">
Waveform Community
</header>
const params = useParams();
const currentChannelId = params?.channelId as string;
const { activeServer } = useActiveServer();
const { closeNav, toggleNav } = useSidebarStore();
<div className="flex-1 overflow-y-auto p-3 space-y-4">
<div>
<h2 className="text-xs font-semibold text-muted uppercase tracking-wider mb-2 px-2">
Text Channels
</h2>
<nav className="space-y-0.5">
<button className="w-full text-left px-2 py-1.5 rounded text-muted hover:bg-background hover:text-foreground font-medium text-sm flex items-center gap-2 transition-colors cursor-pointer">
<Hash className="w-4 h-4 text-muted shrink-0" />
<span className="truncate">general</span>
</button>
<button className="w-full text-left px-2 py-1.5 rounded text-muted hover:bg-background hover:text-foreground font-medium text-sm flex items-center gap-2 transition-colors cursor-pointer">
<Hash className="w-4 h-4 text-muted shrink-0" />
<span className="truncate">dev-talk</span>
</button>
</nav>
/**
* Closes the mobile navigation drawer when a channel link is selected on viewports smaller than 768px.
*/
const handleChannelClick = () => {
if (!window.matchMedia("(min-width: 768px)").matches) {
closeNav();
}
};
if (!activeServer) {
return <DirectMessageSidebar />;
}
return (
<div className="flex-1 w-full md:w-72 bg-surface/50 border-r border-background flex flex-col h-full shrink-0">
{/* Server Header */}
<div className="h-14 border-b border-background flex items-center justify-between px-4 font-bold text-white shadow-sm">
<span className="truncate">{activeServer.name}</span>
<button
type="button"
onClick={toggleNav}
title="Collapse the sidebar"
className="p-1.5 rounded-md text-muted hover:text-white hover:bg-surface transition-colors cursor-pointer"
>
<PanelLeftClose className="w-5 h-5" />
</button>
</div>
{/* Channel List */}
<div className="flex-1 overflow-y-auto p-3 space-y-1">
<div className="flex items-center justify-between text-xs font-semibold text-muted px-2 py-1 uppercase tracking-wider">
<span>Text Channels</span>
</div>
<div className="space-y-0.5">
{activeServer.channels.map((channel) => {
const isActive = currentChannelId === channel.id;
return (
<Link
key={channel.id}
href={`/servers/${activeServer.id}/channels/${channel.id}`}
onClick={handleChannelClick}
prefetch={false}
className={`flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-all group ${
isActive
? "bg-accent/50 text-white font-medium"
: "text-muted hover:bg-surface hover:text-white"
}`}
>
<span className="text-muted group-hover:text-white text-base">
#
</span>
<span className="truncate">{channel.name}</span>
</Link>
);
})}
</div>
</div>
</aside>
</div>
);
}

View file

@ -0,0 +1,58 @@
/**
* @file components/sidebar/DirectMessageSidebar.tsx
* @description Sidebar listing direct messages and friends list when no server is selected.
*/
"use client";
import { useSidebarStore } from "@/lib/stores/useSidebarStore";
import { PanelLeftClose } from "lucide-react";
import Link from "next/link";
/**
* DirectMessageSidebar component that renders navigation for direct messages and friends.
* Includes a header with collapsible controls and links for viewing chat channels and active conversations.
*
* @returns {JSX.Element} The direct message sidebar component layout.
*/
export function DirectMessageSidebar() {
const { toggleNav } = useSidebarStore();
return (
<div className="flex-1 w-full md:w-60 bg-surface/50 border-r border-background flex flex-col h-full shrink-0">
{/* Header */}
<div className="h-14 border-b border-background flex items-center justify-between px-4 font-bold text-white shadow-sm">
<span>Direct Messages</span>
<button
type="button"
onClick={toggleNav}
title="Collapse the sidebar"
className="p-1.5 rounded-md text-muted hover:text-white hover:bg-surface transition-colors cursor-pointer"
>
<PanelLeftClose className="w-5 h-5" />
</button>
</div>
{/* Navigation & List */}
<div className="flex-1 overflow-y-auto p-3 space-y-4">
<div className="space-y-1">
<Link
href="/"
className="flex items-center gap-3 px-3 py-2 rounded-md text-sm text-white bg-accent/20 font-medium transition-all"
>
<span>Friends</span>
</Link>
</div>
<div>
<div className="text-xs font-semibold text-muted px-2 mb-2 uppercase tracking-wider">
Direct Messages
</div>
<div className="text-sm text-muted px-2 py-1 italic">
No active chats
</div>
</div>
</div>
</div>
);
}

View file

@ -1,25 +1,67 @@
/**
* @file components/sidebar/MemberSidebar.tsx
* @description Sidebar component displaying the list of active server/chat members and their online status.
* @description Sidebar component displaying lists of online and offline channel members with avatar and status indicators.
*/
/**
* Renders the member sidebar showing online users and their profile avatars.
* Renders the member sidebar showing categorized online and offline user statuses.
*
* @returns {JSX.Element} The rendered member sidebar navigation container.
* @returns {JSX.Element} The rendered member sidebar interface.
*/
export function MemberSidebar() {
return (
<aside className="w-60 bg-[hsl(200_6%_8%)] border-l border-neutral-800 p-4 shrink-0 h-full overflow-y-auto">
<h2 className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-3">
Online 1
</h2>
<div className="flex items-center gap-3 py-1.5">
<div className="w-8 h-8 rounded-full bg-accent flex items-center justify-center font-bold text-xs text-white">
U
<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>
<span className="text-sm font-medium text-foreground">User</span>
</div>
</aside>
{/* 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

@ -1,24 +1,119 @@
/**
* @file components/sidebar/ServerSidebar.tsx
* @description Sidebar navigation component for switching between servers and direct messages.
* @description Sidebar navigation component for switching between servers and home view.
*/
"use client";
import Link from "next/link";
import NextImage from "next/image";
import { usePathname } from "next/navigation";
import {
useActiveServer,
type ServerWithChannels,
} from "@/lib/context/ServerContext";
/**
* Navigation bar for switching between servers and direct messages.
* ServerSidebar component rendering the list of available servers, home navigation, and server creation trigger.
*
* @returns {JSX.Element} The rendered server sidebar component.
* @param {Object} props - The component props.
* @param {ServerWithChannels[]} props.servers - Array of server objects containing channel and display metadata.
* @returns {JSX.Element} The rendered server sidebar navigation.
*/
export function ServerSidebar() {
export function ServerSidebar({ servers }: { servers: ServerWithChannels[] }) {
const pathname = usePathname();
const { setActiveServer } = useActiveServer();
/**
* Mapping of Tailwind CSS background color classes for server icons.
*/
const 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",
};
/**
* Shared base CSS utility classes for server icon buttons.
*/
const baseIconStyles =
"w-12 h-12 flex items-center justify-center transition-all duration-200 shadow-md shrink-0";
/**
* CSS utility classes applied to the currently active server icon.
*/
const activeIconStyles =
"rounded-xl ring-2 ring-accent ring-offset-2 ring-offset-surface cursor-default pointer-events-none opacity-100";
/**
* CSS utility classes applied to inactive server icons.
*/
const inactiveIconStyles =
"rounded-3xl opacity-80 hover:opacity-100 hover:rounded-xl hover:scale-105 hover:shadow-lg hover:ring-2 hover:ring-accent/40 cursor-pointer active:scale-95";
return (
<aside className="w-18 bg-surface flex flex-col items-center py-3 gap-3 border-r border-background shrink-0 h-full justify-between">
<div className="flex flex-col items-center gap-3 w-full">
<div className="w-12 h-12 rounded-2xl bg-accent flex items-center justify-center font-bold text-white cursor-pointer hover:rounded-xl transition-all shadow-md">
W
</div>
{/* Home Icon */}
<Link
href="/"
title="Home"
prefetch={false}
onClick={() => setActiveServer(null)}
className={`${baseIconStyles} bg-surface/50 overflow-hidden ${
pathname === "/" ? activeIconStyles : inactiveIconStyles
}`}
>
<NextImage
src="/logo.png"
alt="Logo"
width={36}
height={36}
className="object-contain"
/>
</Link>
<div className="w-8 h-0.5 bg-background/80 rounded-full" />
<div className="w-12 h-12 rounded-3xl bg-background flex items-center justify-center text-muted hover:bg-accent hover:text-white hover:rounded-xl transition-all cursor-pointer">
+
{/* Server List */}
<div className="flex flex-col gap-3 w-full items-center overflow-y-auto max-h-[calc(100vh-160px)] p-1">
{servers.map((server) => {
const isActive = pathname.startsWith(`/servers/${server.id}`);
const initial = server.name.charAt(0).toUpperCase();
const serverBg = COLOR_CLASSES[server.color] || "bg-indigo-500";
// Direkt zum ersten Channel verlinken (falls vorhanden), sonst zur Fallback-Server-Page
const firstChannelId = server.channels?.[0]?.id;
const targetHref = firstChannelId
? `/servers/${server.id}/channels/${firstChannelId}`
: `/servers/${server.id}`;
return (
<Link
key={server.id}
href={targetHref}
title={server.name}
prefetch={false}
style={{ textShadow: "0 1px 2px rgba(0, 0, 0, 0.8)" }}
className={`${baseIconStyles} ${serverBg} text-white text-2xl font-semibold ${
isActive ? activeIconStyles : inactiveIconStyles
}`}
>
{initial}
</Link>
);
})}
</div>
{/* Add Server Button */}
<button
type="button"
title="Server hinzufügen"
className={`${baseIconStyles} bg-background text-muted hover:bg-accent hover:text-white ${inactiveIconStyles}`}
>
+
</button>
</div>
</aside>
);

View file

@ -1,6 +1,6 @@
/**
* @file components/sidebar/UserProfile.tsx
* @description User profile component displaying current session info, status, settings, and logout action.
* @file components/sidebar/UserPanel.tsx
* @description Client component providing a footer user panel with session information, avatar display, online status, and settings/logout actions.
*/
"use client";
@ -9,13 +9,16 @@ import { LogOut, Settings } from "lucide-react";
import { signOut, useSession } from "next-auth/react";
/**
* Renders the user profile card with user information, online status, settings, and logout trigger.
* Renders the user panel footer component showing current session profile details and authentication controls.
*
* @returns {JSX.Element} The rendered user profile footer element.
* @returns {JSX.Element} The rendered user panel component.
*/
export function UserProfile() {
export function UserPanel() {
const { data: session } = useSession();
/**
* Triggers the NextAuth sign-out procedure and redirects the user to the login page.
*/
const handleLogout = () => {
signOut({ callbackUrl: "/login" });
};

View file

@ -1,6 +1,6 @@
/**
* @file components/ui/MobileDrawer.tsx
* @description Slide-out drawer component for mobile views with configurable side placement, responsive breakpoint behavior, and backdrop overlay.
* @description Slide-out drawer component for mobile views with optional responsive breakpoint behavior.
*/
"use client";
@ -9,58 +9,48 @@ import { clsx } from "clsx";
interface MobileDrawerProps {
isOpen: boolean;
onClose: () => void;
side: "left" | "right";
breakpoint: "md" | "xl";
breakpoint?: "md" | "none";
children: React.ReactNode;
}
/**
* Renders a responsive drawer container that slides in from either side on mobile viewports and transitions to a static layout at specified breakpoints.
* Slide-out drawer component designed for mobile layouts with configurable side positioning and responsive display logic.
*
* @param {MobileDrawerProps} props - The component props.
* @param {boolean} props.isOpen - Controls the visibility state of the drawer overlay on mobile viewports.
* @param {() => void} props.onClose - Callback function triggered when clicking the backdrop overlay to close the drawer.
* @param {"left" | "right"} props.side - The screen edge from which the drawer slides out.
* @param {"md" | "xl"} props.breakpoint - Tailwind responsive breakpoint at which the drawer becomes static and hides the mobile backdrop.
* @param {React.ReactNode} props.children - Content rendered within the drawer body.
* @returns {JSX.Element} The drawer element alongside its conditional backdrop overlay.
* @param {boolean} props.isOpen - Indicates whether the drawer is currently expanded or hidden.
* @param {"left" | "right"} props.side - The edge of the viewport from which the drawer slides out.
* @param {"md" | "none"} [props.breakpoint="none"] - Optional breakpoint at which the drawer becomes statically positioned.
* @param {React.ReactNode} props.children - The elements to render inside the drawer container.
* @returns {JSX.Element} The rendered mobile drawer component.
*/
export function MobileDrawer({
isOpen,
onClose,
side,
breakpoint,
breakpoint = "none",
children,
}: MobileDrawerProps) {
const isLeft = side === "left";
const translateHidden = isLeft ? "-translate-x-full" : "translate-x-full";
const breakpointStatic =
breakpoint === "md"
? "md:static md:translate-x-0"
: "xl:static xl:translate-x-0";
const breakpointHidden = breakpoint === "md" ? "md:hidden" : "xl:hidden";
breakpoint === "md" ? "md:static md:translate-x-0 md:w-auto" : "";
return (
<>
<div
className={clsx(
"fixed inset-y-0 z-40 flex transform transition-transform duration-200 ease-in-out h-full",
isLeft ? "left-0" : "right-0",
"fixed inset-y-0 z-40 flex transform transition-transform duration-200 ease-in-out h-full w-full pointer-events-none",
isLeft ? "left-0 justify-start" : "right-0 justify-end",
breakpointStatic,
isOpen ? "translate-x-0" : translateHidden,
)}
>
{children}
<div className="pointer-events-auto h-full w-full sm:w-auto flex justify-end bg-surface">
{children}
</div>
</div>
{isOpen && (
<div
className={clsx("fixed inset-0 bg-black/60 z-30", breakpointHidden)}
onClick={onClose}
/>
)}
</>
);
}

View file

@ -0,0 +1,67 @@
/**
* @file lib/context/ServerContext.tsx
* @description Context for managing active server state across sidebars and mobile drawers.
*/
"use client";
import { createContext, useContext, useState } from "react";
import type { Server, Channel } from "@/db/schema";
/**
* Type definition representing a server entity along with its associated channels array.
*/
export type ServerWithChannels = Server & { channels: Channel[] };
/**
* Interface defining the shape of the ServerContext state and update functions.
*
* @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.
*/
interface ServerContextType {
activeServer: ServerWithChannels | null;
setActiveServer: (server: ServerWithChannels | null) => void;
}
/**
* React Context instance for providing and consuming active server state.
*/
const ServerContext = createContext<ServerContextType>({
activeServer: null,
setActiveServer: () => {},
});
/**
* Context provider component that wraps the tree to manage and broadcast active server state.
*
* @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.
*/
export function ServerProvider({ children }: { children: React.ReactNode }) {
const [activeServer, setActiveServer] = useState<ServerWithChannels | null>(
null,
);
return (
<ServerContext.Provider value={{ activeServer, setActiveServer }}>
{children}
</ServerContext.Provider>
);
}
/**
* Custom hook to consume the ServerContext values.
*
* @returns {ServerContextType} The active server context state and setter method.
* @throws {Error} Throws an error if used outside of a ServerProvider wrapper.
*/
export function useActiveServer() {
const context = useContext(ServerContext);
if (!context) {
throw new Error("useActiveServer must be used within a ServerProvider");
}
return context;
}

View file

@ -0,0 +1,29 @@
/**
* @file lib/providers/ServerStateSync.tsx
* @description Syncs active server data into ServerContext on mount and updates on change.
*/
"use client";
import { useEffect } from "react";
import {
useActiveServer,
type ServerWithChannels,
} from "@/lib/context/ServerContext";
/**
* Client component that synchronizes the current server state with the global server context.
*
* @param {Object} props - The component props.
* @param {ServerWithChannels} props.server - The server object containing channels to be set as active.
* @returns {null} Renders no UI elements.
*/
export function ServerStateSync({ server }: { server: ServerWithChannels }) {
const { setActiveServer } = useActiveServer();
useEffect(() => {
setActiveServer(server);
}, [server, setActiveServer]);
return null;
}

View file

@ -0,0 +1,20 @@
/**
* @file lib/services/channel.service.ts
* @description Service module providing database access methods for channel management.
*/
import { db } from "@/db";
import { channels } from "@/db/schema";
import { eq } from "drizzle-orm";
/**
* Retrieves a single channel record from the database by its unique identifier.
*
* @param {string} channelId - The unique identifier of the channel to fetch.
* @returns {Promise<Object | undefined>} The channel object if found, or undefined if no matching channel exists.
*/
export async function getChannelById(channelId: string) {
return await db.query.channels.findFirst({
where: eq(channels.id, channelId),
});
}

View file

@ -0,0 +1,29 @@
/**
* @file lib/services/message.service.ts
* @description Data access service for retrieving and managing message entities from the database.
*/
import { db } from "@/db";
import { messages } from "@/db/schema";
import { eq } from "drizzle-orm";
/**
* Retrieves all messages belonging to a specific channel, ordered chronologically ascending,
* including associated member and user details.
*
* @param {string} channelId - The unique identifier of the target channel.
* @returns {Promise<Array<Object>>} A promise resolving to a list of message objects populated with nested member and user data.
*/
export async function getChannelMessages(channelId: string) {
return await db.query.messages.findMany({
where: eq(messages.channelId, channelId),
with: {
member: {
with: {
user: true,
},
},
},
orderBy: (messages, { asc }) => [asc(messages.createdAt)],
});
}

View file

@ -0,0 +1,57 @@
/**
* @file lib/services/server.service.ts
* @description Service module providing database queries for managing server data, user memberships, and associated channels.
*/
import { db } from "@/db";
import { members, servers } from "@/db/schema";
import { eq, and } from "drizzle-orm";
/**
* Retrieves a server along with its channels sorted chronologically if the specified user is a verified member.
*
* @param {string} serverId - The unique identifier of the server to retrieve.
* @param {string} userId - The unique identifier of the requesting user.
* @returns {Promise<Object | null>} The server record with nested channels array, or null if the user is not a member or the server is not found.
*/
export async function getServerWithChannels(serverId: string, userId: string) {
const isMember = await db.query.members.findFirst({
where: and(eq(members.serverId, serverId), eq(members.userId, userId)),
});
if (!isMember) return null;
const server = await db.query.servers.findFirst({
where: eq(servers.id, serverId),
with: {
channels: {
orderBy: (channels, { asc }) => [asc(channels.createdAt)],
},
},
});
return server;
}
/**
* Fetches all servers that the specified user belongs to, including each server's sorted channels list.
*
* @param {string} userId - The unique identifier of the user whose servers are to be fetched.
* @returns {Promise<Array<Object>>} An array of server objects associated with the user.
*/
export async function getUserServers(userId: string) {
const userMemberships = await db.query.members.findMany({
where: eq(members.userId, userId),
with: {
server: {
with: {
channels: {
orderBy: (channels, { asc }) => [asc(channels.createdAt)],
},
},
},
},
});
return userMemberships.map((membership) => membership.server).filter(Boolean);
}

View file

@ -1,6 +1,6 @@
/**
* @file lib/stores/useSidebarStore.ts
* @description Condition state management store for controlling the visibility and mutual exclusion of navigation and members sidebars.
* @description State management store using Zustand for controlling the visibility of navigation and members sidebars.
*/
import { create } from "zustand";
@ -11,8 +11,10 @@ import { create } from "zustand";
* @interface SidebarState
* @property {boolean} isNavOpen - Indicates whether the navigation sidebar is open.
* @property {boolean} isMembersOpen - Indicates whether the members list sidebar is open.
* @property {() => void} toggleNav - Toggles the navigation sidebar while automatically closing the members sidebar.
* @property {() => void} toggleMembers - Toggles the members sidebar while automatically closing the navigation sidebar.
* @property {() => void} toggleNav - Toggles the visibility of the navigation sidebar.
* @property {() => void} toggleMembers - Toggles the visibility of the members list sidebar.
* @property {() => void} closeNav - Explicitly closes the navigation sidebar.
* @property {() => void} closeMembers - Explicitly closes the members list sidebar.
* @property {() => void} closeAll - Closes both the navigation and members sidebars simultaneously.
*/
interface SidebarState {
@ -20,20 +22,23 @@ interface SidebarState {
isMembersOpen: boolean;
toggleNav: () => void;
toggleMembers: () => void;
closeNav: () => void;
closeMembers: () => void;
closeAll: () => void;
}
/**
* Custom Condition hook for managing global sidebar visibility states.
* Custom Zustand hook for managing global sidebar visibility states.
*
* @returns {SidebarState} The current sidebar state and action handlers.
*/
export const useSidebarStore = create<SidebarState>((set) => ({
isNavOpen: false,
isNavOpen: true,
isMembersOpen: false,
toggleNav: () =>
set((state) => ({ isNavOpen: !state.isNavOpen, isMembersOpen: false })),
toggleNav: () => set((state) => ({ isNavOpen: !state.isNavOpen })),
toggleMembers: () =>
set((state) => ({ isMembersOpen: !state.isMembersOpen, isNavOpen: false })),
set((state) => ({ isMembersOpen: !state.isMembersOpen })),
closeNav: () => set({ isNavOpen: false }),
closeMembers: () => set({ isMembersOpen: false }),
closeAll: () => set({ isNavOpen: false, isMembersOpen: false }),
}));