feat(servers): implement dynamic server & channel sidebar navigation and layout integration
This commit is contained in:
parent
1d56f9ef1f
commit
4dc68e92a7
24 changed files with 1084 additions and 190 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
47
app/(app)/servers/[serverId]/channels/[channelId]/page.tsx
Normal file
47
app/(app)/servers/[serverId]/channels/[channelId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
app/(app)/servers/[serverId]/layout.tsx
Normal file
50
app/(app)/servers/[serverId]/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
app/(app)/servers/[serverId]/page.tsx
Normal file
40
app/(app)/servers/[serverId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
64
components/chat/ChatItem.tsx
Normal file
64
components/chat/ChatItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
components/chat/ChatMessages.tsx
Normal file
47
components/chat/ChatMessages.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
components/layout/AppHeader.tsx
Normal file
84
components/layout/AppHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
components/layout/AppSidebar.tsx
Normal file
65
components/layout/AppSidebar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
112
components/layout/MemberDrawer.tsx
Normal file
112
components/layout/MemberDrawer.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
58
components/sidebar/DirectMessageSidebar.tsx
Normal file
58
components/sidebar/DirectMessageSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
};
|
||||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
67
lib/context/ServerContext.tsx
Normal file
67
lib/context/ServerContext.tsx
Normal 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;
|
||||
}
|
||||
29
lib/providers/ServerStateSync.tsx
Normal file
29
lib/providers/ServerStateSync.tsx
Normal 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;
|
||||
}
|
||||
20
lib/services/channel.service.ts
Normal file
20
lib/services/channel.service.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
29
lib/services/message.service.ts
Normal file
29
lib/services/message.service.ts
Normal 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)],
|
||||
});
|
||||
}
|
||||
57
lib/services/server.service.ts
Normal file
57
lib/services/server.service.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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 }),
|
||||
}));
|
||||
|
|
|
|||
Loading…
Reference in a new issue