diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/layout-shell.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/layout-shell.tsx
index 08ca498af..e03667944 100644
--- a/surfsense_web/app/dashboard/[workspace_id]/playground/layout-shell.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/playground/layout-shell.tsx
@@ -1,155 +1,26 @@
"use client";
-import { ChevronRight, History, LayoutGrid } from "lucide-react";
-import Link from "next/link";
+import { History, LayoutGrid } from "lucide-react";
import { useSelectedLayoutSegments } from "next/navigation";
import type React from "react";
-import { useEffect, useMemo, useState } from "react";
-import { Button } from "@/components/ui/button";
+import { useMemo } from "react";
import {
- Drawer,
- DrawerContent,
- DrawerHandle,
- DrawerTitle,
- DrawerTrigger,
-} from "@/components/ui/drawer";
-import { Separator } from "@/components/ui/separator";
-import { PLAYGROUND_PLATFORMS, type PlaygroundPlatform, type PlaygroundVerb } from "@/lib/playground/catalog";
-import { cn } from "@/lib/utils";
+ type RoutedSectionGroup,
+ type RoutedSectionItem,
+ RoutedSectionShell,
+} from "@/components/layout";
+import { PLAYGROUND_PLATFORMS } from "@/lib/playground/catalog";
interface PlaygroundLayoutShellProps {
workspaceId: string;
children: React.ReactNode;
}
-interface TopLevelNavItem {
- value: "overview" | "runs";
- label: string;
- href: string;
- icon: React.ReactNode;
-}
-
-function TopLevelNavLink({
- item,
- activeValue,
- onNavigate,
-}: {
- item: TopLevelNavItem;
- activeValue: string;
- onNavigate?: () => void;
-}) {
- const isActive = activeValue === item.value;
-
- return (
-
- {item.icon}
- {item.label}
-
- );
-}
-
-function ProviderNavGroup({
- platform,
- base,
- activeValue,
- isExpanded,
- onToggle,
- onNavigate,
-}: {
- platform: PlaygroundPlatform;
- base: string;
- activeValue: string;
- isExpanded: boolean;
- onToggle: () => void;
- onNavigate?: () => void;
-}) {
- const Icon = platform.icon;
-
- return (
-
-
-
-
-
- {platform.verbs.map((verb) => {
- const value = `${platform.id}/${verb.verb}`;
- const isActive = activeValue === value;
- return (
-
- {verb.label}
-
- );
- })}
-
-
-
-
- );
-}
-
export function PlaygroundLayoutShell({ workspaceId, children }: PlaygroundLayoutShellProps) {
const segments = useSelectedLayoutSegments();
const base = `/dashboard/${workspaceId}/playground`;
- const activePlatform = PLAYGROUND_PLATFORMS.some((platform) => platform.id === segments[0])
- ? segments[0]
- : null;
- const [expandedProvider, setExpandedProvider] = useState(activePlatform);
- const [mobileNavOpen, setMobileNavOpen] = useState(false);
- useEffect(() => {
- if (activePlatform) {
- setExpandedProvider(activePlatform);
- }
- }, [activePlatform]);
-
- const topLevelItems = useMemo(
+ const topLevelItems = useMemo(
() => [
{
value: "overview",
@@ -167,6 +38,24 @@ export function PlaygroundLayoutShell({ workspaceId, children }: PlaygroundLayou
[base]
);
+ const providerGroups = useMemo(
+ () =>
+ PLAYGROUND_PLATFORMS.map((platform) => {
+ const Icon = platform.icon;
+ return {
+ value: platform.id,
+ label: platform.label,
+ icon: ,
+ items: platform.verbs.map((verb) => ({
+ value: `${platform.id}/${verb.verb}`,
+ label: verb.label,
+ href: `${base}/${platform.id}/${verb.verb}`,
+ })),
+ };
+ }),
+ [base]
+ );
+
const activeValue =
segments.length >= 2
? `${segments[0]}/${segments[1]}`
@@ -174,86 +63,33 @@ export function PlaygroundLayoutShell({ workspaceId, children }: PlaygroundLayou
? segments[0]
: "overview";
- const selectedLabel = getSelectedLabel(activeValue, topLevelItems);
-
- const renderNav = (onNavigate?: () => void) => (
- <>
- {topLevelItems.map((item) => (
-
- ))}
-
-
- {PLAYGROUND_PLATFORMS.map((platform) => (
-
- setExpandedProvider((current) => (current === platform.id ? null : platform.id))
- }
- onNavigate={onNavigate}
- />
- ))}
-
- >
- );
+ const selectedLabel = getSelectedLabel(activeValue, topLevelItems, providerGroups);
return (
-
-
-
- API Playground
-
-
-
-
-
-
-
-
- API Playground navigation
-
-
-
-
-
-
-
-
{selectedLabel}
-
-
-
{children}
-
-
+
+ {children}
+
);
}
-function getSelectedLabel(activeValue: string, topLevelItems: TopLevelNavItem[]): string {
- const topLevelItem = topLevelItems.find((item) => item.value === activeValue);
+function getSelectedLabel(
+ activeValue: string,
+ items: RoutedSectionItem[],
+ groups: RoutedSectionGroup[]
+): string {
+ const topLevelItem = items.find((item) => item.value === activeValue);
if (topLevelItem) return topLevelItem.label;
- const [platformId, verbId] = activeValue.split("/");
- const platform = PLAYGROUND_PLATFORMS.find((item) => item.id === platformId);
- const verb: PlaygroundVerb | undefined = platform?.verbs.find((item) => item.verb === verbId);
+ const group = groups.find((item) => item.items.some((child) => child.value === activeValue));
+ const child = group?.items.find((item) => item.value === activeValue);
- if (platform && verb) return `${platform.label} / ${verb.label}`;
+ if (group && child) return `${group.label} / ${child.label}`;
return "API Playground";
}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/user-settings/layout-shell.tsx b/surfsense_web/app/dashboard/[workspace_id]/user-settings/layout-shell.tsx
index 0559deaf9..aa73917ef 100644
--- a/surfsense_web/app/dashboard/[workspace_id]/user-settings/layout-shell.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/user-settings/layout-shell.tsx
@@ -11,14 +11,12 @@ import {
ShieldCheck,
WandSparkles,
} from "lucide-react";
-import Link from "next/link";
import { useSelectedLayoutSegment } from "next/navigation";
import { useTranslations } from "next-intl";
import type React from "react";
-import { useCallback, useMemo, useState } from "react";
-import { Separator } from "@/components/ui/separator";
+import { useMemo } from "react";
+import { type RoutedSectionItem, RoutedSectionShell } from "@/components/layout";
import { usePlatform } from "@/hooks/use-platform";
-import { cn } from "@/lib/utils";
export type UserSettingsTab =
| "profile"
@@ -42,50 +40,49 @@ export function UserSettingsLayoutShell({ workspaceId, children }: UserSettingsL
const t = useTranslations("userSettings");
const { isDesktop } = usePlatform();
const segment = useSelectedLayoutSegment();
- const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start");
- const handleTabScroll = useCallback((e: React.UIEvent) => {
- const el = e.currentTarget;
- const atStart = el.scrollLeft <= 2;
- const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2;
- setTabScrollPos(atStart ? "start" : atEnd ? "end" : "middle");
- }, []);
-
- const navItems = useMemo(
+ const navItems = useMemo(
() => [
{
value: "profile" as const,
label: t("profile_nav_label"),
+ href: `/dashboard/${workspaceId}/user-settings/profile`,
icon: ,
},
{
value: "api-key" as const,
label: t("api_key_nav_label"),
+ href: `/dashboard/${workspaceId}/user-settings/api-key`,
icon: ,
},
{
value: "prompts" as const,
label: "My Prompts",
+ href: `/dashboard/${workspaceId}/user-settings/prompts`,
icon: ,
},
{
value: "community-prompts" as const,
label: "Community Prompts",
+ href: `/dashboard/${workspaceId}/user-settings/community-prompts`,
icon: ,
},
{
value: "agent-permissions" as const,
label: "Agent Permissions",
+ href: `/dashboard/${workspaceId}/user-settings/agent-permissions`,
icon: ,
},
{
value: "messaging-channels" as const,
label: "Messaging Channels",
+ href: `/dashboard/${workspaceId}/user-settings/messaging-channels`,
icon: ,
},
{
value: "purchases" as const,
label: "Purchase History",
+ href: `/dashboard/${workspaceId}/user-settings/purchases`,
icon: ,
},
...(isDesktop
@@ -93,17 +90,19 @@ export function UserSettingsLayoutShell({ workspaceId, children }: UserSettingsL
{
value: "desktop" as const,
label: "App Preferences",
+ href: `/dashboard/${workspaceId}/user-settings/desktop`,
icon: ,
},
{
value: "hotkeys" as const,
label: "Hotkeys",
+ href: `/dashboard/${workspaceId}/user-settings/hotkeys`,
icon: ,
},
]
: []),
],
- [t, isDesktop]
+ [t, isDesktop, workspaceId]
);
const activeTab: UserSettingsTab =
@@ -112,72 +111,15 @@ export function UserSettingsLayoutShell({ workspaceId, children }: UserSettingsL
: DEFAULT_TAB;
const selectedLabel = navItems.find((item) => item.value === activeTab)?.label ?? t("title");
- const hrefFor = (tab: UserSettingsTab) => `/dashboard/${workspaceId}/user-settings/${tab}`;
-
return (
-
-
-
- {t("title")}
-
-
-
-
- {navItems.map((item) => (
-
- {item.icon}
- {item.label}
-
- ))}
-
-
-
-
-
-
-
{selectedLabel}
-
-
-
{children}
-
-
+
+ {children}
+
);
}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/workspace-settings/layout-shell.tsx b/surfsense_web/app/dashboard/[workspace_id]/workspace-settings/layout-shell.tsx
index ffef57a03..80276d30a 100644
--- a/surfsense_web/app/dashboard/[workspace_id]/workspace-settings/layout-shell.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/workspace-settings/layout-shell.tsx
@@ -1,13 +1,11 @@
"use client";
import { BookText, Cpu, Earth, Settings, UserKey } from "lucide-react";
-import Link from "next/link";
import { useSelectedLayoutSegment } from "next/navigation";
import { useTranslations } from "next-intl";
import type React from "react";
-import { useCallback, useMemo, useState } from "react";
-import { Separator } from "@/components/ui/separator";
-import { cn } from "@/lib/utils";
+import { useMemo } from "react";
+import { type RoutedSectionItem, RoutedSectionShell } from "@/components/layout";
export type WorkspaceSettingsTab = "general" | "models" | "team-roles" | "prompts" | "public-links";
@@ -24,44 +22,41 @@ export function WorkspaceSettingsLayoutShell({
}: WorkspaceSettingsLayoutShellProps) {
const t = useTranslations("workspaceSettings");
const segment = useSelectedLayoutSegment();
- const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start");
- const handleTabScroll = useCallback((e: React.UIEvent) => {
- const el = e.currentTarget;
- const atStart = el.scrollLeft <= 2;
- const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2;
- setTabScrollPos(atStart ? "start" : atEnd ? "end" : "middle");
- }, []);
-
- const navItems = useMemo(
+ const navItems = useMemo(
() => [
{
value: "general" as const,
label: t("nav_general"),
+ href: `/dashboard/${workspaceId}/workspace-settings/general`,
icon: ,
},
{
value: "models" as const,
label: t("nav_models"),
+ href: `/dashboard/${workspaceId}/workspace-settings/models`,
icon: ,
},
{
value: "team-roles" as const,
label: t("nav_team_roles"),
+ href: `/dashboard/${workspaceId}/workspace-settings/team-roles`,
icon: ,
},
{
value: "prompts" as const,
label: t("nav_system_instructions"),
+ href: `/dashboard/${workspaceId}/workspace-settings/prompts`,
icon: ,
},
{
value: "public-links" as const,
label: t("nav_public_links"),
+ href: `/dashboard/${workspaceId}/workspace-settings/public-links`,
icon: ,
},
],
- [t]
+ [t, workspaceId]
);
const activeTab: WorkspaceSettingsTab =
@@ -70,73 +65,15 @@ export function WorkspaceSettingsLayoutShell({
: DEFAULT_TAB;
const selectedLabel = navItems.find((item) => item.value === activeTab)?.label ?? t("title");
- const hrefFor = (tab: WorkspaceSettingsTab) =>
- `/dashboard/${workspaceId}/workspace-settings/${tab}`;
-
return (
-
-
-
- {t("title")}
-
-
-
-
- {navItems.map((item) => (
-
- {item.icon}
- {item.label}
-
- ))}
-
-
-
-
-
-
-
{selectedLabel}
-
-
-
{children}
-
-
+
+ {children}
+
);
}
diff --git a/surfsense_web/components/layout/index.ts b/surfsense_web/components/layout/index.ts
index e6903c642..a4d4a88f8 100644
--- a/surfsense_web/components/layout/index.ts
+++ b/surfsense_web/components/layout/index.ts
@@ -9,6 +9,7 @@ export type {
User,
Workspace,
} from "./types/layout.types";
+export type { RoutedSectionGroup, RoutedSectionItem } from "./ui";
export {
ChatListItem,
CreateWorkspaceDialog,
@@ -20,6 +21,7 @@ export {
MobileSidebarTrigger,
NavIcon,
NavSection,
+ RoutedSectionShell,
Sidebar,
SidebarCollapseButton,
SidebarHeader,
diff --git a/surfsense_web/components/layout/ui/RoutedSectionShell.tsx b/surfsense_web/components/layout/ui/RoutedSectionShell.tsx
new file mode 100644
index 000000000..007244806
--- /dev/null
+++ b/surfsense_web/components/layout/ui/RoutedSectionShell.tsx
@@ -0,0 +1,267 @@
+"use client";
+
+import { ChevronRight } from "lucide-react";
+import Link from "next/link";
+import type React from "react";
+import { useCallback, useEffect, useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Drawer,
+ DrawerContent,
+ DrawerHandle,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer";
+import { Separator } from "@/components/ui/separator";
+import { cn } from "@/lib/utils";
+
+export interface RoutedSectionItem {
+ value: string;
+ label: string;
+ href: string;
+ icon?: React.ReactNode;
+}
+
+export interface RoutedSectionGroup {
+ value: string;
+ label: string;
+ icon?: React.ReactNode;
+ items: RoutedSectionItem[];
+}
+
+interface RoutedSectionShellProps {
+ title: string;
+ items: RoutedSectionItem[];
+ activeValue: string;
+ selectedLabel: string;
+ children: React.ReactNode;
+ groups?: RoutedSectionGroup[];
+ mobileNav?: "scroll" | "drawer";
+ contentClassName?: string;
+}
+
+function findActiveGroupValue(groups: RoutedSectionGroup[], activeValue: string): string | null {
+ return (
+ groups.find((group) => group.items.some((item) => item.value === activeValue))?.value ?? null
+ );
+}
+
+function SectionNavLink({
+ item,
+ activeValue,
+ onNavigate,
+}: {
+ item: RoutedSectionItem;
+ activeValue: string;
+ onNavigate?: () => void;
+}) {
+ const isActive = activeValue === item.value;
+
+ return (
+
+ {item.icon}
+ {item.label}
+
+ );
+}
+
+function SectionNavGroup({
+ group,
+ activeValue,
+ isExpanded,
+ onToggle,
+ onNavigate,
+}: {
+ group: RoutedSectionGroup;
+ activeValue: string;
+ isExpanded: boolean;
+ onToggle: () => void;
+ onNavigate?: () => void;
+}) {
+ return (
+
+
+
+
+
+ {group.items.map((item) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+export function RoutedSectionShell({
+ title,
+ items,
+ activeValue,
+ selectedLabel,
+ children,
+ groups = [],
+ mobileNav = "scroll",
+ contentClassName,
+}: RoutedSectionShellProps) {
+ const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start");
+ const [drawerOpen, setDrawerOpen] = useState(false);
+ const [expandedGroup, setExpandedGroup] = useState(() =>
+ findActiveGroupValue(groups, activeValue)
+ );
+
+ useEffect(() => {
+ const activeGroup = findActiveGroupValue(groups, activeValue);
+ if (activeGroup) {
+ setExpandedGroup(activeGroup);
+ }
+ }, [activeValue, groups]);
+
+ const handleTabScroll = useCallback((e: React.UIEvent) => {
+ const el = e.currentTarget;
+ const atStart = el.scrollLeft <= 2;
+ const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2;
+ setTabScrollPos(atStart ? "start" : atEnd ? "end" : "middle");
+ }, []);
+
+ const renderNav = (onNavigate?: () => void) => (
+ <>
+ {items.map((item) => (
+
+ ))}
+ {groups.length > 0 && (
+ <>
+
+
+ {groups.map((group) => (
+
+ setExpandedGroup((current) => (current === group.value ? null : group.value))
+ }
+ onNavigate={onNavigate}
+ />
+ ))}
+
+ >
+ )}
+ >
+ );
+
+ return (
+
+
+
+ {title}
+
+
+ {mobileNav === "drawer" ? (
+
+
+
+
+
+
+ {title} navigation
+
+
+
+ ) : (
+
+
+ {items.map((item) => (
+
+ {item.icon}
+ {item.label}
+
+ ))}
+
+
+ )}
+
+
+
+
+
{selectedLabel}
+
+
+
{children}
+
+
+ );
+}
diff --git a/surfsense_web/components/layout/ui/index.ts b/surfsense_web/components/layout/ui/index.ts
index 52507ba37..338a4f352 100644
--- a/surfsense_web/components/layout/ui/index.ts
+++ b/surfsense_web/components/layout/ui/index.ts
@@ -1,6 +1,8 @@
export { CreateWorkspaceDialog } from "./dialogs";
export { Header } from "./header";
export { IconRail, NavIcon, WorkspaceAvatar } from "./icon-rail";
+export type { RoutedSectionGroup, RoutedSectionItem } from "./RoutedSectionShell";
+export { RoutedSectionShell } from "./RoutedSectionShell";
export { LayoutShell } from "./shell";
export {
ChatListItem,