Submatter/apps/web/components/layout/icon-rail.tsx
varutasu ff01a5f097
Some checks are pending
CI / Lint, type-check, test (push) Waiting to run
Deploy homelab (CT 116) / Deploy to CT 116 (push) Waiting to run
Make workspace Chat durable and ready for source-built deployment
2026-10-29 19:30:20 +00:00

270 lines
8.5 KiB
TypeScript

"use client";
import type { ComponentType } from "react";
import {
CalendarDays,
MessageCircle,
ClipboardList,
FileText,
Home,
LayoutGrid,
MoreHorizontal,
Presentation,
Radar,
Sparkles,
UserPlus,
Users,
} from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
import { cn } from "@/lib/utils";
import { api } from "@/lib/trpc";
import { chatBadge } from "@/components/chat/chat-utils";
export interface IconRailProps {
className?: string;
}
type NavItem = {
id: string;
label: string;
segment: string;
icon: ComponentType<{ className?: string }>;
};
const NAV_ITEMS: NavItem[] = [
{ id: "home", label: "Home", segment: "", icon: Home },
{ id: "spaces", label: "Spaces", segment: "", icon: LayoutGrid },
{ id: "planner", label: "Planner", segment: "planner", icon: CalendarDays },
{
id: "mission-control",
label: "Mission control",
segment: "mission-control",
icon: Radar,
},
{ id: "ai", label: "AI", segment: "ai", icon: Sparkles },
{ id: "chat", label: "Chat", segment: "chat", icon: MessageCircle },
{ id: "teams", label: "Teams", segment: "teams", icon: Users },
{ id: "docs", label: "Docs", segment: "docs", icon: FileText },
{
id: "whiteboards",
label: "Whiteboards",
segment: "whiteboards",
icon: Presentation,
},
{ id: "forms", label: "Forms", segment: "forms", icon: ClipboardList },
];
function workspaceHref(workspaceSegment: string, segment: string) {
return segment ? `/${workspaceSegment}/${segment}` : `/${workspaceSegment}`;
}
const RESERVED_ROOT_SEGMENTS = new Set([
"planner",
"mission-control",
"ai",
"chat",
"teams",
"docs",
"whiteboards",
"forms",
]);
function isAtWorkspaceHome(pathname: string, prefix: string): boolean {
const rest = pathname.slice(prefix.length).replace(/^\//, "");
const first = rest.split("/")[0] ?? "";
if (!first) return true;
return !RESERVED_ROOT_SEGMENTS.has(first);
}
function isNavItemActive(
pathname: string,
workspaceSegment: string | undefined,
item: NavItem,
): boolean {
if (!workspaceSegment) return false;
const prefix = `/${workspaceSegment}`;
if (!pathname.startsWith(prefix)) return false;
if (item.segment) {
const next =
pathname[prefix.length] === "/" ? pathname.slice(prefix.length + 1) : "";
const first = next.split("/")[0] ?? "";
return first === item.segment;
}
if (item.id === "home") {
return isAtWorkspaceHome(pathname, prefix);
}
// Spaces uses the same URL as home for now; keep inactive here to avoid double highlight
if (item.id === "spaces") {
return false;
}
return false;
}
export function IconRail({ className }: IconRailProps) {
const pathname = usePathname();
const router = useRouter();
const params = useParams();
const currentWorkspace = useWorkspaceStore((s) => s.currentWorkspace);
const slugParam =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : "";
const workspaceSegment =
currentWorkspace?.slug || currentWorkspace?.id || slugParam || undefined;
const workspaceName = currentWorkspace?.name ?? "";
const unread = api.chat.unreadSummary.useQuery(
{ workspace: workspaceSegment ?? "" },
{ enabled: !!workspaceSegment, refetchInterval: 15000 },
);
const visibleChannels = api.chat.listChannels.useQuery(
{ workspace: workspaceSegment ?? "" },
{ enabled: !!workspaceSegment, refetchInterval: 15000 },
);
const directIds = new Set(
visibleChannels.data
?.filter((channel) => channel.kind === "direct")
.map((channel) => channel.id),
);
const badge = chatBadge({
unreadCount:
unread.data?.channels.reduce(
(total, channel) => total + channel.unreadCount,
0,
) ?? 0,
mentionCount:
unread.data?.channels.reduce(
(total, channel) => total + channel.mentionCount,
0,
) ?? 0,
directUnreadCount:
unread.data?.channels.reduce(
(total, channel) =>
total + (directIds.has(channel.channelId) ? channel.unreadCount : 0),
0,
) ?? 0,
});
const initials = workspaceName.trim().slice(0, 2).toUpperCase() || "—";
const push = (segment: string) => {
if (!workspaceSegment) return;
router.push(workspaceHref(workspaceSegment, segment));
};
return (
<aside
className={cn(
"flex h-full w-[60px] shrink-0 flex-col",
"bg-gradient-to-b from-[hsl(263,70%,25%)] to-[hsl(263,70%,18%)]",
"dark:from-[hsl(263,70%,20%)] dark:to-[hsl(263,70%,12%)]",
className,
)}
>
<nav
className="flex min-h-0 flex-1 flex-col items-stretch overflow-y-auto overflow-x-hidden px-1 py-2"
aria-label="Main"
>
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const active = isNavItemActive(pathname, workspaceSegment, item);
return (
<Tooltip key={item.id}>
<TooltipTrigger asChild>
<button
type="button"
disabled={!workspaceSegment}
aria-label={
item.id === "chat" && badge.dot
? `Chat, ${badge.count ? `${badge.count} mentions and unread direct messages` : "unread messages"}`
: item.label
}
onClick={() => push(item.segment)}
className={cn(
"w-full cursor-pointer rounded-lg px-1 py-2 transition-colors",
"flex flex-col items-center gap-0.5",
active
? "bg-white/20 text-white"
: "text-white/70 hover:bg-white/10",
)}
>
<span className="relative">
<Icon className="h-5 w-5 shrink-0" aria-hidden />
{item.id === "chat" && badge.dot && (
<span
className="absolute -right-1 -top-1 size-2 rounded-full bg-rose-400"
aria-hidden
/>
)}
</span>
{item.id === "chat" && badge.count > 0 && (
<span
className="rounded-full bg-rose-500 px-1 text-[10px] text-white"
aria-label={`${badge.count} mentions and unread direct messages`}
>
{badge.count > 99 ? "99+" : badge.count}
</span>
)}
<span
className={cn(
"max-w-[52px] truncate text-center text-[10px] font-medium leading-tight",
active ? "text-white" : "text-white/70",
)}
>
{item.label}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="right">{item.label}</TooltipContent>
</Tooltip>
);
})}
</nav>
<div className="flex shrink-0 flex-col items-stretch gap-0.5 px-1 pb-3">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
"w-full cursor-pointer rounded-lg px-1 py-2 transition-colors",
"flex flex-col items-center gap-0.5 text-white/50 hover:bg-white/10 hover:text-white/70",
)}
>
<MoreHorizontal className="h-5 w-5 shrink-0" aria-hidden />
<span className="max-w-[52px] truncate text-center text-[10px] font-medium leading-tight">
More
</span>
</button>
</TooltipTrigger>
<TooltipContent side="right">More</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
"w-full cursor-pointer rounded-lg px-1 py-2 transition-colors",
"flex flex-col items-center gap-0.5 text-white/70 hover:bg-white/10",
)}
>
<UserPlus className="h-5 w-5 shrink-0" aria-hidden />
<span className="max-w-[52px] truncate text-center text-[10px] font-medium leading-tight">
Invite
</span>
</button>
</TooltipTrigger>
<TooltipContent side="right">Invite</TooltipContent>
</Tooltip>
</div>
</aside>
);
}