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,