From 23215515182862bd08c5af84a2003051023e9d93 Mon Sep 17 00:00:00 2001 From: Chneemann Date: Sun, 13 Sep 2026 15:56:53 +0200 Subject: [PATCH] feat(channels): display categories and fallback to default text channels --- components/sidebar/ChannelSidebar.tsx | 220 +++++++++++++++++--------- lib/context/ServerContext.tsx | 95 ++++------- lib/services/server.service.ts | 31 ++-- 3 files changed, 185 insertions(+), 161 deletions(-) diff --git a/components/sidebar/ChannelSidebar.tsx b/components/sidebar/ChannelSidebar.tsx index d96d6dd..3ef1637 100644 --- a/components/sidebar/ChannelSidebar.tsx +++ b/components/sidebar/ChannelSidebar.tsx @@ -1,64 +1,118 @@ /** * @file components/sidebar/ChannelSidebar.tsx - * @description Sidebar component for navigating text channels within an active server, featuring channel creation, editing, and mobile responsiveness. + * @description Channel sidebar component listing categories and channels with actions for creation and editing. */ "use client"; import { useState } from "react"; -import Link from "next/link"; import { useParams } from "next/navigation"; import { useActiveServer } from "@/lib/context/ServerContext"; import { useSidebarStore } from "@/lib/stores/useSidebarStore"; import { CreateChannelModal } from "@/components/modals/CreateChannelModal"; import { EditChannelModal } from "@/components/modals/EditChannelModal"; -import { PanelLeftClose, Plus, Settings } from "lucide-react"; +import { + ChevronDown, + ChevronRight, + PanelLeftClose, + Plus, + Settings, +} from "lucide-react"; import type { Channel } from "@/db/schema"; +import Link from "next/link"; -/** - * Renders the channel sidebar for the active server with text channel lists, creation triggers, and settings handlers. - * - * @returns {JSX.Element | null} The rendered channel sidebar container or null if no active server exists. - */ +/** Renders the channel navigation sidebar for the active server. */ export function ChannelSidebar() { const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [editingChannel, setEditingChannel] = useState(null); + const [selectedCategoryId, setSelectedCategoryId] = useState( + null, + ); + + const [collapsedCategories, setCollapsedCategories] = useState< + Record + >({}); const params = useParams(); const currentChannelId = params?.channelId as string; const { activeServer } = useActiveServer(); const { closeNav, toggleNav } = useSidebarStore(); - if (!activeServer) { - return null; - } + if (!activeServer) return null; - /** - * Handles channel selection clicks, automatically closing the mobile navigation drawer on smaller screens. - * - * @function handleChannelClick - * @returns {void} - */ + /** Toggles the collapsed state of a channel category. */ + const toggleCategory = (categoryId: string) => { + setCollapsedCategories((prev) => ({ + ...prev, + [categoryId]: !prev[categoryId], + })); + }; + + /** Handles mobile navigation closure when clicking a channel link. */ const handleChannelClick = () => { if (!window.matchMedia("(min-width: 768px)").matches) { closeNav(); } }; - /** - * Opens the channel settings modal for the selected channel, preventing event propagation. - * - * @function handleOpenSettings - * @param {React.MouseEvent} e - The mouse click event. - * @param {Channel} channel - The channel object to edit. - * @returns {void} - */ + /** Opens the edit channel modal for a specific channel. */ const handleOpenSettings = (e: React.MouseEvent, channel: Channel) => { e.preventDefault(); e.stopPropagation(); setEditingChannel(channel); }; + /** Opens the create channel modal for a specific category or uncategorized. */ + const handleOpenCreateModal = (categoryId: string | null = null) => { + setSelectedCategoryId(categoryId); + setIsCreateModalOpen(true); + }; + + const uncategorizedChannels = activeServer.channels.filter( + (c) => !c.categoryId, + ); + + const categoriesList = (activeServer.categories || []).map((cat) => ({ + ...cat, + channels: activeServer.channels.filter((c) => c.categoryId === cat.id), + })); + + /** Renders an individual channel item in the list. */ + const renderChannelItem = (channel: Channel) => { + const isActive = currentChannelId === channel.id; + return ( + +
+ + # + + {channel.name} +
+ + {!channel.isDefault && ( + + )} + + ); + }; + return ( <>
@@ -76,59 +130,77 @@ export function ChannelSidebar() {
{/* Channel List */} -
-
- Text Channels - +
+
+
+ + + +
+ + {!collapsedCategories["uncategorized"] && ( +
+ {uncategorizedChannels.map(renderChannelItem)} +
+ )}
-
- {activeServer.channels.map((channel) => { - const isActive = currentChannelId === channel.id; + {/* 2. Custom Kategorien */} + {categoriesList.map((category) => { + const isCollapsed = collapsedCategories[category.id]; - return ( - - {/* Text Area */} -
- - # - - {channel.name} + return ( +
+
+ + + +
+ + {!isCollapsed && ( +
+ {category.channels.map(renderChannelItem)}
- - {/* Gear Button */} - {!channel.isDefault && ( - - )} - - ); - })} -
+ )} +
+ ); + })}
diff --git a/lib/context/ServerContext.tsx b/lib/context/ServerContext.tsx index 935d52a..6abe475 100644 --- a/lib/context/ServerContext.tsx +++ b/lib/context/ServerContext.tsx @@ -6,45 +6,29 @@ "use client"; import { createContext, useContext, useState } from "react"; -import type { Server, Channel } from "@/db/schema"; +import type { Server, Channel, Category } from "@/db/schema"; -/** - * Type definition representing a server entity along with its associated channels array. - */ -export type ServerWithChannels = Server & { channels: Channel[] }; +/** Type definition representing a server entity along with its associated channels array. */ +export type ServerWithChannels = Server & { + channels: Channel[]; + categories: Category[]; +}; -/** - * Represents a member within a server. - * - * @interface ServerMember - * @property {string} id - The unique identifier of the server member. - * @property {string} name - The display name of the server member. - * @property {boolean} [isOnline] - Optional flag indicating whether the member is currently online. - */ +/** Represents a member within a server. */ export interface ServerMember { id: string; name: string; isOnline?: boolean; } -/** - * Interface defining the shape of the ServerContext state and update handlers. - * - * @interface ServerContextType - * @property {ServerWithChannels | null} activeServer - The currently active server instance with its associated channels. - * @property {(server: ServerWithChannels | null) => void} setActiveServer - State setter function for updating the active server. - * @property {(channel: Channel) => void} addChannel - Function to add a new channel to the active server. - * @property {(channelId: string) => void} removeChannel - Function to remove a channel by its identifier. - * @property {(channel: Channel) => void} updateChannel - Function to update an existing channel. - * @property {ServerMember[]} members - The list of members belonging to the active server. - * @property {(members: ServerMember[]) => void} setMembers - State setter function for updating the server members list. - */ +/** Interface defining the shape of the ServerContext state and update handlers. */ interface ServerContextType { activeServer: ServerWithChannels | null; setActiveServer: (server: ServerWithChannels | null) => void; addChannel: (channel: Channel) => void; removeChannel: (channelId: string) => void; updateChannel: (channel: Channel) => void; + addCategory: (category: Category) => void; members: ServerMember[]; setMembers: (members: ServerMember[]) => void; } @@ -55,68 +39,50 @@ const ServerContext = createContext({ addChannel: () => {}, removeChannel: () => {}, updateChannel: () => {}, + addCategory: () => {}, members: [], setMembers: () => {}, }); -/** - * Provider component that wraps the application layout to provide global access to active server state and member listings. - * - * @param {Object} props - React component properties. - * @param {React.ReactNode} props.children - The child components wrapped by the provider. - * @returns {JSX.Element} The rendered React provider wrapping the child elements. - */ +/** Provider component that wraps the application layout to provide global access to active server state and member listings. */ export function ServerProvider({ children }: { children: React.ReactNode }) { const [activeServer, setActiveServer] = useState( null, ); const [members, setMembers] = useState([]); - /** - * Helper function to update channels within the active server state. - * - * @function updateChannels - * @param {(channels: Channel[]) => Channel[]} fn - The updater function receiving current channels and returning new channels. - * @returns {void} - */ + /** Helper function to update channels within the active server state. */ const updateChannels = (fn: (channels: Channel[]) => Channel[]) => { setActiveServer((prev) => prev ? { ...prev, channels: fn(prev.channels) } : prev, ); }; - /** - * Adds a new channel to the active server. - * - * @function addChannel - * @param {Channel} channel - The channel object to add. - * @returns {void} - */ + /** Adds a new channel to the active server. */ const addChannel = (channel: Channel) => updateChannels((prev) => [...prev, channel]); - /** - * Removes a channel from the active server by its identifier. - * - * @function removeChannel - * @param {string} channelId - The unique identifier of the channel to remove. - * @returns {void} - */ + /** Removes a channel from the active server by its identifier. */ const removeChannel = (channelId: string) => updateChannels((prev) => prev.filter((c) => c.id !== channelId)); - /** - * Updates an existing channel within the active server. - * - * @function updateChannel - * @param {Channel} updatedChannel - The updated channel object. - * @returns {void} - */ + /** Updates an existing channel within the active server. */ const updateChannel = (updatedChannel: Channel) => updateChannels((prev) => prev.map((c) => (c.id === updatedChannel.id ? updatedChannel : c)), ); + const addCategory = (category: Category) => { + setActiveServer((prev) => + prev + ? { + ...prev, + categories: [...(prev.categories || []), category], + } + : prev, + ); + }; + return ( } The server record with nested channels array, or null if the user is not a member, the server does not exist, or an invalid ID was provided. - */ +/** Retrieves a server along with its channels and categories sorted chronologically if the specified user is a verified member. */ export async function getServerWithChannels(serverId: string, userId: string) { if ( !uuidSchema.safeParse(serverId).success || @@ -39,6 +32,10 @@ export async function getServerWithChannels(serverId: string, userId: string) { channels: { orderBy: (channels, { asc }) => [asc(channels.createdAt)], }, + // NEU: Categories mitladen + categories: { + orderBy: (categories, { asc }) => [asc(categories.createdAt)], + }, }, }); @@ -49,12 +46,7 @@ export async function getServerWithChannels(serverId: string, userId: string) { } } -/** - * 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>} An array of server objects associated with the user. - */ +/** Fetches all servers that the specified user belongs to, including each server's sorted channels and categories list. */ export async function getUserServers(userId: string) { if (!uuidSchema.safeParse(userId).success) { return []; @@ -69,6 +61,10 @@ export async function getUserServers(userId: string) { channels: { orderBy: (channels, { asc }) => [asc(channels.createdAt)], }, + // NEU: Categories mitladen + categories: { + orderBy: (categories, { asc }) => [asc(categories.createdAt)], + }, }, }, }, @@ -83,12 +79,7 @@ export async function getUserServers(userId: string) { } } -/** - * Retrieves a single server by its ID without checking membership. - * - * @param {string} serverId - The unique identifier of the server to retrieve. - * @returns {Promise} The server record or null if not found/invalid ID. - */ +/** Retrieves a single server by its ID without checking membership. */ export async function getServerById(serverId: string) { if (!uuidSchema.safeParse(serverId).success) { return null;