Refine Header with improved layout, update ChannelSidebar with channel navigation and ChatPage integration.
301 lines
15 KiB
TypeScript
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>
|
|
);
|
|
});
|