feat(web): add workspace contextual sub-nav to sidebar
Detect active workspace via fuzzy matchRoute against /workspaces/$workspaceId and reveal an animated sub-nav with links to Peers, Sessions, Conclusions, and Webhooks for that workspace. Sub-nav collapses smoothly when leaving the workspace context.
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { Link, useMatchRoute } from "@tanstack/react-router";
|
import { Link, useMatchRoute } from "@tanstack/react-router";
|
||||||
import { motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
Boxes,
|
Boxes,
|
||||||
Check,
|
Check,
|
||||||
@@ -8,9 +8,13 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
Lightbulb,
|
||||||
|
MessageSquare,
|
||||||
Moon,
|
Moon,
|
||||||
Settings,
|
Settings,
|
||||||
Sun,
|
Sun,
|
||||||
|
Users,
|
||||||
|
Webhook,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { HealthDot } from "@/components/shared/HealthDot";
|
import { HealthDot } from "@/components/shared/HealthDot";
|
||||||
@@ -20,12 +24,19 @@ import { useInstances } from "@/hooks/useInstances";
|
|||||||
import { useTheme } from "@/hooks/useTheme";
|
import { useTheme } from "@/hooks/useTheme";
|
||||||
import { COLOR } from "@/lib/constants";
|
import { COLOR } from "@/lib/constants";
|
||||||
|
|
||||||
const navItems = [
|
const TOP_NAV = [
|
||||||
{ to: "/" as const, label: "Dashboard", icon: LayoutDashboard, exact: true },
|
{ to: "/" as const, label: "Dashboard", icon: LayoutDashboard, exact: true },
|
||||||
{ to: "/workspaces" as const, label: "Workspaces", icon: Boxes, exact: false },
|
{ to: "/workspaces" as const, label: "Workspaces", icon: Boxes, exact: false },
|
||||||
{ to: "/settings" as const, label: "Settings", icon: Settings, exact: false },
|
{ to: "/settings" as const, label: "Settings", icon: Settings, exact: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const WORKSPACE_SECTIONS = [
|
||||||
|
{ label: "Peers", icon: Users, section: "peers" },
|
||||||
|
{ label: "Sessions", icon: MessageSquare, section: "sessions" },
|
||||||
|
{ label: "Conclusions", icon: Lightbulb, section: "conclusions" },
|
||||||
|
{ label: "Webhooks", icon: Webhook, section: "webhooks" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const matchRoute = useMatchRoute();
|
const matchRoute = useMatchRoute();
|
||||||
const { instances, active, activate } = useInstances();
|
const { instances, active, activate } = useInstances();
|
||||||
@@ -46,6 +57,13 @@ export function Sidebar() {
|
|||||||
return () => window.removeEventListener("mousedown", onClick);
|
return () => window.removeEventListener("mousedown", onClick);
|
||||||
}, [switcherOpen]);
|
}, [switcherOpen]);
|
||||||
|
|
||||||
|
// Detect workspace context — matchRoute returns params or false
|
||||||
|
const wsMatch = matchRoute({
|
||||||
|
to: "/workspaces/$workspaceId" as never,
|
||||||
|
fuzzy: true,
|
||||||
|
}) as { workspaceId: string } | false;
|
||||||
|
const activeWorkspaceId = wsMatch ? wsMatch.workspaceId : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.aside
|
<motion.aside
|
||||||
initial={{ x: -20, opacity: 0 }}
|
initial={{ x: -20, opacity: 0 }}
|
||||||
@@ -160,8 +178,8 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Nav */}
|
{/* Nav */}
|
||||||
<nav className="flex-1 px-2 sm:px-3 py-3 space-y-0.5">
|
<nav className="flex-1 px-2 sm:px-3 py-3 space-y-0.5 overflow-y-auto">
|
||||||
{navItems.map((item) => {
|
{TOP_NAV.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const isActive = matchRoute({ to: item.to, fuzzy: !item.exact });
|
const isActive = matchRoute({ to: item.to, fuzzy: !item.exact });
|
||||||
|
|
||||||
@@ -198,6 +216,73 @@ export function Sidebar() {
|
|||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Workspace contextual sub-nav */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{activeWorkspaceId && (
|
||||||
|
<motion.div
|
||||||
|
key="ws-subnav"
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
transition={{ duration: 0.22, ease: "easeInOut" }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Workspace ID label */}
|
||||||
|
<div className="px-3 pt-2 pb-1 hidden sm:block">
|
||||||
|
<p
|
||||||
|
className="text-xs font-mono truncate"
|
||||||
|
style={{ color: "var(--text-4)" }}
|
||||||
|
title={mask(activeWorkspaceId)}
|
||||||
|
>
|
||||||
|
{mask(activeWorkspaceId)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section links — indented */}
|
||||||
|
<div className="pl-2 sm:pl-3 space-y-0.5">
|
||||||
|
{WORKSPACE_SECTIONS.map((s) => {
|
||||||
|
const Icon = s.icon;
|
||||||
|
const isActive = matchRoute({
|
||||||
|
to: `/workspaces/$workspaceId/${s.section}` as never,
|
||||||
|
params: { workspaceId: activeWorkspaceId } as never,
|
||||||
|
fuzzy: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={s.section}
|
||||||
|
to={`/workspaces/$workspaceId/${s.section}` as never}
|
||||||
|
params={{ workspaceId: activeWorkspaceId } as never}
|
||||||
|
className="relative flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs transition-all justify-center sm:justify-start"
|
||||||
|
style={{
|
||||||
|
color: isActive ? "var(--accent-text)" : "var(--text-3)",
|
||||||
|
}}
|
||||||
|
title={s.label}
|
||||||
|
>
|
||||||
|
{isActive && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="ws-sub-indicator"
|
||||||
|
className="absolute inset-0 rounded-lg"
|
||||||
|
style={{
|
||||||
|
background: "var(--accent-dim)",
|
||||||
|
border: "1px solid var(--accent-border)",
|
||||||
|
}}
|
||||||
|
transition={{ type: "spring", bounce: 0.2, duration: 0.4 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Icon
|
||||||
|
className="w-3.5 h-3.5 shrink-0 relative z-10"
|
||||||
|
strokeWidth={isActive ? 2 : 1.5}
|
||||||
|
/>
|
||||||
|
<span className="relative z-10 font-medium hidden sm:block">{s.label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Theme toggle + footer */}
|
{/* Theme toggle + footer */}
|
||||||
|
|||||||
Reference in New Issue
Block a user