gitdataai/src/components/layout/ChannelSidebar.tsx
ZhenYi 31e9bb68ac feat(ui): update Header and ChannelSidebar components
Refine Header with improved layout, update ChannelSidebar
with channel navigation and ChatPage integration.
2026-05-14 23:15:40 +08:00

301 lines
15 KiB
TypeScript

import { memo, useCallback, useMemo, useState } from "react";
import {Link, useLocation, useParams} from "react-router-dom";
import {useRoomsQuery} from "@/hooks/useRoomsQuery";
import {useProjectInfo} from "@/hooks/useProjectInfo";
import {Hash, PanelLeftClose, Plus, Search, Settings} from "lucide-react";
import {CHANNEL_SIDEBAR} from "@/css/layout/styles";
import {ProjectCreateMenuModal} from "@/app/project";
const NAV_ITEMS = [
{
path: "repos",
name: "Repository",
icon: "M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z",
},
{
path: "issues",
name: "Issues",
icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4",
},
{
path: "skills",
name: "Skills",
icon: "M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z",
},
{
path: "board",
name: "Board",
icon: "M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z",
},
{
path: "chat",
name: "Chat",
icon: "M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z",
},
] as const;
interface ChannelSidebarProps {
onCollapse?: () => void;
}
export const ChannelSidebar = memo(function ChannelSidebar({onCollapse}: ChannelSidebarProps) {
const location = useLocation();
const {projectName} = useParams<{ projectName: string }>();
const {data: projectInfo} = useProjectInfo(projectName);
const isProjectMember = !!projectInfo?.role;
const {data: roomsData, isLoading} = useRoomsQuery(isProjectMember ? projectName : undefined);
const [isCreateMenuOpen, setIsCreateMenuOpen] = useState(false);
const rooms = useMemo(() => roomsData?.rooms ?? [], [roomsData?.rooms]);
const categories = useMemo(() => roomsData?.categories ?? [], [roomsData?.categories]);
const pathParts = location.pathname.split("/").filter(Boolean);
const isActive = useCallback((path: string) => {
return pathParts.length >= 2 && pathParts[1] === path;
}, [pathParts]);
const isRoomActive = useCallback((roomId: string) => {
return pathParts.length >= 3 && pathParts[2] === roomId;
}, [pathParts]);
const isSettingsActive = isActive("settings");
const showSettings = projectInfo?.role === "Owner" || projectInfo?.role === "Admin";
const uncategorizedRooms = useMemo(
() => rooms.filter((r) => !r.isMuted && !r.category),
[rooms],
);
const categorizedRooms = useMemo(
() => [...categories]
.sort((a, b) => a.position - b.position)
.map((cat) => ({
...cat,
rooms: rooms.filter((r) => !r.isMuted && r.category === cat.id),
}))
.filter((cat) => cat.rooms.length > 0),
[rooms, categories],
);
return (
<div
className={CHANNEL_SIDEBAR.container}
style={{
backgroundColor: "var(--surface-sidebar)",
borderRight: "0.5px solid var(--border-subtle)",
width: 220,
}}
>
<div
className={CHANNEL_SIDEBAR.header}
style={{borderBottom: "0.5px solid var(--border-subtle)", paddingTop: 16}}
>
<span
className={`${CHANNEL_SIDEBAR.headerTitle} flex-1 truncate`}
style={{color: "var(--text-primary)"}}
>
{projectName || "Project"}
</span>
<div className="flex items-center gap-1">
<button
className={CHANNEL_SIDEBAR.iconButton}
style={{color: "var(--text-secondary)"}}
title="Search"
>
<Search className="w-[14px] h-[14px]"/>
</button>
{isProjectMember && (
<button
onClick={() => setIsCreateMenuOpen(true)}
className={CHANNEL_SIDEBAR.iconButton}
style={{color: "var(--text-secondary)"}}
title="Create new..."
>
<Plus className="w-[14px] h-[14px]"/>
</button>
)}
{onCollapse && (
<button
onClick={onCollapse}
className={CHANNEL_SIDEBAR.iconButton}
style={{color: "var(--text-secondary)"}}
title="Collapse sidebar"
>
<PanelLeftClose className="w-[14px] h-[14px]"/>
</button>
)}
</div>
</div>
<nav className="flex-1 overflow-y-auto py-2">
{NAV_ITEMS.map((item) => {
const active = isActive(item.path);
return (
<Link
key={item.path}
to={`/${projectName}/${item.path}`}
className={CHANNEL_SIDEBAR.channelItem}
style={{
color: active ? "var(--text-primary)" : "var(--text-secondary)",
backgroundColor: active ? "var(--surface-ground)" : "transparent",
fontWeight: active ? 500 : 400,
}}
>
{active && (
<div
className="absolute left-0 top-2 bottom-2 w-[2.5px] rounded-r-sm"
style={{backgroundColor: "var(--accent)"}}
/>
)}
<svg
className={CHANNEL_SIDEBAR.channelItemIcon}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={item.icon}/>
</svg>
<span className={CHANNEL_SIDEBAR.channelItemText}>{item.name}</span>
</Link>
);
})}
{showSettings && (
<Link
to={`/${projectName}/settings`}
className={CHANNEL_SIDEBAR.channelItem}
style={{
color: isSettingsActive ? "var(--text-primary)" : "var(--text-secondary)",
backgroundColor: isSettingsActive ? "var(--surface-ground)" : "transparent",
fontWeight: isSettingsActive ? 500 : 400,
}}
>
{isSettingsActive && (
<div
className="absolute left-0 top-2 bottom-2 w-[2.5px] rounded-r-sm"
style={{backgroundColor: "var(--accent)"}}
/>
)}
<Settings className={CHANNEL_SIDEBAR.channelItemIcon}/>
<span className={CHANNEL_SIDEBAR.channelItemText}>Settings</span>
</Link>
)}
{!isProjectMember ? null : isLoading ? (
<div className="px-4 py-2 text-[var(--text-muted)]">Loading channels...</div>
) : (
<>
{uncategorizedRooms.length > 0 && (
<div className="mt-2">
<div
className={CHANNEL_SIDEBAR.categoryTitle}
style={{
color: "var(--text-muted)",
padding: "6px 16px 4px",
letterSpacing: "0.04em",
}}
>
Channels
</div>
{uncategorizedRooms.map((room) => (
<Link
key={room.id}
to={`/${projectName}/channel/${room.id}`}
className={CHANNEL_SIDEBAR.channelItem}
style={{
color: isRoomActive(room.id)
? "var(--text-primary)"
: "var(--text-secondary)",
backgroundColor: isRoomActive(room.id)
? "var(--surface-ground)"
: "transparent",
fontWeight: isRoomActive(room.id) ? 500 : 400,
}}
>
{isRoomActive(room.id) && (
<div
className="absolute left-0 top-2 bottom-2 w-[2.5px] rounded-r-sm"
style={{backgroundColor: "var(--accent)"}}
/>
)}
<Hash className={CHANNEL_SIDEBAR.channelItemIcon}/>
<span
className={`${CHANNEL_SIDEBAR.channelItemText} flex-1`}>{room.room_name}</span>
{room.unread_count != null && room.unread_count > 0 && (
<span
className="px-1.5 py-0.5 rounded-full text-[11px] font-medium"
style={{
backgroundColor: "var(--destructive)",
color: "white",
}}
>
{room.unread_count > 99 ? "99+" : room.unread_count}
</span>
)}
</Link>
))}
</div>
)}
{categorizedRooms.map((category) => (
<div key={category.id} className="mt-2">
<div
className={CHANNEL_SIDEBAR.categoryTitle}
style={{
color: "var(--text-muted)",
padding: "6px 16px 4px",
letterSpacing: "0.04em",
}}
>
{category.name}
</div>
{category.rooms.map((room) => (
<Link
key={room.id}
to={`/${projectName}/channel/${room.id}`}
className={CHANNEL_SIDEBAR.channelItem}
style={{
color: isRoomActive(room.id)
? "var(--text-primary)"
: "var(--text-secondary)",
backgroundColor: isRoomActive(room.id)
? "var(--surface-ground)"
: "transparent",
fontWeight: isRoomActive(room.id) ? 500 : 400,
}}
>
{isRoomActive(room.id) && (
<div
className="absolute left-0 top-2 bottom-2 w-[2.5px] rounded-r-sm"
style={{backgroundColor: "var(--accent)"}}
/>
)}
<Hash className={CHANNEL_SIDEBAR.channelItemIcon}/>
<span
className={`${CHANNEL_SIDEBAR.channelItemText} flex-1`}>{room.room_name}</span>
{room.unread_count != null && room.unread_count > 0 && (
<span
className="px-1.5 py-0.5 rounded-full text-[11px] font-medium"
style={{
backgroundColor: "var(--destructive)",
color: "white",
}}
>
{room.unread_count > 99 ? "99+" : room.unread_count}
</span>
)}
</Link>
))}
</div>
))}
</>
)}
</nav>
{isCreateMenuOpen && (
<ProjectCreateMenuModal onClose={() => setIsCreateMenuOpen(false)}/>
)}
</div>
);
});