refactor(ui): split ChannelSidebar into smaller modular components
All checks were successful
Deploy Waveform to VPS / deploy (push) Successful in 6m49s
All checks were successful
Deploy Waveform to VPS / deploy (push) Successful in 6m49s
This commit is contained in:
parent
0cb26f62f0
commit
ff54346a35
3 changed files with 212 additions and 183 deletions
113
components/sidebar/CategorySection.tsx
Normal file
113
components/sidebar/CategorySection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
components/sidebar/ChannelItem.tsx
Normal file
59
components/sidebar/ChannelItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* @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";
|
||||
|
|
@ -13,32 +13,20 @@ import { CreateChannelModal } from "@/components/modals/CreateChannelModal";
|
|||
import { CreateCategoryModal } from "@/components/modals/CreateCategoryModal";
|
||||
import { EditChannelModal } from "@/components/modals/EditChannelModal";
|
||||
import { EditCategoryModal } from "@/components/modals/EditCategoryModal";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FolderPlus,
|
||||
PanelLeftClose,
|
||||
Plus,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { CategorySection } from "./CategorySection";
|
||||
import { FolderPlus, PanelLeftClose } from "lucide-react";
|
||||
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() {
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isCreateCategoryModalOpen, setIsCreateCategoryModalOpen] =
|
||||
useState(false);
|
||||
const [isCreateChannelOpen, setIsCreateChannelOpen] = useState(false);
|
||||
const [isCreateCategoryOpen, setIsCreateCategoryOpen] = useState(false);
|
||||
const [editingChannel, setEditingChannel] = useState<Channel | null>(null);
|
||||
const [editingCategory, setEditingCategory] = useState<Category | 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();
|
||||
|
|
@ -46,100 +34,30 @@ export function ChannelSidebar() {
|
|||
|
||||
if (!activeServer) return null;
|
||||
|
||||
/** 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 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);
|
||||
/** Opens the create channel modal for an optional category. */
|
||||
const openCreateChannel = (catId: string | null = null) => {
|
||||
setSelectedCategoryId(catId);
|
||||
setIsCreateChannelOpen(true);
|
||||
};
|
||||
|
||||
const uncategorizedChannels = activeServer.channels.filter(
|
||||
(c) => !c.categoryId,
|
||||
);
|
||||
|
||||
const categoriesList = (activeServer.categories || []).map((cat) => ({
|
||||
const categories = (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) => 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 (
|
||||
<>
|
||||
<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">
|
||||
<span className="truncate">{activeServer.name}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreateCategoryModalOpen(true)}
|
||||
title="Create Category"
|
||||
onClick={() => setIsCreateCategoryOpen(true)}
|
||||
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" />
|
||||
|
|
@ -147,7 +65,6 @@ export function ChannelSidebar() {
|
|||
<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 shrink-0"
|
||||
>
|
||||
<PanelLeftClose className="w-5 h-5" />
|
||||
|
|
@ -155,111 +72,51 @@ export function ChannelSidebar() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel List */}
|
||||
{/* Content */}
|
||||
<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>
|
||||
<CategorySection
|
||||
title="Text Channels"
|
||||
channels={uncategorizedChannels}
|
||||
currentChannelId={currentChannelId}
|
||||
serverId={activeServer.id}
|
||||
onCloseNav={closeNav}
|
||||
onCreateChannel={() => openCreateChannel(null)}
|
||||
onEditChannel={setEditingChannel}
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
})}
|
||||
{categories.map((category) => (
|
||||
<CategorySection
|
||||
key={category.id}
|
||||
title={category.name}
|
||||
channels={category.channels}
|
||||
currentChannelId={currentChannelId}
|
||||
serverId={activeServer.id}
|
||||
onCloseNav={closeNav}
|
||||
onCreateChannel={() => openCreateChannel(category.id)}
|
||||
onEditChannel={setEditingChannel}
|
||||
onEditCategory={() => setEditingCategory(category)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
<CreateCategoryModal
|
||||
isOpen={isCreateCategoryModalOpen}
|
||||
onClose={() => setIsCreateCategoryModalOpen(false)}
|
||||
isOpen={isCreateCategoryOpen}
|
||||
onClose={() => setIsCreateCategoryOpen(false)}
|
||||
serverId={activeServer.id}
|
||||
/>
|
||||
|
||||
<EditCategoryModal
|
||||
isOpen={!!editingCategory}
|
||||
onClose={() => setEditingCategory(null)}
|
||||
category={editingCategory}
|
||||
/>
|
||||
|
||||
<CreateChannelModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
isOpen={isCreateChannelOpen}
|
||||
onClose={() => setIsCreateChannelOpen(false)}
|
||||
serverId={activeServer.id}
|
||||
defaultCategoryId={selectedCategoryId}
|
||||
/>
|
||||
|
||||
<EditChannelModal
|
||||
isOpen={!!editingChannel}
|
||||
onClose={() => setEditingChannel(null)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue