From b73cc8d421288d0f22af0b020d21e0b8ee92966e Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Sun, 19 Apr 2026 01:12:38 +0800 Subject: [PATCH] refactor(room): Discord-style UI redesign for channel sidebar and member list --- src/app/project/room.tsx | 18 +- src/components/room/DiscordChannelSidebar.tsx | 589 ++++++++++++------ src/contexts/room-context.tsx | 92 +-- 3 files changed, 484 insertions(+), 215 deletions(-) diff --git a/src/app/project/room.tsx b/src/app/project/room.tsx index bcbcb27..0738927 100644 --- a/src/app/project/room.tsx +++ b/src/app/project/room.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import { useParams } from 'react-router-dom'; import { useRoom, useUser } from '@/contexts'; import { RoomProvider } from '@/contexts'; @@ -18,6 +18,8 @@ function ProjectRoomInner() { const { rooms, roomsLoading, + categories, + createCategory, activeRoom, activeRoomId, setActiveRoom, @@ -89,6 +91,17 @@ function ProjectRoomInner() { } }; + const handleCreateCategory = useCallback(async (name: string) => { + await createCategory(name); + }, [createCategory]); + + const handleMoveRoomToCategory = useCallback( + async (roomId: string, categoryId: string | null) => { + await updateRoom(roomId, undefined, undefined, categoryId ?? undefined); + }, + [updateRoom], + ); + const isAdmin = !!user && members.some( (m) => m.user === user.uid && @@ -112,6 +125,9 @@ function ProjectRoomInner() { selectedRoomId={activeRoomId} onSelectRoom={handleSelectRoom} onCreateRoom={handleOpenCreate} + categories={categories.map((c) => ({ id: c.id, name: c.name }))} + onCreateCategory={handleCreateCategory} + onMoveRoomToCategory={handleMoveRoomToCategory} /> {/* Main chat area */} diff --git a/src/components/room/DiscordChannelSidebar.tsx b/src/components/room/DiscordChannelSidebar.tsx index 3be474d..d90527f 100644 --- a/src/components/room/DiscordChannelSidebar.tsx +++ b/src/components/room/DiscordChannelSidebar.tsx @@ -1,198 +1,437 @@ 'use client'; -import {memo, useCallback, useState} from 'react'; -import type {RoomWithCategory} from '@/contexts/room-context'; -import {Button} from '@/components/ui/button'; -import {cn} from '@/lib/utils'; -import {ChevronDown, ChevronRight, Hash, Lock, Plus, Settings} from 'lucide-react'; +import { memo, useCallback, useMemo, useState } from 'react'; +import type { RoomWithCategory } from '@/contexts/room-context'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { cn } from '@/lib/utils'; +import { ChevronDown, ChevronRight, Hash, Lock, Plus, X, GripVertical } from 'lucide-react'; +import { + DndContext, + closestCorners, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, + type UniqueIdentifier, +} from '@dnd-kit/core'; +import { + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, + useSortable, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +/* ── Types ─────────────────────────────────────────────────────── */ interface RoomWithUnread extends RoomWithCategory { - unread_count?: number; + unread_count?: number; } interface DiscordChannelSidebarProps { - projectName: string; - rooms: RoomWithCategory[]; - selectedRoomId: string | null; - onSelectRoom: (room: RoomWithCategory) => void; - onCreateRoom: () => void; - onOpenSettings?: () => void; + projectName: string; + rooms: RoomWithCategory[]; + selectedRoomId: string | null; + onSelectRoom: (room: RoomWithCategory) => void; + onCreateRoom: () => void; + categories: Array<{ id: string; name: string }>; + onCreateCategory: (name: string) => Promise; + onMoveRoomToCategory: (roomId: string, categoryId: string | null) => void; + onOpenSettings?: () => void; } -interface ChannelGroupProps { - categoryName: string; - rooms: RoomWithCategory[]; - selectedRoomId: string | null; - onSelectRoom: (room: RoomWithCategory) => void; - isCollapsed?: boolean; - onToggle?: () => void; -} +type CatName = string; + +const DRAG_PREFIX = 'room:'; + +/* ── Draggable row ─────────────────────────────────────────────── */ + +const DraggableRow = memo(function DraggableRow({ + roomId, + children, +}: { + roomId: string; + children: React.ReactNode; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: `${DRAG_PREFIX}${roomId}` }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
  • +
    + {children} +
    +
  • + ); +}); + +/* ── Single room button ────────────────────────────────────────── */ + +const RoomButton = memo(function RoomButton({ + room, + selectedRoomId, + onSelectRoom, +}: { + room: RoomWithCategory; + selectedRoomId: string | null; + onSelectRoom: (room: RoomWithCategory) => void; +}) { + const isSelected = selectedRoomId === room.id; + const unreadCount = (room as RoomWithUnread).unread_count ?? 0; + + return ( + + ); +}); + +/* ── Category group ────────────────────────────────────────────── */ const ChannelGroup = memo(function ChannelGroup({ - categoryName, - rooms, - selectedRoomId, - onSelectRoom, - isCollapsed, - onToggle, - }: ChannelGroupProps) { - if (rooms.length === 0) return null; + categoryName, + rooms, + selectedRoomId, + onSelectRoom, + isCollapsed, + onToggle, + canReceiveDrops, +}: { + categoryName: string; + rooms: RoomWithCategory[]; + selectedRoomId: string | null; + onSelectRoom: (room: RoomWithCategory) => void; + isCollapsed?: boolean; + onToggle?: () => void; + canReceiveDrops?: true; +}) { + const ids: UniqueIdentifier[] = rooms.map((r) => `${DRAG_PREFIX}${r.id}`); - return ( -
    - + return ( +
    e.preventDefault()} + onDrop={canReceiveDrops ? () => undefined /* handled by DnD */ : undefined} + > + - {!isCollapsed && ( -
      - {rooms.map((room) => { - const isSelected = selectedRoomId === room.id; - const unreadCount = (room as RoomWithUnread).unread_count ?? 0; - return ( -
    • - -
    • - ); - })} -
    - )} -
    - ); + {!isCollapsed && ( + + )} +
    + ); }); +/* ── List content ──────────────────────────────────────────────── */ + +function ChannelListContent({ + rooms, + selectedRoomId, + onSelectRoom, + categories, + uncategorizedRooms, + categorizedRooms, + collapsedState, + toggleCategory, + onMoveRoom, +}: { + rooms: RoomWithCategory[]; + selectedRoomId: string | null; + onSelectRoom: (room: RoomWithCategory) => void; + categories: Array<{ id: string; name: string }>; + uncategorizedRooms: RoomWithCategory[]; + categorizedRooms: Map; + collapsedState: Record; + toggleCategory: (name: string) => void; + onMoveRoom: (roomId: string, catId: string | null) => void; +}) { + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + // Build flat list of all category names in display order for collision targets + const sortedCatNames = useMemo( + () => Array.from(categorizedRooms.keys()).sort(), + [categorizedRooms], + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { over } = event; + if (!over) return; + + const dragId = String(event.active.id); + if (!dragId.startsWith(DRAG_PREFIX)) return; + + const draggedRoomId = dragId.slice(DRAG_PREFIX.length); + + // Dropping onto a category (target id matches a category name) + const targetName = String(over.id); + const targetCat = categories.find((c) => c.name === targetName); + if (targetCat) { + const currentRoom = rooms.find((r) => r.id === draggedRoomId); + if (currentRoom && currentRoom.category_info?.id !== targetCat.id) { + onMoveRoom(draggedRoomId, targetCat.id); + } + return; + } + + // Dropping onto another room's sort position → nothing to change visually + // dnd-kit handles the reorder via SortableContext automatically + }, + [categories, rooms, onMoveRoom], + ); + + return ( + + {/* Uncategorized channels at top */} + {uncategorizedRooms.length > 0 && ( + + )} + + {/* Categorized groups */} + {sortedCatNames.map((catName) => ( + toggleCategory(catName)} + canReceiveDrops + /> + ))} + + ); +} + +/* ── Public component ──────────────────────────────────────────── */ + export const DiscordChannelSidebar = memo(function DiscordChannelSidebar({ - projectName, - rooms, - selectedRoomId, - onSelectRoom, - onCreateRoom, - onOpenSettings, - }: DiscordChannelSidebarProps) { - // Group rooms by category - const uncategorized = rooms.filter((r) => !r.category_info?.name); - const categorized = rooms.filter((r) => r.category_info?.name); + projectName, + rooms, + selectedRoomId, + onSelectRoom, + onCreateRoom, + categories, + onCreateCategory, + onMoveRoomToCategory, + onOpenSettings, +}: DiscordChannelSidebarProps) { + const [collapsed, setCollapsed] = useState>({}); + const toggleCategory = useCallback((name: string) => { + setCollapsed((prev) => ({ ...prev, [name]: !prev[name] })); + }, []); - // Build category map: categoryName → rooms[] - const categoryMap = new Map(); + const [creatingCat, setCreatingCat] = useState(false); + const [newCatName, setNewCatName] = useState(''); + + const handleCreateCategory = useCallback(async () => { + const trimmed = newCatName.trim(); + if (!trimmed) return; + await onCreateCategory(trimmed); + setNewCatName(''); + setCreatingCat(false); + }, [newCatName, onCreateCategory]); + + const handleMoveRoom = useCallback( + (roomId: string, categoryId: string | null) => { + onMoveRoomToCategory(roomId, categoryId); + }, + [onMoveRoomToCategory], + ); + + // Group rooms by category + const uncategorized = useMemo( + () => rooms.filter((r) => !r.category_info?.name), + [rooms], + ); + const categorized = useMemo( + () => rooms.filter((r) => r.category_info?.name), + [rooms], + ); + + const categoryMap = useMemo(() => { + const map = new Map(); for (const room of categorized) { - const name = room.category_info!.name; - if (!categoryMap.has(name)) categoryMap.set(name, []); - categoryMap.get(name)!.push(room); + const name = room.category_info!.name; + if (!map.has(name)) map.set(name, []); + map.get(name)!.push(room); } + return map; + }, [categorized]); - // Collapse state per category - const [collapsed, setCollapsed] = useState>({}); - - const toggleCategory = useCallback((name: string) => { - setCollapsed((prev) => ({...prev, [name]: !prev[name]})); - }, []); - - // Uncategorized channels at top, then alphabetical categories - const sortedCategoryNames = Array.from(categoryMap.keys()).sort(); - - return ( -
    - {/* Header */} -
    -
    - {projectName} -
    -
    - {onOpenSettings && ( - - )} -
    -
    - - {/* Channel list */} -
    - {/* Uncategorized */} - {uncategorized.length > 0 && ( - - )} - - {/* Categorized */} - {sortedCategoryNames.map((catName) => ( - toggleCategory(catName)} - /> - ))} - - {rooms.length === 0 && ( -
    -

    No channels yet

    - -
    - )} -
    - - {/* Footer: user / add channel */} -
    - -
    + return ( +
    + {/* Header */} +
    +
    + {projectName}
    - ); +
    + {onOpenSettings && ( + + )} +
    +
    + + {/* Scrollable list */} +
    + + + {/* Create category */} + {creatingCat ? ( +
    + setNewCatName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleCreateCategory(); + if (e.key === 'Escape') { setCreatingCat(false); setNewCatName(''); } + }} + placeholder="Category name" + className="h-7 text-xs" + /> + + +
    + ) : ( + + )} + + {rooms.length === 0 && ( +
    +

    No channels yet

    + +
    + )} +
    + + {/* Footer */} +
    + +
    +
    + ); }); diff --git a/src/contexts/room-context.tsx b/src/contexts/room-context.tsx index a62c551..5e926ec 100644 --- a/src/contexts/room-context.tsx +++ b/src/contexts/room-context.tsx @@ -439,45 +439,59 @@ export function RoomProvider({ }); } }, - onAiStreamChunk: (chunk) => { - if (chunk.done) { - setStreamingContent((prev) => { - const next = new Map(prev); - next.delete(chunk.message_id); - return next; - }); - setMessages((prev) => - prev.map((m) => - m.id === chunk.message_id - ? { ...m, content: chunk.content, is_streaming: false } - : m, - ), - ); - } else { - setStreamingContent((prev) => { - const next = new Map(prev); - const existing = next.get(chunk.message_id) ?? ''; - next.set(chunk.message_id, existing + chunk.content); - return next; - }); - setMessages((prev) => { - if (prev.some((m) => m.id === chunk.message_id)) { - return prev; - } - const newMsg: MessageWithMeta = { - id: chunk.message_id, - room: chunk.room_id, - seq: 0, - sender_type: 'ai', - content: '', - content_type: 'text', - send_at: new Date().toISOString(), - is_streaming: true, - }; - return [...prev, newMsg]; - }); - } - }, + const onAiStreamChunk = useCallback((chunk: AiStreamChunkPayload) => { + if (chunk.done) { + setStreamingContent((prev) => { + prev.delete(chunk.message_id); + return new Map(prev); + }); + setMessages((prev) => + prev.map((m) => + m.id === chunk.message_id + ? { ...m, content: chunk.content, display_content: chunk.content, is_streaming: false } + : m, + ), + ); + } else { + // Accumulate streaming content in dedicated map + setStreamingContent((prev) => { + const next = new Map(prev); + next.set(chunk.message_id, (next.get(chunk.message_id) ?? '') + chunk.content); + return next; + }); + // Update or insert the AI message with current accumulated content + // Use streamingContent map as source of truth for display during streaming + setStreamingContent((current) => { + const accumulated = current.get(chunk.message_id) ?? ''; + setMessages((prev) => { + const idx = prev.findIndex((m) => m.id === chunk.message_id); + if (idx !== -1) { + const m = prev[idx]; + // Skip render if content hasn't changed (dedup protection) + if (m.content === accumulated && m.is_streaming === true) return prev; + const updated = [...prev]; + updated[idx] = { ...m, content: accumulated, display_content: accumulated }; + return updated; + } + // New message — avoid adding empty content blocks + if (!accumulated) return prev; + const newMsg: MessageWithMeta = { + id: chunk.message_id, + room: chunk.room_id, + seq: 0, + sender_type: 'ai', + content: accumulated, + display_content: accumulated, + content_type: 'text', + send_at: new Date().toISOString(), + is_streaming: true, + }; + return [...prev, newMsg]; + }); + return current; + }); + } + }, []); onRoomReactionUpdated: (payload: RoomReactionUpdatedPayload) => { if (!activeRoomIdRef.current) return; setMessages((prev) => {