refactor(ui): split ChannelSidebar into smaller modular components
All checks were successful
Deploy Waveform to VPS / deploy (push) Successful in 6m49s

This commit is contained in:
Chneemann 2026-09-14 19:24:51 +02:00
parent 0cb26f62f0
commit ff54346a35
No known key found for this signature in database
3 changed files with 212 additions and 183 deletions

View file

@ -0,0 +1,113 @@
/**
* @file components/sidebar/CategorySection.tsx
* @description Sub-component for rendering a category group and its list of channels.
*/
"use client";
import { useState } from "react";
import { ChevronDown, ChevronRight, Plus, Settings } from "lucide-react";
import { ChannelItem } from "./ChannelItem";
import type { Channel } from "@/db/schema";
/** Props for the CategorySection component. */
interface CategorySectionProps {
title: string;
channels: Channel[];
currentChannelId: string;
serverId: string;
onCloseNav: () => void;
onCreateChannel: () => void;
onEditChannel: (channel: Channel) => void;
onEditCategory?: () => void;
}
/** Renders a collapsible category section containing channels and contextual actions. */
export function CategorySection({
title,
channels,
currentChannelId,
serverId,
onCloseNav,
onCreateChannel,
onEditChannel,
onEditCategory,
}: CategorySectionProps) {
const [isCollapsed, setIsCollapsed] = useState(false);
/** Handles closing mobile navigation when clicking a channel. */
const handleChannelClick = () => {
if (!window.matchMedia("(min-width: 768px)").matches) {
onCloseNav();
}
};
/** Opens settings for a specific channel. */
const handleOpenChannelSettings = (e: React.MouseEvent, channel: Channel) => {
e.preventDefault();
e.stopPropagation();
onEditChannel(channel);
};
/** Opens settings for the current category. */
const handleOpenCategorySettings = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onEditCategory?.();
};
return (
<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={() => setIsCollapsed((prev) => !prev)}
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">{title}</span>
</button>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{onEditCategory && (
<button
type="button"
onClick={handleOpenCategorySettings}
className="p-0.5 rounded text-muted hover:text-white hover:bg-surface transition-colors cursor-pointer"
aria-label="Edit category"
>
<Settings className="w-3.5 h-3.5" />
</button>
)}
<button
type="button"
onClick={onCreateChannel}
className="p-0.5 rounded text-muted hover:text-white hover:bg-surface transition-colors cursor-pointer"
aria-label="Create channel in category"
>
<Plus className="w-3.5 h-3.5" />
</button>
</div>
</div>
{!isCollapsed && (
<div className="space-y-0.5 pl-2">
{channels.map((channel) => (
<ChannelItem
key={channel.id}
channel={channel}
serverId={serverId}
isActive={currentChannelId === channel.id}
onChannelClick={handleChannelClick}
onOpenSettings={handleOpenChannelSettings}
/>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,59 @@
/**
* @file components/sidebar/ChannelItem.tsx
* @description Channel item component rendering channel links with active state styling and settings action.
*/
"use client";
import Link from "next/link";
import { Settings } from "lucide-react";
import type { Channel } from "@/db/schema";
/** Props for the ChannelItem component. */
interface ChannelItemProps {
channel: Channel;
serverId: string;
isActive: boolean;
onChannelClick: () => void;
onOpenSettings: (e: React.MouseEvent, channel: Channel) => void;
}
/** Renders an individual channel item link with an optional settings trigger. */
export function ChannelItem({
channel,
serverId,
isActive,
onChannelClick,
onOpenSettings,
}: ChannelItemProps) {
return (
<Link
href={`/servers/${serverId}/channels/${channel.id}`}
onClick={onChannelClick}
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) => onOpenSettings(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>
);
}

View file

@ -1,6 +1,6 @@
/** /**
* @file components/sidebar/ChannelSidebar.tsx * @file components/sidebar/ChannelSidebar.tsx
* @description Channel sidebar component listing categories and channels with actions for creation and editing. * @description Channel sidebar component rendering categories, channels, and modals for creation and editing.
*/ */
"use client"; "use client";
@ -13,32 +13,20 @@ import { CreateChannelModal } from "@/components/modals/CreateChannelModal";
import { CreateCategoryModal } from "@/components/modals/CreateCategoryModal"; import { CreateCategoryModal } from "@/components/modals/CreateCategoryModal";
import { EditChannelModal } from "@/components/modals/EditChannelModal"; import { EditChannelModal } from "@/components/modals/EditChannelModal";
import { EditCategoryModal } from "@/components/modals/EditCategoryModal"; import { EditCategoryModal } from "@/components/modals/EditCategoryModal";
import { import { CategorySection } from "./CategorySection";
ChevronDown, import { FolderPlus, PanelLeftClose } from "lucide-react";
ChevronRight,
FolderPlus,
PanelLeftClose,
Plus,
Settings,
} from "lucide-react";
import type { Category, Channel } from "@/db/schema"; import type { Category, Channel } from "@/db/schema";
import Link from "next/link";
/** Renders the channel navigation sidebar for the active server. */ /** Renders the channel sidebar navigation for the active server. */
export function ChannelSidebar() { export function ChannelSidebar() {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isCreateChannelOpen, setIsCreateChannelOpen] = useState(false);
const [isCreateCategoryModalOpen, setIsCreateCategoryModalOpen] = const [isCreateCategoryOpen, setIsCreateCategoryOpen] = useState(false);
useState(false);
const [editingChannel, setEditingChannel] = useState<Channel | null>(null); const [editingChannel, setEditingChannel] = useState<Channel | null>(null);
const [editingCategory, setEditingCategory] = useState<Category | null>(null); const [editingCategory, setEditingCategory] = useState<Category | null>(null);
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>( const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(
null, null,
); );
const [collapsedCategories, setCollapsedCategories] = useState<
Record<string, boolean>
>({});
const params = useParams(); const params = useParams();
const currentChannelId = params?.channelId as string; const currentChannelId = params?.channelId as string;
const { activeServer } = useActiveServer(); const { activeServer } = useActiveServer();
@ -46,100 +34,30 @@ export function ChannelSidebar() {
if (!activeServer) return null; if (!activeServer) return null;
/** Toggles the collapsed state of a channel category. */ /** Opens the create channel modal for an optional category. */
const toggleCategory = (categoryId: string) => { const openCreateChannel = (catId: string | null = null) => {
setCollapsedCategories((prev) => ({ setSelectedCategoryId(catId);
...prev, setIsCreateChannelOpen(true);
[categoryId]: !prev[categoryId],
}));
};
/** Handles mobile navigation closure when clicking a channel link. */
const handleChannelClick = () => {
if (!window.matchMedia("(min-width: 768px)").matches) {
closeNav();
}
};
/** Opens the edit channel modal for a specific channel. */
const handleOpenChannelSettings = (e: React.MouseEvent, channel: Channel) => {
e.preventDefault();
e.stopPropagation();
setEditingChannel(channel);
};
/** Opens the edit category modal for a specific category. */
const handleOpenCategorySettings = (
e: React.MouseEvent,
category: Category,
) => {
e.preventDefault();
e.stopPropagation();
setEditingCategory(category);
};
/** 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( const uncategorizedChannels = activeServer.channels.filter(
(c) => !c.categoryId, (c) => !c.categoryId,
); );
const categories = (activeServer.categories || []).map((cat) => ({
const categoriesList = (activeServer.categories || []).map((cat) => ({
...cat, ...cat,
channels: activeServer.channels.filter((c) => c.categoryId === cat.id), 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) => handleOpenChannelSettings(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 ( return (
<> <>
<div className="flex-1 w-full bg-surface/50 border-r border-background flex flex-col h-full min-w-0 overflow-hidden"> <div className="flex-1 w-full bg-surface/50 border-r border-background flex flex-col h-full min-w-0 overflow-hidden">
{/* Server Header */} {/* Header */}
<div className="h-14 border-b border-background flex items-center justify-between px-4 font-bold text-white shadow-sm shrink-0"> <div className="h-14 border-b border-background flex items-center justify-between px-4 font-bold text-white shadow-sm shrink-0">
<span className="truncate">{activeServer.name}</span> <span className="truncate">{activeServer.name}</span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
type="button" type="button"
onClick={() => setIsCreateCategoryModalOpen(true)} onClick={() => setIsCreateCategoryOpen(true)}
title="Create Category"
className="p-1.5 rounded-md text-muted hover:text-white hover:bg-surface transition-colors cursor-pointer shrink-0" className="p-1.5 rounded-md text-muted hover:text-white hover:bg-surface transition-colors cursor-pointer shrink-0"
> >
<FolderPlus className="w-4 h-4" /> <FolderPlus className="w-4 h-4" />
@ -147,7 +65,6 @@ export function ChannelSidebar() {
<button <button
type="button" type="button"
onClick={toggleNav} onClick={toggleNav}
title="Collapse the sidebar"
className="p-1.5 rounded-md text-muted hover:text-white hover:bg-surface transition-colors cursor-pointer shrink-0" className="p-1.5 rounded-md text-muted hover:text-white hover:bg-surface transition-colors cursor-pointer shrink-0"
> >
<PanelLeftClose className="w-5 h-5" /> <PanelLeftClose className="w-5 h-5" />
@ -155,111 +72,51 @@ export function ChannelSidebar() {
</div> </div>
</div> </div>
{/* Channel List */} {/* Content */}
<div className="flex-1 overflow-y-auto p-3 space-y-4 min-w-0"> <div className="flex-1 overflow-y-auto p-3 space-y-4 min-w-0">
<div className="space-y-1"> <CategorySection
<div className="flex items-center justify-between text-xs font-semibold text-muted px-1 py-1 uppercase tracking-wider group"> title="Text Channels"
<button channels={uncategorizedChannels}
type="button" currentChannelId={currentChannelId}
onClick={() => toggleCategory("uncategorized")} serverId={activeServer.id}
className="flex items-center gap-1 hover:text-white transition-colors cursor-pointer min-w-0 truncate" onCloseNav={closeNav}
> onCreateChannel={() => openCreateChannel(null)}
{collapsedCategories["uncategorized"] ? ( onEditChannel={setEditingChannel}
<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 {categories.map((category) => (
type="button" <CategorySection
onClick={() => handleOpenCreateModal(null)} key={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" title={category.name}
aria-label="Create channel" channels={category.channels}
> currentChannelId={currentChannelId}
<Plus className="w-3.5 h-3.5" /> serverId={activeServer.id}
</button> onCloseNav={closeNav}
</div> onCreateChannel={() => openCreateChannel(category.id)}
onEditChannel={setEditingChannel}
{!collapsedCategories["uncategorized"] && ( onEditCategory={() => setEditingCategory(category)}
<div className="space-y-0.5 pl-2"> />
{uncategorizedChannels.map(renderChannelItem)} ))}
</div>
)}
</div>
{/* Custom Categories */}
{categoriesList.map((category) => {
const isCollapsed = collapsedCategories[category.id];
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>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={(e) => handleOpenCategorySettings(e, category)}
className="p-0.5 rounded text-muted hover:text-white hover:bg-surface transition-colors cursor-pointer"
aria-label="Edit category"
>
<Settings className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => handleOpenCreateModal(category.id)}
className="p-0.5 rounded text-muted hover:text-white hover:bg-surface transition-colors cursor-pointer"
aria-label="Create channel in category"
>
<Plus className="w-3.5 h-3.5" />
</button>
</div>
</div>
{!isCollapsed && (
<div className="space-y-0.5 pl-2">
{category.channels.map(renderChannelItem)}
</div>
)}
</div>
);
})}
</div> </div>
</div> </div>
{/* Modals */} {/* Modals */}
<CreateCategoryModal <CreateCategoryModal
isOpen={isCreateCategoryModalOpen} isOpen={isCreateCategoryOpen}
onClose={() => setIsCreateCategoryModalOpen(false)} onClose={() => setIsCreateCategoryOpen(false)}
serverId={activeServer.id} serverId={activeServer.id}
/> />
<EditCategoryModal <EditCategoryModal
isOpen={!!editingCategory} isOpen={!!editingCategory}
onClose={() => setEditingCategory(null)} onClose={() => setEditingCategory(null)}
category={editingCategory} category={editingCategory}
/> />
<CreateChannelModal <CreateChannelModal
isOpen={isCreateModalOpen} isOpen={isCreateChannelOpen}
onClose={() => setIsCreateModalOpen(false)} onClose={() => setIsCreateChannelOpen(false)}
serverId={activeServer.id} serverId={activeServer.id}
defaultCategoryId={selectedCategoryId} defaultCategoryId={selectedCategoryId}
/> />
<EditChannelModal <EditChannelModal
isOpen={!!editingChannel} isOpen={!!editingChannel}
onClose={() => setEditingChannel(null)} onClose={() => setEditingChannel(null)}