@@ -13,11 +13,29 @@ import {
1313 User ,
1414 FolderPlus ,
1515} from 'lucide-react' ;
16+ import {
17+ DndContext ,
18+ closestCenter ,
19+ KeyboardSensor ,
20+ PointerSensor ,
21+ useSensor ,
22+ useSensors ,
23+ DragEndEvent ,
24+ } from '@dnd-kit/core' ;
25+ import {
26+ arrayMove ,
27+ SortableContext ,
28+ sortableKeyboardCoordinates ,
29+ useSortable ,
30+ verticalListSortingStrategy ,
31+ } from '@dnd-kit/sortable' ;
32+ import { CSS } from '@dnd-kit/utilities' ;
1633import { useServerStore } from '@/stores/serverStore' ;
1734import { useChannelStore , Channel , Category } from '@/stores/channelStore' ;
1835import { useUIStore } from '@/stores/uiStore' ;
1936import { useVoiceStore } from '@/stores/voiceStore' ;
20- import { VoiceChannelUsers , VoiceJoinButton } from '@/components/voice' ;
37+ import { VoiceChannelUsers } from '@/components/voice' ;
38+ import { apiClient } from '@/lib/api-client' ;
2139
2240function ChannelIcon ( { type } : { type : Channel [ 'type' ] } ) {
2341 switch ( type ) {
@@ -30,24 +48,40 @@ function ChannelIcon({ type }: { type: Channel['type'] }) {
3048 }
3149}
3250
33- interface ChannelItemProps {
51+ interface SortableChannelItemProps {
3452 channel : Channel ;
3553 isSelected : boolean ;
3654 onClick : ( ) => void ;
3755 onSettingsClick : ( ) => void ;
3856 serverId : string ;
3957}
4058
41- function ChannelItem ( { channel, isSelected, onClick, onSettingsClick, serverId } : ChannelItemProps ) {
59+ function SortableChannelItem ( { channel, isSelected, onClick, onSettingsClick, serverId } : SortableChannelItemProps ) {
60+ const {
61+ attributes,
62+ listeners,
63+ setNodeRef,
64+ transform,
65+ transition,
66+ isDragging,
67+ } = useSortable ( { id : channel . id } ) ;
68+
69+ const style = {
70+ transform : CSS . Transform . toString ( transform ) ,
71+ transition,
72+ opacity : isDragging ? 0.5 : 1 ,
73+ zIndex : isDragging ? 1000 : undefined ,
74+ } ;
75+
4276 const hasUnread = channel . unreadCount && channel . unreadCount > 0 ;
4377 const { isConnected, currentChannelId, getUsersByChannel } = useVoiceStore ( ) ;
4478 const voiceUsers = channel . type === 'voice' ? getUsersByChannel ( channel . id ) : [ ] ;
4579 const isInVoiceChannel = isConnected && currentChannelId === channel . id ;
4680
47- // For voice channels, show users and join button
81+ // For voice channels, show users - click navigates to voice view
4882 if ( channel . type === 'voice' ) {
4983 return (
50- < div className = "w-full" >
84+ < div ref = { setNodeRef } style = { style } className = "w-full" >
5185 < div
5286 onClick = { onClick }
5387 onContextMenu = { ( e ) => {
@@ -59,9 +93,14 @@ function ChannelItem({ channel, isSelected, onClick, onSettingsClick, serverId }
5993 ? 'bg-accent-muted text-accent'
6094 : 'text-foreground-muted hover:bg-background-surface hover:text-foreground'
6195 } `}
96+ { ...attributes }
97+ { ...listeners }
6298 >
6399 < ChannelIcon type = { channel . type } />
64100 < span className = "truncate flex-1 text-left" > { channel . name } </ span >
101+ { isInVoiceChannel && (
102+ < span className = "w-2 h-2 rounded-full bg-success animate-pulse" />
103+ ) }
65104 < div className = "hidden group-hover:flex items-center gap-0.5" >
66105 < button
67106 onClick = { ( e ) => {
@@ -76,35 +115,24 @@ function ChannelItem({ channel, isSelected, onClick, onSettingsClick, serverId }
76115 </ div >
77116 </ div >
78117
79- { /* Voice users in this channel */ }
118+ { /* Voice users in this channel - avatar stack */ }
80119 { voiceUsers . length > 0 && (
81120 < VoiceChannelUsers channelId = { channel . id } />
82121 ) }
83-
84- { /* Join button if not connected */ }
85- { ! isInVoiceChannel && (
86- < VoiceJoinButton channelId = { channel . id } serverId = { serverId } />
87- ) }
88122 </ div >
89123 ) ;
90124 }
91125
92126 // Text channels
93127 return (
94128 < div
129+ ref = { setNodeRef }
130+ style = { style }
95131 onClick = { onClick }
96132 onContextMenu = { ( e ) => {
97133 e . preventDefault ( ) ;
98134 onSettingsClick ( ) ;
99135 } }
100- onKeyDown = { ( e ) => {
101- if ( e . key === 'Enter' || e . key === ' ' ) {
102- e . preventDefault ( ) ;
103- onClick ( ) ;
104- }
105- } }
106- role = "button"
107- tabIndex = { 0 }
108136 className = { `w-full flex items-center gap-1.5 px-2 py-1.5 rounded text-sm group transition-colors cursor-pointer ${
109137 isSelected
110138 ? 'bg-accent-muted text-accent'
@@ -113,6 +141,8 @@ function ChannelItem({ channel, isSelected, onClick, onSettingsClick, serverId }
113141 : 'text-foreground-muted hover:bg-background-surface hover:text-foreground'
114142 } `}
115143 aria-current = { isSelected ? 'page' : undefined }
144+ { ...attributes }
145+ { ...listeners }
116146 >
117147 < ChannelIcon type = { channel . type } />
118148 < span className = "truncate flex-1 text-left" > { channel . name } </ span >
@@ -154,6 +184,7 @@ interface CategorySectionProps {
154184 serverId : string ;
155185 onChannelClick : ( channel : Channel ) => void ;
156186 onChannelSettings : ( channel : Channel ) => void ;
187+ onDragEnd : ( event : DragEndEvent , categoryId : string ) => void ;
157188}
158189
159190function CategorySection ( {
@@ -163,12 +194,26 @@ function CategorySection({
163194 serverId,
164195 onChannelClick,
165196 onChannelSettings,
197+ onDragEnd,
166198} : CategorySectionProps ) {
167199 const { toggleCategoryCollapse } = useChannelStore ( ) ;
168200 const { openCreateChannelModal } = useUIStore ( ) ;
169201
202+ const sensors = useSensors (
203+ useSensor ( PointerSensor , {
204+ activationConstraint : {
205+ distance : 8 ,
206+ } ,
207+ } ) ,
208+ useSensor ( KeyboardSensor , {
209+ coordinateGetter : sortableKeyboardCoordinates ,
210+ } )
211+ ) ;
212+
170213 if ( channels . length === 0 ) return null ;
171214
215+ const channelIds = channels . map ( ( c ) => c . id ) ;
216+
172217 return (
173218 < div className = "mb-2" >
174219 < button
@@ -195,18 +240,26 @@ function CategorySection({
195240 </ button >
196241
197242 { ! category . isCollapsed && (
198- < div className = "space-y-0.5" >
199- { channels . map ( ( channel ) => (
200- < ChannelItem
201- key = { channel . id }
202- channel = { channel }
203- isSelected = { currentChannelId === channel . id }
204- onClick = { ( ) => onChannelClick ( channel ) }
205- onSettingsClick = { ( ) => onChannelSettings ( channel ) }
206- serverId = { serverId }
207- />
208- ) ) }
209- </ div >
243+ < DndContext
244+ sensors = { sensors }
245+ collisionDetection = { closestCenter }
246+ onDragEnd = { ( event ) => onDragEnd ( event , category . id ) }
247+ >
248+ < SortableContext items = { channelIds } strategy = { verticalListSortingStrategy } >
249+ < div className = "space-y-0.5" >
250+ { channels . map ( ( channel ) => (
251+ < SortableChannelItem
252+ key = { channel . id }
253+ channel = { channel }
254+ isSelected = { currentChannelId === channel . id }
255+ onClick = { ( ) => onChannelClick ( channel ) }
256+ onSettingsClick = { ( ) => onChannelSettings ( channel ) }
257+ serverId = { serverId }
258+ />
259+ ) ) }
260+ </ div >
261+ </ SortableContext >
262+ </ DndContext >
210263 ) }
211264 </ div >
212265 ) ;
@@ -235,9 +288,9 @@ export default function ChannelSidebar() {
235288 ( channel : Channel ) => {
236289 setCurrentChannel ( channel . id ) ;
237290 clearChannelUnread ( channel . id ) ;
238- if ( channel . type === ' text' ) {
239- router . push ( `/app/servers/ ${ currentServerId } / channels/ ${ channel . id } ` ) ;
240- }
291+ // Navigate for both text and voice channels
292+ // Voice channels will render VoiceChannelView which handles joining
293+ router . push ( `/app/servers/ ${ currentServerId } /channels/ ${ channel . id } ` ) ;
241294 } ,
242295 [ setCurrentChannel , clearChannelUnread , router , currentServerId ]
243296 ) ;
@@ -249,6 +302,34 @@ export default function ChannelSidebar() {
249302 [ openEditChannelModal ]
250303 ) ;
251304
305+ const handleDragEnd = useCallback (
306+ async ( event : DragEndEvent , categoryId : string ) => {
307+ const { active, over } = event ;
308+
309+ if ( over && active . id !== over . id && currentServerId ) {
310+ const categoryChannels = getChannelsForCategory ( categoryId ) ;
311+ const channelIds = categoryChannels . map ( ( c ) => c . id ) ;
312+ const oldIndex = channelIds . indexOf ( active . id as string ) ;
313+ const newIndex = channelIds . indexOf ( over . id as string ) ;
314+
315+ const newOrder = arrayMove ( channelIds , oldIndex , newIndex ) ;
316+
317+ // Persist to backend
318+ try {
319+ const positions = newOrder . map ( ( id , index ) => ( {
320+ id,
321+ position : index ,
322+ categoryId : categoryId || null ,
323+ } ) ) ;
324+ await apiClient . updateChannelPositions ( currentServerId , positions ) ;
325+ } catch ( error ) {
326+ console . error ( 'Failed to update channel positions:' , error ) ;
327+ }
328+ }
329+ } ,
330+ [ currentServerId , channels ]
331+ ) ;
332+
252333 if ( ! isChannelSidebarOpen ) return null ;
253334
254335 // No server selected - show DMs view
@@ -293,11 +374,11 @@ export default function ChannelSidebar() {
293374 </ div >
294375 ) : (
295376 < >
296- { /* Uncategorized channels */ }
377+ { /* Uncategorized channels - not sortable for simplicity */ }
297378 { getChannelsForCategory ( null ) . length > 0 && (
298379 < div className = "space-y-0.5 mb-2" >
299380 { getChannelsForCategory ( null ) . map ( ( channel ) => (
300- < ChannelItem
381+ < SortableChannelItem
301382 key = { channel . id }
302383 channel = { channel }
303384 isSelected = { currentChannelId === channel . id }
@@ -309,7 +390,7 @@ export default function ChannelSidebar() {
309390 </ div >
310391 ) }
311392
312- { /* Categories */ }
393+ { /* Categories with sortable channels */ }
313394 { categories
314395 . sort ( ( a , b ) => a . position - b . position )
315396 . map ( ( category ) => (
@@ -321,6 +402,7 @@ export default function ChannelSidebar() {
321402 serverId = { currentServerId }
322403 onChannelClick = { handleChannelClick }
323404 onChannelSettings = { handleChannelSettings }
405+ onDragEnd = { handleDragEnd }
324406 />
325407 ) ) }
326408
0 commit comments