feat(channels): display categories and fallback to default text channels
All checks were successful
Deploy Waveform to VPS / deploy (push) Successful in 6m56s

This commit is contained in:
Chneemann 2026-09-13 15:56:53 +02:00
parent 4b9256e570
commit 2321551518
No known key found for this signature in database
3 changed files with 185 additions and 161 deletions

View file

@ -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<Channel | null>(null);
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(
null,
);
const [collapsedCategories, setCollapsedCategories] = useState<
Record<string, boolean>
>({});
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 (
<Link
key={channel.id}
href={`/servers/${activeServer.id}/channels/${channel.id}`}
onClick={handleChannelClick}
prefetch={false}
className={`flex items-center justify-between w-full px-2 py-1.5 rounded-md text-sm transition-all group min-w-0 overflow-hidden ${
isActive
? "bg-accent/50 text-white font-medium"
: "text-muted hover:bg-surface hover:text-white"
}`}
>
<div className="flex items-center gap-2 min-w-0 flex-1 overflow-hidden">
<span className="text-muted group-hover:text-white text-base shrink-0">
#
</span>
<span className="truncate">{channel.name}</span>
</div>
{!channel.isDefault && (
<button
type="button"
onClick={(e) => handleOpenSettings(e, channel)}
className="opacity-0 group-hover:opacity-100 p-1 text-muted hover:text-white focus:outline-none transition-all cursor-pointer shrink-0 ml-2"
aria-label="Channel Settings"
>
<Settings className="w-3.5 h-3.5" />
</button>
)}
</Link>
);
};
return (
<>
<div className="flex-1 w-full bg-surface/50 border-r border-background flex flex-col h-full min-w-0 overflow-hidden">
@ -76,59 +130,77 @@ export function ChannelSidebar() {
</div>
{/* Channel List */}
<div className="flex-1 overflow-y-auto p-3 space-y-1 min-w-0">
<div className="flex items-center justify-between text-xs font-semibold text-muted px-2 py-1 uppercase tracking-wider">
<span>Text Channels</span>
<button
type="button"
onClick={() => setIsCreateModalOpen(true)}
className="p-1 rounded text-muted hover:text-white hover:bg-surface transition-colors cursor-pointer"
aria-label="Create channel"
>
<Plus className="w-4 h-4" />
</button>
<div className="flex-1 overflow-y-auto p-3 space-y-4 min-w-0">
<div className="space-y-1">
<div className="flex items-center justify-between text-xs font-semibold text-muted px-1 py-1 uppercase tracking-wider group">
<button
type="button"
onClick={() => toggleCategory("uncategorized")}
className="flex items-center gap-1 hover:text-white transition-colors cursor-pointer min-w-0 truncate"
>
{collapsedCategories["uncategorized"] ? (
<ChevronRight className="w-3.5 h-3.5 shrink-0" />
) : (
<ChevronDown className="w-3.5 h-3.5 shrink-0" />
)}
<span className="truncate">Text Channels</span>
</button>
<button
type="button"
onClick={() => handleOpenCreateModal(null)}
className="opacity-0 group-hover:opacity-100 p-0.5 rounded text-muted hover:text-white hover:bg-surface transition-colors cursor-pointer"
aria-label="Create channel"
>
<Plus className="w-3.5 h-3.5" />
</button>
</div>
{!collapsedCategories["uncategorized"] && (
<div className="space-y-0.5 pl-2">
{uncategorizedChannels.map(renderChannelItem)}
</div>
)}
</div>
<div className="space-y-0.5 w-full">
{activeServer.channels.map((channel) => {
const isActive = currentChannelId === channel.id;
{/* 2. Custom Kategorien */}
{categoriesList.map((category) => {
const isCollapsed = collapsedCategories[category.id];
return (
<Link
key={channel.id}
href={`/servers/${activeServer.id}/channels/${channel.id}`}
onClick={handleChannelClick}
prefetch={false}
className={`flex items-center justify-between w-full px-2 py-1.5 rounded-md text-sm transition-all group min-w-0 overflow-hidden ${
isActive
? "bg-accent/50 text-white font-medium"
: "text-muted hover:bg-surface hover:text-white"
}`}
>
{/* Text Area */}
<div className="flex items-center gap-2 min-w-0 flex-1 overflow-hidden">
<span className="text-muted group-hover:text-white text-base shrink-0">
#
</span>
<span className="truncate">{channel.name}</span>
return (
<div key={category.id} className="space-y-1">
<div className="flex items-center justify-between text-xs font-semibold text-muted px-1 py-1 uppercase tracking-wider group">
<button
type="button"
onClick={() => toggleCategory(category.id)}
className="flex items-center gap-1 hover:text-white transition-colors cursor-pointer min-w-0 truncate"
>
{isCollapsed ? (
<ChevronRight className="w-3.5 h-3.5 shrink-0" />
) : (
<ChevronDown className="w-3.5 h-3.5 shrink-0" />
)}
<span className="truncate">{category.name}</span>
</button>
<button
type="button"
onClick={() => handleOpenCreateModal(category.id)}
className="opacity-0 group-hover:opacity-100 p-0.5 rounded text-muted hover:text-white hover:bg-surface transition-colors cursor-pointer"
aria-label="Channel in Kategorie erstellen"
>
<Plus className="w-3.5 h-3.5" />
</button>
</div>
{!isCollapsed && (
<div className="space-y-0.5 pl-2">
{category.channels.map(renderChannelItem)}
</div>
{/* Gear Button */}
{!channel.isDefault && (
<button
type="button"
onClick={(e) => handleOpenSettings(e, channel)}
className="opacity-0 group-hover:opacity-100 p-1 text-muted hover:text-white focus:outline-none transition-all cursor-pointer shrink-0 ml-2"
aria-label="Channel Settings"
title="Channel Settings"
>
<Settings className="w-3.5 h-3.5" />
</button>
)}
</Link>
);
})}
</div>
)}
</div>
);
})}
</div>
</div>

View file

@ -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<ServerContextType>({
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<ServerWithChannels | null>(
null,
);
const [members, setMembers] = useState<ServerMember[]>([]);
/**
* 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 (
<ServerContext.Provider
value={{
@ -125,6 +91,7 @@ export function ServerProvider({ children }: { children: React.ReactNode }) {
addChannel,
removeChannel,
updateChannel,
addCategory,
members,
setMembers,
}}
@ -134,13 +101,7 @@ export function ServerProvider({ children }: { children: React.ReactNode }) {
);
}
/**
* Custom hook to access the active server context.
*
* @function useActiveServer
* @throws {Error} Throws an error if used outside of a ServerProvider.
* @returns {ServerContextType} The active server context value.
*/
/** Custom hook to access the active server context. */
export function useActiveServer() {
const context = useContext(ServerContext);
if (!context) {

View file

@ -10,14 +10,7 @@ import { z } from "zod";
const uuidSchema = z.uuid();
/**
* Retrieves a server along with its channels sorted chronologically if the specified user is a verified member.
* Validates UUID formats prior to database execution to prevent database errors.
*
* @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, 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<Array<Object>>} 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<Object | null>} 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;