mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 04:14:42 +00:00
Move settings and admin navigation into the sidebar
Settings and admin both drove their pages from a horizontal tab strip. Seven tabs no longer fit: settings had grown scroll arrows, gradient masks and a hiddenGradient state machine just to survive on mobile, and the active tab was resolved by comparing translated labels against the URL. Teams and admin had no home in the strip at all — admin was reachable only from the Help popover. Replace both strips with a vertical nav that takes over the sidebar while you are inside a section, plus a back button that returns to the app. A declarative registry in navigation/sections.ts holds each section's destinations; the active item is resolved from the route by longest path match, so a detail route like /settings/tools/slack keeps Tools active. Section state is derived from the route rather than stored, so deep links and browser back keep working. - Below lg the sidebar is an overlay, so /settings and /admin render their destination list as page content and each page carries a back link to it. - Entering settings no longer clears the conversation; the back button returns to the route you came from and the chat list stays mounted, keeping its scroll position. - Collapsing the sidebar inside a section shows the same destinations as icons instead of stranding you on one page. - Detail views nested in a section page (a tool's config, a team) now use a breadcrumb rather than a second back arrow, so only the section nav means "leave". - Teams and admin join the settings nav; admin-only entries are hidden from non-admins along with the group heading they leave empty.
This commit is contained in:
1 parent
295969b6a4
commit
60532ec46c
22 files changed
+1226
-532
No files matched your search
@@ -23,6 +23,7 @@ import { useDarkTheme, useMediaQuery } from './hooks';
|
|||||||
import useDataInitializer from './hooks/useDataInitializer';
|
import useDataInitializer from './hooks/useDataInitializer';
|
||||||
import useTokenAuth from './hooks/useTokenAuth';
|
import useTokenAuth from './hooks/useTokenAuth';
|
||||||
import Navigation from './Navigation';
|
import Navigation from './Navigation';
|
||||||
|
import { getSectionForPath } from './navigation/sections';
|
||||||
import PageNotFound from './PageNotFound';
|
import PageNotFound from './PageNotFound';
|
||||||
import Setting from './settings';
|
import Setting from './settings';
|
||||||
import Teams from './settings/Teams';
|
import Teams from './settings/Teams';
|
||||||
@@ -84,11 +85,15 @@ function MainLayout() {
|
|||||||
const { isMobile, isTablet } = useMediaQuery();
|
const { isMobile, isTablet } = useMediaQuery();
|
||||||
const [navOpen, setNavOpen] = useState(!(isMobile || isTablet));
|
const [navOpen, setNavOpen] = useState(!(isMobile || isTablet));
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
// Settings and admin pages keep the profile menu but drop the chat actions:
|
||||||
|
// the conversation now survives the trip, so "share" would target a chat
|
||||||
|
// that isn't on screen.
|
||||||
|
const inSection = Boolean(getSectionForPath(location.pathname));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background relative h-screen overflow-hidden">
|
<div className="bg-background relative h-screen overflow-hidden">
|
||||||
<Navigation navOpen={navOpen} setNavOpen={setNavOpen} />
|
<Navigation navOpen={navOpen} setNavOpen={setNavOpen} />
|
||||||
<ActionButtons showNewChat={true} showShare={true} />
|
<ActionButtons showNewChat={!inSection} showShare={!inSection} />
|
||||||
<div
|
<div
|
||||||
className={`h-[calc(100dvh-64px)] overflow-auto transition-all duration-300 ease-in-out lg:h-screen ${
|
className={`h-[calc(100dvh-64px)] overflow-auto transition-all duration-300 ease-in-out lg:h-screen ${
|
||||||
!(isMobile || isTablet)
|
!(isMobile || isTablet)
|
||||||
|
|||||||
+313
-235
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { NavLink, useNavigate } from 'react-router-dom';
|
import { Link, NavLink, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Menu,
|
Menu,
|
||||||
@@ -34,15 +34,21 @@ import {
|
|||||||
import ConversationTile from './conversation/ConversationTile';
|
import ConversationTile from './conversation/ConversationTile';
|
||||||
import { useMediaQuery } from './hooks';
|
import { useMediaQuery } from './hooks';
|
||||||
import useTokenAuth from './hooks/useTokenAuth';
|
import useTokenAuth from './hooks/useTokenAuth';
|
||||||
|
import { cn } from './lib/utils';
|
||||||
import ConfirmationModal from './modals/ConfirmationModal';
|
import ConfirmationModal from './modals/ConfirmationModal';
|
||||||
import JWTModal from './modals/JWTModal';
|
import JWTModal from './modals/JWTModal';
|
||||||
import SearchConversationsModal from './modals/SearchConversationsModal';
|
import SearchConversationsModal from './modals/SearchConversationsModal';
|
||||||
import { ActiveState } from './models/misc';
|
import { ActiveState } from './models/misc';
|
||||||
import { getConversations } from './preferences/preferenceApi';
|
import { getConversations } from './preferences/preferenceApi';
|
||||||
|
import SectionNav from './navigation/SectionNav';
|
||||||
|
import SectionRail from './navigation/SectionRail';
|
||||||
|
import { useActiveSection } from './navigation/sections';
|
||||||
|
import { useLastAppPath } from './navigation/useLastAppPath';
|
||||||
import {
|
import {
|
||||||
selectAgents,
|
selectAgents,
|
||||||
selectConversationId,
|
selectConversationId,
|
||||||
selectConversations,
|
selectConversations,
|
||||||
|
selectIsAdmin,
|
||||||
selectModalStateDeleteConv,
|
selectModalStateDeleteConv,
|
||||||
selectSelectedAgent,
|
selectSelectedAgent,
|
||||||
selectSharedAgents,
|
selectSharedAgents,
|
||||||
@@ -76,10 +82,28 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
|||||||
const agents = useSelector(selectAgents);
|
const agents = useSelector(selectAgents);
|
||||||
const sharedAgents = useSelector(selectSharedAgents);
|
const sharedAgents = useSelector(selectSharedAgents);
|
||||||
const selectedAgent = useSelector(selectSelectedAgent);
|
const selectedAgent = useSelector(selectSelectedAgent);
|
||||||
|
const isAdmin = useSelector(selectIsAdmin);
|
||||||
|
|
||||||
const { isMobile, isTablet } = useMediaQuery();
|
const { isMobile, isTablet } = useMediaQuery();
|
||||||
const { showTokenModal, handleTokenSubmit } = useTokenAuth();
|
const { showTokenModal, handleTokenSubmit } = useTokenAuth();
|
||||||
|
|
||||||
|
// Section state is derived from the route, so deep links and the browser
|
||||||
|
// back button keep working without a second source of truth.
|
||||||
|
const { section: activeSection, item: activeSectionItem } =
|
||||||
|
useActiveSection();
|
||||||
|
const lastAppPath = useLastAppPath();
|
||||||
|
const inSection = Boolean(activeSection);
|
||||||
|
const backToAppLabel = t('navigation.backToApp');
|
||||||
|
|
||||||
|
const exitSection = () => {
|
||||||
|
if (isMobile || isTablet) setNavOpen(false);
|
||||||
|
navigate(lastAppPath.current || '/');
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeNavOnMobile = () => {
|
||||||
|
if (isMobile || isTablet) setNavOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
const [isDeletingConversation, setIsDeletingConversation] = useState(false);
|
const [isDeletingConversation, setIsDeletingConversation] = useState(false);
|
||||||
const [uploadModalState, setUploadModalState] =
|
const [uploadModalState, setUploadModalState] =
|
||||||
useState<ActiveState>('INACTIVE');
|
useState<ActiveState>('INACTIVE');
|
||||||
@@ -320,7 +344,8 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
|||||||
{!navOpen && !isMobile && !isTablet && (
|
{!navOpen && !isMobile && !isTablet && (
|
||||||
<div
|
<div
|
||||||
ref={navRef}
|
ref={navRef}
|
||||||
className="bg-sidebar border-border fixed top-0 left-0 z-10 hidden h-full w-14 flex-col items-center gap-2 border-r py-3 lg:flex">
|
className="bg-sidebar border-border scrollbar-overlay fixed top-0 left-0 z-10 hidden h-full w-14 flex-col items-center gap-2 overflow-x-hidden overflow-y-auto border-r py-3 lg:flex"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -331,64 +356,74 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
|||||||
>
|
>
|
||||||
<PanelLeftOpen className="size-5" strokeWidth={1.75} />
|
<PanelLeftOpen className="size-5" strokeWidth={1.75} />
|
||||||
</Button>
|
</Button>
|
||||||
{queries?.length > 0 && (
|
{activeSection ? (
|
||||||
<Button
|
<SectionRail
|
||||||
type="button"
|
section={activeSection}
|
||||||
variant="ghost"
|
activeItemKey={activeSectionItem?.key}
|
||||||
size="icon"
|
isAdmin={isAdmin}
|
||||||
onClick={() => newChat()}
|
onBack={exitSection}
|
||||||
aria-label="Start new chat"
|
backLabel={backToAppLabel}
|
||||||
className="text-muted-foreground hover:text-foreground"
|
/>
|
||||||
>
|
) : (
|
||||||
<Plus className="size-5" strokeWidth={1.75} />
|
<>
|
||||||
</Button>
|
{queries?.length > 0 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => newChat()}
|
||||||
|
aria-label="Start new chat"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Plus className="size-5" strokeWidth={1.75} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(setSelectedAgent(null));
|
||||||
|
navigate('/agents');
|
||||||
|
}}
|
||||||
|
aria-label={t('manageAgents')}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<LayoutGrid className="size-5" strokeWidth={1.75} />
|
||||||
|
</Button>
|
||||||
|
{conversations?.data && conversations.data.length > 0 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setSearchOpen(true)}
|
||||||
|
aria-label={t('modals.searchConversations.searchPlaceholder')}
|
||||||
|
title={t('modals.searchConversations.searchPlaceholder')}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<SearchIcon className="size-5" strokeWidth={1.75} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className="mt-auto flex flex-col items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => navigate('/settings')}
|
||||||
|
aria-label={t('settings.label')}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<SettingsIcon className="size-5" strokeWidth={1.75} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => {
|
|
||||||
dispatch(setSelectedAgent(null));
|
|
||||||
navigate('/agents');
|
|
||||||
}}
|
|
||||||
aria-label={t('manageAgents')}
|
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<LayoutGrid className="size-5" strokeWidth={1.75} />
|
|
||||||
</Button>
|
|
||||||
{conversations?.data && conversations.data.length > 0 && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setSearchOpen(true)}
|
|
||||||
aria-label={t('modals.searchConversations.searchPlaceholder')}
|
|
||||||
title={t('modals.searchConversations.searchPlaceholder')}
|
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<SearchIcon className="size-5" strokeWidth={1.75} />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<div className="mt-auto flex flex-col items-center gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => {
|
|
||||||
resetConversation();
|
|
||||||
navigate('/settings');
|
|
||||||
}}
|
|
||||||
aria-label={t('settings.label')}
|
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<SettingsIcon className="size-5" strokeWidth={1.75} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={`${!navOpen && '-ml-96 md:-ml-72'
|
className={`${
|
||||||
} bg-sidebar dark:border-r-sidebar-border fixed top-0 z-20 flex h-full w-72 flex-col border-r border-b-0 transition-all duration-300 ease-in-out dark:text-white`}
|
!navOpen && '-ml-96 md:-ml-72'
|
||||||
|
} bg-sidebar dark:border-r-sidebar-border fixed top-0 z-20 flex h-full w-72 flex-col border-r border-b-0 transition-all duration-300 ease-in-out dark:text-white`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
@@ -427,205 +462,249 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<NavLink
|
{/* The chat list and the section nav swap places here. Both stay
|
||||||
to={'/c/new'}
|
mounted so the conversation list keeps its scroll position while
|
||||||
onClick={() => {
|
the user is away in a section. */}
|
||||||
if (isMobile || isTablet) {
|
<div className="relative flex min-h-0 flex-1 overflow-hidden">
|
||||||
setNavOpen(!navOpen);
|
<div
|
||||||
}
|
className={cn(
|
||||||
resetConversation();
|
'absolute inset-0 flex flex-col transition-[translate,opacity,visibility] duration-300 ease-in-out',
|
||||||
}}
|
inSection
|
||||||
className={({ isActive }) =>
|
? 'invisible -translate-x-full opacity-0'
|
||||||
`${isActive ? 'bg-transparent' : ''
|
: 'visible translate-x-0 opacity-100',
|
||||||
} group border-sidebar-border hover:border-sidebar-border sticky mx-4 mt-4 flex cursor-pointer items-center gap-2.5 rounded-3xl border p-3 hover:bg-transparent dark:text-white`
|
)}
|
||||||
}
|
>
|
||||||
>
|
<NavLink
|
||||||
<Plus
|
to={'/c/new'}
|
||||||
className="text-muted-foreground group-hover:text-foreground size-5 shrink-0"
|
onClick={() => {
|
||||||
strokeWidth={1.75}
|
if (isMobile || isTablet) {
|
||||||
aria-label="Create new chat"
|
setNavOpen(!navOpen);
|
||||||
/>
|
}
|
||||||
<p className="text-muted-foreground group-hover:text-foreground text-sm">
|
resetConversation();
|
||||||
{t('newChat')}
|
}}
|
||||||
</p>
|
className={({ isActive }) =>
|
||||||
</NavLink>
|
`${
|
||||||
<div
|
isActive ? 'bg-transparent' : ''
|
||||||
id="conversationsMainDiv"
|
} group border-sidebar-border hover:border-sidebar-border sticky mx-4 mt-4 flex cursor-pointer items-center gap-2.5 rounded-3xl border p-3 hover:bg-transparent dark:text-white`
|
||||||
className="scrollbar-overlay mb-auto h-[78vh] overflow-x-hidden overflow-y-auto dark:text-white"
|
}
|
||||||
>
|
|
||||||
{conversations?.loading && !isDeletingConversation && (
|
|
||||||
<div
|
|
||||||
className="text-foreground absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transform dark:text-white"
|
|
||||||
role="status"
|
|
||||||
aria-label="Loading conversations"
|
|
||||||
>
|
>
|
||||||
<Spinner size="small" />
|
<Plus
|
||||||
</div>
|
className="text-muted-foreground group-hover:text-foreground size-5 shrink-0"
|
||||||
)}
|
strokeWidth={1.75}
|
||||||
{recentAgents?.length > 0 ? (
|
aria-label="Create new chat"
|
||||||
<div>
|
/>
|
||||||
<div className="mx-4 my-auto mt-2 flex h-6 items-center">
|
<p className="text-muted-foreground group-hover:text-foreground text-sm">
|
||||||
<p className="mt-1 ml-4 text-sm font-semibold">
|
{t('newChat')}
|
||||||
{t('navigation.agents')}
|
</p>
|
||||||
</p>
|
</NavLink>
|
||||||
</div>
|
<div
|
||||||
<div className="agents-container">
|
id="conversationsMainDiv"
|
||||||
<div>
|
className="scrollbar-overlay min-h-0 flex-1 overflow-x-hidden overflow-y-auto dark:text-white"
|
||||||
{recentAgents.map((agent, idx) => (
|
>
|
||||||
<div
|
{conversations?.loading && !isDeletingConversation && (
|
||||||
key={idx}
|
<div
|
||||||
className={`group hover:bg-sidebar-accent mx-4 my-auto mt-4 flex h-9 cursor-pointer items-center justify-between rounded-3xl pl-4 ${agent.id === selectedAgent?.id && !conversationId
|
className="text-foreground absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transform dark:text-white"
|
||||||
? 'bg-sidebar-accent'
|
role="status"
|
||||||
: ''
|
aria-label="Loading conversations"
|
||||||
}`}
|
>
|
||||||
onClick={() => handleAgentClick(agent)}
|
<Spinner size="small" />
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex w-6 justify-center">
|
|
||||||
<Avatar
|
|
||||||
src={agent.image}
|
|
||||||
alt="agent-logo"
|
|
||||||
imgClassName="h-6 w-6 rounded-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-foreground dark:text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
|
|
||||||
{agent.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`${isMobile || isTablet ? 'flex' : 'invisible flex group-hover:visible'} items-center px-3`}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className="h-auto w-auto rounded-full p-0 hover:bg-transparent hover:opacity-75"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleTogglePin(agent);
|
|
||||||
}}
|
|
||||||
aria-label={
|
|
||||||
agent.pinned ? 'Unpin agent' : 'Pin agent'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={agent.pinned ? UnPin : Pin}
|
|
||||||
className="h-4 w-4"
|
|
||||||
></img>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{recentAgents?.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<div className="mx-4 my-auto mt-2 flex h-6 items-center">
|
||||||
|
<p className="mt-1 ml-4 text-sm font-semibold">
|
||||||
|
{t('navigation.agents')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="agents-container">
|
||||||
|
<div>
|
||||||
|
{recentAgents.map((agent, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`group hover:bg-sidebar-accent mx-4 my-auto mt-4 flex h-9 cursor-pointer items-center justify-between rounded-3xl pl-4 ${
|
||||||
|
agent.id === selectedAgent?.id && !conversationId
|
||||||
|
? 'bg-sidebar-accent'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handleAgentClick(agent)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex w-6 justify-center">
|
||||||
|
<Avatar
|
||||||
|
src={agent.image}
|
||||||
|
alt="agent-logo"
|
||||||
|
imgClassName="h-6 w-6 rounded-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-foreground dark:text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
|
||||||
|
{agent.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`${isMobile || isTablet ? 'flex' : 'invisible flex group-hover:visible'} items-center px-3`}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="h-auto w-auto rounded-full p-0 hover:bg-transparent hover:opacity-75"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleTogglePin(agent);
|
||||||
|
}}
|
||||||
|
aria-label={
|
||||||
|
agent.pinned ? 'Unpin agent' : 'Pin agent'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={agent.pinned ? UnPin : Pin}
|
||||||
|
className="h-4 w-4"
|
||||||
|
></img>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<NavLink
|
||||||
|
to="/agents"
|
||||||
|
end
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(setSelectedAgent(null));
|
||||||
|
if (isMobile || isTablet) {
|
||||||
|
setNavOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`hover:bg-sidebar-accent mx-4 my-auto mt-2 flex h-9 cursor-pointer items-center gap-2 rounded-3xl pl-4 ${
|
||||||
|
isActive ? 'bg-sidebar-accent' : ''
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex w-6 justify-center">
|
||||||
|
<LayoutGrid
|
||||||
|
className="text-muted-foreground size-5"
|
||||||
|
strokeWidth={1.75}
|
||||||
|
aria-label="manage-agents"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-foreground dark:text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
|
||||||
|
{t('manageAgents')}
|
||||||
|
</p>
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/agents"
|
to="/agents"
|
||||||
end
|
end
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
dispatch(setSelectedAgent(null));
|
|
||||||
if (isMobile || isTablet) {
|
if (isMobile || isTablet) {
|
||||||
setNavOpen(false);
|
setNavOpen(false);
|
||||||
}
|
}
|
||||||
|
dispatch(setSelectedAgent(null));
|
||||||
}}
|
}}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`hover:bg-sidebar-accent mx-4 my-auto mt-2 flex h-9 cursor-pointer items-center gap-2 rounded-3xl pl-4 ${isActive ? 'bg-sidebar-accent' : ''
|
`hover:bg-sidebar-accent mx-4 my-auto mt-2 flex h-9 cursor-pointer items-center gap-2.5 rounded-3xl pl-3 ${
|
||||||
|
isActive ? 'bg-sidebar-accent' : ''
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex w-6 justify-center">
|
<LayoutGrid
|
||||||
<LayoutGrid
|
className="text-muted-foreground size-5 shrink-0"
|
||||||
className="text-muted-foreground size-5"
|
strokeWidth={1.75}
|
||||||
strokeWidth={1.75}
|
aria-label="manage-agents"
|
||||||
aria-label="manage-agents"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-foreground dark:text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
|
<p className="text-foreground dark:text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
|
||||||
{t('manageAgents')}
|
{t('manageAgents')}
|
||||||
</p>
|
</p>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
)}
|
||||||
|
{conversations?.data && conversations.data.length > 0 ? (
|
||||||
|
<div className="mt-7">
|
||||||
|
<div className="my-auto mt-2 ml-2.75 flex h-9 items-center justify-between gap-4 rounded-3xl p-1">
|
||||||
|
<p className="mt-1 ml-4 text-sm font-semibold">
|
||||||
|
{t('chats')}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setSearchOpen(true)}
|
||||||
|
className="text-muted-foreground hover:text-foreground hover:bg-sidebar-accent mr-1 rounded-full"
|
||||||
|
aria-label={t(
|
||||||
|
'modals.searchConversations.searchPlaceholder',
|
||||||
|
)}
|
||||||
|
title={t('modals.searchConversations.searchPlaceholder')}
|
||||||
|
>
|
||||||
|
<SearchIcon
|
||||||
|
className="size-5"
|
||||||
|
strokeWidth={1.75}
|
||||||
|
aria-label="search"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="conversations-container">
|
||||||
|
{(conversations.data ?? []).map((conversation) => (
|
||||||
|
<ConversationTile
|
||||||
|
key={conversation.id}
|
||||||
|
conversation={conversation}
|
||||||
|
selectConversation={(id) => handleConversationClick(id)}
|
||||||
|
onConversationClick={() => {
|
||||||
|
if (isMobile) {
|
||||||
|
setNavOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDeleteConversation={(id) =>
|
||||||
|
handleDeleteConversation(id)
|
||||||
|
}
|
||||||
|
onSave={(conversation) =>
|
||||||
|
updateConversationName(conversation)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<NavLink
|
<div
|
||||||
to="/agents"
|
className={cn(
|
||||||
end
|
'absolute inset-0 flex flex-col transition-[translate,opacity,visibility] duration-300 ease-in-out',
|
||||||
onClick={() => {
|
inSection
|
||||||
if (isMobile || isTablet) {
|
? 'visible translate-x-0 opacity-100'
|
||||||
setNavOpen(false);
|
: 'invisible translate-x-full opacity-0',
|
||||||
}
|
)}
|
||||||
dispatch(setSelectedAgent(null));
|
>
|
||||||
}}
|
{activeSection && (
|
||||||
className={({ isActive }) =>
|
<SectionNav
|
||||||
`hover:bg-sidebar-accent mx-4 my-auto mt-2 flex h-9 cursor-pointer items-center gap-2.5 rounded-3xl pl-3 ${isActive ? 'bg-sidebar-accent' : ''
|
section={activeSection}
|
||||||
}`
|
activeItemKey={activeSectionItem?.key}
|
||||||
}
|
isAdmin={isAdmin}
|
||||||
>
|
onBack={exitSection}
|
||||||
<LayoutGrid
|
backLabel={backToAppLabel}
|
||||||
className="text-muted-foreground size-5 shrink-0"
|
onNavigate={closeNavOnMobile}
|
||||||
strokeWidth={1.75}
|
|
||||||
aria-label="manage-agents"
|
|
||||||
/>
|
/>
|
||||||
<p className="text-foreground dark:text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
|
)}
|
||||||
{t('manageAgents')}
|
</div>
|
||||||
</p>
|
|
||||||
</NavLink>
|
|
||||||
)}
|
|
||||||
{conversations?.data && conversations.data.length > 0 ? (
|
|
||||||
<div className="mt-7">
|
|
||||||
<div className="my-auto mt-2 ml-2.75 p-1 flex h-9 items-center justify-between gap-4 rounded-3xl">
|
|
||||||
<p className="mt-1 ml-4 text-sm font-semibold">{t('chats')}</p>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setSearchOpen(true)}
|
|
||||||
className="mr-1 text-muted-foreground hover:text-foreground hover:bg-sidebar-accent rounded-full"
|
|
||||||
aria-label={t('modals.searchConversations.searchPlaceholder')}
|
|
||||||
title={t('modals.searchConversations.searchPlaceholder')}
|
|
||||||
>
|
|
||||||
<SearchIcon
|
|
||||||
className="size-5"
|
|
||||||
strokeWidth={1.75}
|
|
||||||
aria-label="search"
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="conversations-container">
|
|
||||||
{(conversations.data ?? []).map((conversation) => (
|
|
||||||
<ConversationTile
|
|
||||||
key={conversation.id}
|
|
||||||
conversation={conversation}
|
|
||||||
selectConversation={(id) => handleConversationClick(id)}
|
|
||||||
onConversationClick={() => {
|
|
||||||
if (isMobile) {
|
|
||||||
setNavOpen(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDeleteConversation={(id) => handleDeleteConversation(id)}
|
|
||||||
onSave={(conversation) =>
|
|
||||||
updateConversationName(conversation)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<></>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-foreground flex h-auto flex-col justify-end dark:text-white">
|
<div className="text-foreground flex h-auto shrink-0 flex-col justify-end dark:text-white">
|
||||||
<div className="dark:border-b-sidebar-border flex flex-col gap-2 border-b py-2">
|
{/* Inside a section its own nav is the way around, so this entry
|
||||||
<NavLink
|
would only duplicate what is already on screen. Entering settings
|
||||||
onClick={() => {
|
no longer clears the conversation either, so the section's back
|
||||||
if (isMobile || isTablet) {
|
button can return to it. */}
|
||||||
setNavOpen(false);
|
<div
|
||||||
}
|
className={cn(
|
||||||
resetConversation();
|
'dark:border-b-sidebar-border flex flex-col gap-2 border-b py-2',
|
||||||
}}
|
inSection && 'hidden',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
onClick={closeNavOnMobile}
|
||||||
to="/settings"
|
to="/settings"
|
||||||
className={({ isActive }) =>
|
className="hover:bg-sidebar-accent mx-4 my-auto flex h-9 cursor-pointer items-center gap-2.5 rounded-3xl pl-3"
|
||||||
`hover:bg-sidebar-accent mx-4 my-auto flex h-9 cursor-pointer items-center gap-2.5 rounded-3xl pl-3 ${isActive ? 'bg-sidebar-accent' : ''
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<SettingsIcon
|
<SettingsIcon
|
||||||
className="text-muted-foreground size-5 shrink-0"
|
className="text-muted-foreground size-5 shrink-0"
|
||||||
@@ -635,7 +714,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
|||||||
<p className="text-foreground text-sm dark:text-white">
|
<p className="text-foreground text-sm dark:text-white">
|
||||||
{t('settings.label')}
|
{t('settings.label')}
|
||||||
</p>
|
</p>
|
||||||
</NavLink>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-foreground flex flex-col justify-end dark:text-white">
|
<div className="text-foreground flex flex-col justify-end dark:text-white">
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-1">
|
||||||
@@ -698,7 +777,6 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
|||||||
>
|
>
|
||||||
<Menu className="size-5" strokeWidth={1.75} />
|
<Menu className="size-5" strokeWidth={1.75} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
|
||||||
import {
|
|
||||||
Navigate,
|
|
||||||
Route,
|
|
||||||
Routes,
|
|
||||||
useLocation,
|
|
||||||
useNavigate,
|
|
||||||
} from 'react-router-dom';
|
|
||||||
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from '../components/ui/tabs';
|
import { useMediaQuery } from '../hooks';
|
||||||
|
import SectionIndexPage from '../navigation/SectionIndexPage';
|
||||||
|
import SectionPageHeader from '../navigation/SectionPageHeader';
|
||||||
|
import { ADMIN_SECTION, getActiveItem } from '../navigation/sections';
|
||||||
import Admins from './Admins';
|
import Admins from './Admins';
|
||||||
import Audit from './Audit';
|
import Audit from './Audit';
|
||||||
import Overview from './Overview';
|
import Overview from './Overview';
|
||||||
@@ -15,60 +11,39 @@ import Quotas from './Quotas';
|
|||||||
import Usage from './Usage';
|
import Usage from './Usage';
|
||||||
import Users from './Users';
|
import Users from './Users';
|
||||||
|
|
||||||
const TABS = [
|
|
||||||
{ key: 'overview', label: 'Overview', path: '/admin' },
|
|
||||||
{ key: 'users', label: 'Users', path: '/admin/users' },
|
|
||||||
{ key: 'admins', label: 'Admins', path: '/admin/roles' },
|
|
||||||
{ key: 'usage', label: 'Usage', path: '/admin/usage' },
|
|
||||||
{ key: 'quotas', label: 'Quotas', path: '/admin/quotas' },
|
|
||||||
{ key: 'audit', label: 'Audit', path: '/admin/audit' },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin dashboard (operator-level, global admin). Reached only via <AdminRoute>
|
* Admin dashboard (operator-level, global admin). Reached only via <AdminRoute>
|
||||||
* (cosmetic guard); every endpoint it calls is independently @admin_required on
|
* (cosmetic guard); every endpoint it calls is independently @admin_required on
|
||||||
* the server.
|
* the server. Navigation lives in the sidebar, like the settings section.
|
||||||
*/
|
*/
|
||||||
export default function Admin() {
|
export default function Admin() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { isMobile, isTablet } = useMediaQuery();
|
||||||
|
|
||||||
const active =
|
const activeItem = getActiveItem(ADMIN_SECTION, location.pathname);
|
||||||
TABS.slice(1).find((tab) => location.pathname.startsWith(tab.path))
|
const showIndex =
|
||||||
?.label ?? 'Overview';
|
(isMobile || isTablet) && location.pathname === ADMIN_SECTION.rootPath;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-auto p-4 md:p-12">
|
<div className="h-full overflow-auto p-4 md:p-12">
|
||||||
<div className="mx-auto w-full max-w-7xl">
|
<div className="mx-auto w-full max-w-7xl">
|
||||||
<p className="text-foreground text-2xl font-bold">
|
{showIndex ? (
|
||||||
{t('admin.label', 'Admin')}
|
<SectionIndexPage section={ADMIN_SECTION} />
|
||||||
</p>
|
) : (
|
||||||
<Tabs
|
<>
|
||||||
value={active}
|
<SectionPageHeader section={ADMIN_SECTION} item={activeItem} />
|
||||||
onValueChange={(label) => {
|
<Routes>
|
||||||
const tab = TABS.find((tb) => tb.label === label);
|
<Route index element={<Overview />} />
|
||||||
if (tab) navigate(tab.path);
|
<Route path="overview" element={<Overview />} />
|
||||||
}}
|
<Route path="users" element={<Users />} />
|
||||||
className="relative mt-6 flex flex-row items-center space-x-1 overflow-auto md:space-x-0"
|
<Route path="roles" element={<Admins />} />
|
||||||
>
|
<Route path="usage" element={<Usage />} />
|
||||||
<TabsList aria-label={t('admin.tabsAriaLabel', 'Admin sections')}>
|
<Route path="quotas" element={<Quotas />} />
|
||||||
{TABS.map((tab) => (
|
<Route path="audit" element={<Audit />} />
|
||||||
<TabsTrigger key={tab.key} value={tab.label}>
|
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||||
{t(`admin.tabs.${tab.key}`, tab.label)}
|
</Routes>
|
||||||
</TabsTrigger>
|
</>
|
||||||
))}
|
)}
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
<Routes>
|
|
||||||
<Route index element={<Overview />} />
|
|
||||||
<Route path="users" element={<Users />} />
|
|
||||||
<Route path="roles" element={<Admins />} />
|
|
||||||
<Route path="usage" element={<Usage />} />
|
|
||||||
<Route path="quotas" element={<Quotas />} />
|
|
||||||
<Route path="audit" element={<Audit />} />
|
|
||||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
|
||||||
</Routes>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -436,7 +436,6 @@
|
|||||||
"label": "Teams",
|
"label": "Teams",
|
||||||
"subtitle": "Verwalte deine Teams, Mitglieder und geteilten Ressourcen.",
|
"subtitle": "Verwalte deine Teams, Mitglieder und geteilten Ressourcen.",
|
||||||
"newTeam": "Neues Team",
|
"newTeam": "Neues Team",
|
||||||
"backToTeams": "Zurück zu den Teams",
|
|
||||||
"createTeam": "Team erstellen",
|
"createTeam": "Team erstellen",
|
||||||
"createTeamDescription": "Gib deinem Team einen Namen. Mitglieder und Ressourcen kannst du nach dem Erstellen hinzufügen.",
|
"createTeamDescription": "Gib deinem Team einen Namen. Mitglieder und Ressourcen kannst du nach dem Erstellen hinzufügen.",
|
||||||
"editTeam": "Bearbeiten",
|
"editTeam": "Bearbeiten",
|
||||||
@@ -565,7 +564,6 @@
|
|||||||
"queryParameters": "Abfrageparameter",
|
"queryParameters": "Abfrageparameter",
|
||||||
"body": "Body",
|
"body": "Body",
|
||||||
"deleteActionWarning": "Bist du sicher, dass du die Aktion \"{{name}}\" löschen möchtest?",
|
"deleteActionWarning": "Bist du sicher, dass du die Aktion \"{{name}}\" löschen möchtest?",
|
||||||
"backToAllTools": "Zurück zu allen Werkzeugen",
|
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
"saving": "Speichere...",
|
"saving": "Speichere...",
|
||||||
"saveFailed": "Werkzeug-Konfiguration konnte nicht gespeichert werden",
|
"saveFailed": "Werkzeug-Konfiguration konnte nicht gespeichert werden",
|
||||||
@@ -872,10 +870,7 @@
|
|||||||
"subtitle": "Das neue Geheimnis für \"{{name}}\" ist bereit. Das alte funktioniert nicht mehr."
|
"subtitle": "Das neue Geheimnis für \"{{name}}\" ist bereit. Das alte funktioniert nicht mehr."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"scrollTabsLeft": "Tabs nach links scrollen",
|
|
||||||
"tabsAriaLabel": "Einstellungs-Tabs",
|
|
||||||
"scrollTabsRight": "Tabs nach rechts scrollen"
|
|
||||||
},
|
},
|
||||||
"modals": {
|
"modals": {
|
||||||
"uploadDoc": {
|
"uploadDoc": {
|
||||||
@@ -1647,7 +1642,16 @@
|
|||||||
"increaseZoom": "Vergrößern"
|
"increaseZoom": "Vergrößern"
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"agents": "Agenten"
|
"agents": "Agenten",
|
||||||
|
"backToApp": "Zurück zur App",
|
||||||
|
"sections": {
|
||||||
|
"groups": {
|
||||||
|
"personal": "Persönlich",
|
||||||
|
"workspace": "Arbeitsbereich",
|
||||||
|
"insights": "Auswertungen",
|
||||||
|
"administration": "Administration"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"teams": {
|
"teams": {
|
||||||
"switcher": {
|
"switcher": {
|
||||||
|
|||||||
@@ -441,7 +441,6 @@
|
|||||||
"label": "Teams",
|
"label": "Teams",
|
||||||
"subtitle": "Manage your teams, members, and shared resources.",
|
"subtitle": "Manage your teams, members, and shared resources.",
|
||||||
"newTeam": "New team",
|
"newTeam": "New team",
|
||||||
"backToTeams": "Back to teams",
|
|
||||||
"createTeam": "Create a team",
|
"createTeam": "Create a team",
|
||||||
"createTeamDescription": "Give your team a name. You can add members and share resources once it's created.",
|
"createTeamDescription": "Give your team a name. You can add members and share resources once it's created.",
|
||||||
"editTeam": "Edit",
|
"editTeam": "Edit",
|
||||||
@@ -570,7 +569,6 @@
|
|||||||
"queryParameters": "Query Parameters",
|
"queryParameters": "Query Parameters",
|
||||||
"body": "Body",
|
"body": "Body",
|
||||||
"deleteActionWarning": "Are you sure you want to delete the action \"{{name}}\"?",
|
"deleteActionWarning": "Are you sure you want to delete the action \"{{name}}\"?",
|
||||||
"backToAllTools": "Back to all tools",
|
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"saving": "Saving...",
|
"saving": "Saving...",
|
||||||
"saveFailed": "Failed to save tool configuration",
|
"saveFailed": "Failed to save tool configuration",
|
||||||
@@ -877,10 +875,7 @@
|
|||||||
"subtitle": "The new secret for \"{{name}}\" is ready. The old one no longer works."
|
"subtitle": "The new secret for \"{{name}}\" is ready. The old one no longer works."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"scrollTabsLeft": "Scroll tabs left",
|
|
||||||
"tabsAriaLabel": "Settings tabs",
|
|
||||||
"scrollTabsRight": "Scroll tabs right"
|
|
||||||
},
|
},
|
||||||
"modals": {
|
"modals": {
|
||||||
"uploadDoc": {
|
"uploadDoc": {
|
||||||
@@ -1658,7 +1653,16 @@
|
|||||||
"increaseZoom": "Increase zoom"
|
"increaseZoom": "Increase zoom"
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"agents": "Agents"
|
"agents": "Agents",
|
||||||
|
"backToApp": "Back to app",
|
||||||
|
"sections": {
|
||||||
|
"groups": {
|
||||||
|
"personal": "Personal",
|
||||||
|
"workspace": "Workspace",
|
||||||
|
"insights": "Insights",
|
||||||
|
"administration": "Administration"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"teams": {
|
"teams": {
|
||||||
"switcher": {
|
"switcher": {
|
||||||
@@ -1677,5 +1681,16 @@
|
|||||||
},
|
},
|
||||||
"prompts": {
|
"prompts": {
|
||||||
"textAriaLabel": "Prompt Text"
|
"textAriaLabel": "Prompt Text"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"label": "Admin",
|
||||||
|
"tabs": {
|
||||||
|
"overview": "Overview",
|
||||||
|
"users": "Users",
|
||||||
|
"admins": "Admins",
|
||||||
|
"usage": "Usage",
|
||||||
|
"quotas": "Quotas",
|
||||||
|
"audit": "Audit"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -436,7 +436,6 @@
|
|||||||
"label": "Equipos",
|
"label": "Equipos",
|
||||||
"subtitle": "Gestiona tus equipos, miembros y recursos compartidos.",
|
"subtitle": "Gestiona tus equipos, miembros y recursos compartidos.",
|
||||||
"newTeam": "Nuevo equipo",
|
"newTeam": "Nuevo equipo",
|
||||||
"backToTeams": "Volver a equipos",
|
|
||||||
"createTeam": "Crear un equipo",
|
"createTeam": "Crear un equipo",
|
||||||
"createTeamDescription": "Dale un nombre a tu equipo. Podrás añadir miembros y compartir recursos una vez creado.",
|
"createTeamDescription": "Dale un nombre a tu equipo. Podrás añadir miembros y compartir recursos una vez creado.",
|
||||||
"editTeam": "Editar",
|
"editTeam": "Editar",
|
||||||
@@ -565,7 +564,6 @@
|
|||||||
"queryParameters": "Parámetros de Consulta",
|
"queryParameters": "Parámetros de Consulta",
|
||||||
"body": "Cuerpo",
|
"body": "Cuerpo",
|
||||||
"deleteActionWarning": "¿Estás seguro de que deseas eliminar la acción \"{{name}}\"?",
|
"deleteActionWarning": "¿Estás seguro de que deseas eliminar la acción \"{{name}}\"?",
|
||||||
"backToAllTools": "Volver a todas las herramientas",
|
|
||||||
"save": "Guardar",
|
"save": "Guardar",
|
||||||
"saving": "Guardando...",
|
"saving": "Guardando...",
|
||||||
"saveFailed": "No se pudo guardar la configuración de la herramienta",
|
"saveFailed": "No se pudo guardar la configuración de la herramienta",
|
||||||
@@ -872,10 +870,7 @@
|
|||||||
"subtitle": "El nuevo secreto de \"{{name}}\" está listo. El anterior ya no funciona."
|
"subtitle": "El nuevo secreto de \"{{name}}\" está listo. El anterior ya no funciona."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"scrollTabsLeft": "Desplazar pestañas a la izquierda",
|
|
||||||
"tabsAriaLabel": "Pestañas de configuración",
|
|
||||||
"scrollTabsRight": "Desplazar pestañas a la derecha"
|
|
||||||
},
|
},
|
||||||
"modals": {
|
"modals": {
|
||||||
"uploadDoc": {
|
"uploadDoc": {
|
||||||
@@ -1647,7 +1642,16 @@
|
|||||||
"increaseZoom": "Aumentar zoom"
|
"increaseZoom": "Aumentar zoom"
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"agents": "Agentes"
|
"agents": "Agentes",
|
||||||
|
"backToApp": "Volver a la app",
|
||||||
|
"sections": {
|
||||||
|
"groups": {
|
||||||
|
"personal": "Personal",
|
||||||
|
"workspace": "Espacio de trabajo",
|
||||||
|
"insights": "Estadísticas",
|
||||||
|
"administration": "Administración"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"teams": {
|
"teams": {
|
||||||
"switcher": {
|
"switcher": {
|
||||||
|
|||||||
@@ -436,7 +436,6 @@
|
|||||||
"label": "チーム",
|
"label": "チーム",
|
||||||
"subtitle": "チーム、メンバー、共有リソースを管理します。",
|
"subtitle": "チーム、メンバー、共有リソースを管理します。",
|
||||||
"newTeam": "新しいチーム",
|
"newTeam": "新しいチーム",
|
||||||
"backToTeams": "チーム一覧に戻る",
|
|
||||||
"createTeam": "チームを作成",
|
"createTeam": "チームを作成",
|
||||||
"createTeamDescription": "チームに名前を付けてください。作成後にメンバーの追加やリソースの共有ができます。",
|
"createTeamDescription": "チームに名前を付けてください。作成後にメンバーの追加やリソースの共有ができます。",
|
||||||
"editTeam": "編集",
|
"editTeam": "編集",
|
||||||
@@ -565,7 +564,6 @@
|
|||||||
"queryParameters": "クエリパラメータ",
|
"queryParameters": "クエリパラメータ",
|
||||||
"body": "ボディ",
|
"body": "ボディ",
|
||||||
"deleteActionWarning": "アクション \"{{name}}\" を削除してもよろしいですか?",
|
"deleteActionWarning": "アクション \"{{name}}\" を削除してもよろしいですか?",
|
||||||
"backToAllTools": "すべてのツールに戻る",
|
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
"saving": "保存中...",
|
"saving": "保存中...",
|
||||||
"saveFailed": "ツール設定の保存に失敗しました",
|
"saveFailed": "ツール設定の保存に失敗しました",
|
||||||
@@ -872,10 +870,7 @@
|
|||||||
"subtitle": "「{{name}}」の新しいシークレットの準備ができました。以前のものは使用できません。"
|
"subtitle": "「{{name}}」の新しいシークレットの準備ができました。以前のものは使用できません。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"scrollTabsLeft": "タブを左にスクロール",
|
|
||||||
"tabsAriaLabel": "設定タブ",
|
|
||||||
"scrollTabsRight": "タブを右にスクロール"
|
|
||||||
},
|
},
|
||||||
"modals": {
|
"modals": {
|
||||||
"uploadDoc": {
|
"uploadDoc": {
|
||||||
@@ -1647,7 +1642,16 @@
|
|||||||
"increaseZoom": "ズームイン"
|
"increaseZoom": "ズームイン"
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"agents": "エージェント"
|
"agents": "エージェント",
|
||||||
|
"backToApp": "アプリに戻る",
|
||||||
|
"sections": {
|
||||||
|
"groups": {
|
||||||
|
"personal": "個人",
|
||||||
|
"workspace": "ワークスペース",
|
||||||
|
"insights": "インサイト",
|
||||||
|
"administration": "管理"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"teams": {
|
"teams": {
|
||||||
"switcher": {
|
"switcher": {
|
||||||
|
|||||||
@@ -436,7 +436,6 @@
|
|||||||
"label": "Команды",
|
"label": "Команды",
|
||||||
"subtitle": "Управляйте командами, участниками и общими ресурсами.",
|
"subtitle": "Управляйте командами, участниками и общими ресурсами.",
|
||||||
"newTeam": "Новая команда",
|
"newTeam": "Новая команда",
|
||||||
"backToTeams": "Назад к командам",
|
|
||||||
"createTeam": "Создать команду",
|
"createTeam": "Создать команду",
|
||||||
"createTeamDescription": "Дайте команде название. После создания вы сможете добавить участников и делиться ресурсами.",
|
"createTeamDescription": "Дайте команде название. После создания вы сможете добавить участников и делиться ресурсами.",
|
||||||
"editTeam": "Редактировать",
|
"editTeam": "Редактировать",
|
||||||
@@ -565,7 +564,6 @@
|
|||||||
"queryParameters": "Параметры запроса",
|
"queryParameters": "Параметры запроса",
|
||||||
"body": "Тело запроса",
|
"body": "Тело запроса",
|
||||||
"deleteActionWarning": "Вы уверены, что хотите удалить действие \"{{name}}\"?",
|
"deleteActionWarning": "Вы уверены, что хотите удалить действие \"{{name}}\"?",
|
||||||
"backToAllTools": "Вернуться ко всем инструментам",
|
|
||||||
"save": "Сохранить",
|
"save": "Сохранить",
|
||||||
"saving": "Сохранение...",
|
"saving": "Сохранение...",
|
||||||
"saveFailed": "Не удалось сохранить настройки инструмента",
|
"saveFailed": "Не удалось сохранить настройки инструмента",
|
||||||
@@ -892,10 +890,7 @@
|
|||||||
"subtitle": "Новый секрет для «{{name}}» готов. Старый больше не работает."
|
"subtitle": "Новый секрет для «{{name}}» готов. Старый больше не работает."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"scrollTabsLeft": "Прокрутить вкладки влево",
|
|
||||||
"tabsAriaLabel": "Вкладки настроек",
|
|
||||||
"scrollTabsRight": "Прокрутить вкладки вправо"
|
|
||||||
},
|
},
|
||||||
"modals": {
|
"modals": {
|
||||||
"uploadDoc": {
|
"uploadDoc": {
|
||||||
@@ -1667,7 +1662,16 @@
|
|||||||
"increaseZoom": "Увеличить масштаб"
|
"increaseZoom": "Увеличить масштаб"
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"agents": "Агенты"
|
"agents": "Агенты",
|
||||||
|
"backToApp": "Вернуться в приложение",
|
||||||
|
"sections": {
|
||||||
|
"groups": {
|
||||||
|
"personal": "Личное",
|
||||||
|
"workspace": "Рабочее пространство",
|
||||||
|
"insights": "Аналитика",
|
||||||
|
"administration": "Администрирование"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"teams": {
|
"teams": {
|
||||||
"switcher": {
|
"switcher": {
|
||||||
|
|||||||
@@ -436,7 +436,6 @@
|
|||||||
"label": "團隊",
|
"label": "團隊",
|
||||||
"subtitle": "管理您的團隊、成員與共享資源。",
|
"subtitle": "管理您的團隊、成員與共享資源。",
|
||||||
"newTeam": "新增團隊",
|
"newTeam": "新增團隊",
|
||||||
"backToTeams": "返回團隊列表",
|
|
||||||
"createTeam": "建立團隊",
|
"createTeam": "建立團隊",
|
||||||
"createTeamDescription": "為您的團隊命名。建立後即可新增成員並分享資源。",
|
"createTeamDescription": "為您的團隊命名。建立後即可新增成員並分享資源。",
|
||||||
"editTeam": "編輯",
|
"editTeam": "編輯",
|
||||||
@@ -565,7 +564,6 @@
|
|||||||
"queryParameters": "查詢參數",
|
"queryParameters": "查詢參數",
|
||||||
"body": "主體",
|
"body": "主體",
|
||||||
"deleteActionWarning": "您確定要刪除操作 \"{{name}}\" 嗎?",
|
"deleteActionWarning": "您確定要刪除操作 \"{{name}}\" 嗎?",
|
||||||
"backToAllTools": "返回所有工具",
|
|
||||||
"save": "儲存",
|
"save": "儲存",
|
||||||
"saving": "儲存中...",
|
"saving": "儲存中...",
|
||||||
"saveFailed": "儲存工具設定失敗",
|
"saveFailed": "儲存工具設定失敗",
|
||||||
@@ -872,10 +870,7 @@
|
|||||||
"subtitle": "「{{name}}」的新密鑰已就緒,舊密鑰已失效。"
|
"subtitle": "「{{name}}」的新密鑰已就緒,舊密鑰已失效。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"scrollTabsLeft": "向左捲動標籤",
|
|
||||||
"tabsAriaLabel": "設定標籤",
|
|
||||||
"scrollTabsRight": "向右捲動標籤"
|
|
||||||
},
|
},
|
||||||
"modals": {
|
"modals": {
|
||||||
"uploadDoc": {
|
"uploadDoc": {
|
||||||
@@ -1647,7 +1642,16 @@
|
|||||||
"increaseZoom": "放大"
|
"increaseZoom": "放大"
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"agents": "代理"
|
"agents": "代理",
|
||||||
|
"backToApp": "返回應用程式",
|
||||||
|
"sections": {
|
||||||
|
"groups": {
|
||||||
|
"personal": "個人",
|
||||||
|
"workspace": "工作區",
|
||||||
|
"insights": "洞察",
|
||||||
|
"administration": "管理"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"teams": {
|
"teams": {
|
||||||
"switcher": {
|
"switcher": {
|
||||||
|
|||||||
@@ -436,7 +436,6 @@
|
|||||||
"label": "团队",
|
"label": "团队",
|
||||||
"subtitle": "管理您的团队、成员和共享资源。",
|
"subtitle": "管理您的团队、成员和共享资源。",
|
||||||
"newTeam": "新建团队",
|
"newTeam": "新建团队",
|
||||||
"backToTeams": "返回团队列表",
|
|
||||||
"createTeam": "创建团队",
|
"createTeam": "创建团队",
|
||||||
"createTeamDescription": "为您的团队命名。创建后即可添加成员并共享资源。",
|
"createTeamDescription": "为您的团队命名。创建后即可添加成员并共享资源。",
|
||||||
"editTeam": "编辑",
|
"editTeam": "编辑",
|
||||||
@@ -565,7 +564,6 @@
|
|||||||
"queryParameters": "查询参数",
|
"queryParameters": "查询参数",
|
||||||
"body": "请求体",
|
"body": "请求体",
|
||||||
"deleteActionWarning": "您确定要删除操作 \"{{name}}\" 吗?",
|
"deleteActionWarning": "您确定要删除操作 \"{{name}}\" 吗?",
|
||||||
"backToAllTools": "返回所有工具",
|
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
"saving": "保存中...",
|
"saving": "保存中...",
|
||||||
"saveFailed": "保存工具配置失败",
|
"saveFailed": "保存工具配置失败",
|
||||||
@@ -872,10 +870,7 @@
|
|||||||
"subtitle": "“{{name}}”的新密钥已就绪,旧密钥已失效。"
|
"subtitle": "“{{name}}”的新密钥已就绪,旧密钥已失效。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"scrollTabsLeft": "向左滚动标签",
|
|
||||||
"tabsAriaLabel": "设置标签",
|
|
||||||
"scrollTabsRight": "向右滚动标签"
|
|
||||||
},
|
},
|
||||||
"modals": {
|
"modals": {
|
||||||
"uploadDoc": {
|
"uploadDoc": {
|
||||||
@@ -1647,7 +1642,16 @@
|
|||||||
"increaseZoom": "放大"
|
"increaseZoom": "放大"
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"agents": "代理"
|
"agents": "代理",
|
||||||
|
"backToApp": "返回应用",
|
||||||
|
"sections": {
|
||||||
|
"groups": {
|
||||||
|
"personal": "个人",
|
||||||
|
"workspace": "工作区",
|
||||||
|
"insights": "洞察",
|
||||||
|
"administration": "管理"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"teams": {
|
"teams": {
|
||||||
"switcher": {
|
"switcher": {
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
} from '@/components/ui/breadcrumb';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
type DetailBreadcrumbProps = {
|
||||||
|
parentLabel: string;
|
||||||
|
currentLabel: string;
|
||||||
|
/** Returns to the list view; kept a callback because these detail views are
|
||||||
|
* component state rather than routes, and some guard unsaved changes. */
|
||||||
|
onParentClick: () => void;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trail for a detail view nested inside a section page (a tool's config, a
|
||||||
|
* team). The section nav's back button always means "leave the section", so
|
||||||
|
* going up one level is a breadcrumb here rather than a second back arrow.
|
||||||
|
*/
|
||||||
|
export default function DetailBreadcrumb({
|
||||||
|
parentLabel,
|
||||||
|
currentLabel,
|
||||||
|
onParentClick,
|
||||||
|
className,
|
||||||
|
}: DetailBreadcrumbProps) {
|
||||||
|
return (
|
||||||
|
<Breadcrumb className={cn('min-w-0', className)}>
|
||||||
|
<BreadcrumbList className="flex-nowrap">
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbLink asChild>
|
||||||
|
<button type="button" onClick={onParentClick}>
|
||||||
|
{parentLabel}
|
||||||
|
</button>
|
||||||
|
</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
<BreadcrumbSeparator />
|
||||||
|
<BreadcrumbItem className="min-w-0">
|
||||||
|
<BreadcrumbPage
|
||||||
|
title={currentLabel}
|
||||||
|
className="max-w-[32ch] truncate"
|
||||||
|
>
|
||||||
|
{currentLabel}
|
||||||
|
</BreadcrumbPage>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { selectIsAdmin } from '@/preferences/preferenceSlice';
|
||||||
|
|
||||||
|
import { getVisibleGroups, type Section } from './sections';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small-screen landing page for a section: the same destinations as the
|
||||||
|
* sidebar nav, rendered as page content so they stay reachable while the
|
||||||
|
* sidebar is an overlay. Tapping a row pushes its page, which carries a back
|
||||||
|
* link to here.
|
||||||
|
*/
|
||||||
|
export default function SectionIndexPage({ section }: { section: Section }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const isAdmin = useSelector(selectIsAdmin);
|
||||||
|
const groups = getVisibleGroups(section, { isAdmin });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<h1 className="text-foreground dark:text-foreground text-2xl font-bold">
|
||||||
|
{t(section.titleKey)}
|
||||||
|
</h1>
|
||||||
|
<div className="mt-6 flex flex-col gap-6">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<div key={group.key} className="flex flex-col gap-1">
|
||||||
|
{group.labelKey && (
|
||||||
|
<p className="text-muted-foreground mb-1 text-xs font-medium">
|
||||||
|
{t(group.labelKey)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="border-border divide-border divide-y overflow-hidden rounded-2xl border">
|
||||||
|
{group.items.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.key}
|
||||||
|
to={item.path}
|
||||||
|
className="hover:bg-muted dark:hover:bg-accent flex items-center gap-3 px-4 py-3.5"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className="text-muted-foreground size-5 shrink-0"
|
||||||
|
strokeWidth={1.75}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="text-foreground dark:text-foreground flex-1 text-sm">
|
||||||
|
{t(item.labelKey)}
|
||||||
|
</span>
|
||||||
|
<ChevronRight
|
||||||
|
className="text-muted-foreground size-4 shrink-0"
|
||||||
|
strokeWidth={1.75}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
import { getVisibleGroups, type Section, type SectionItem } from './sections';
|
||||||
|
|
||||||
|
type SectionNavProps = {
|
||||||
|
section: Section;
|
||||||
|
/** Key of the item matching the current route, from ``getActiveItem``. */
|
||||||
|
activeItemKey?: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
/** Leaves the section — always "exit", never "up one level". */
|
||||||
|
onBack: () => void;
|
||||||
|
backLabel: string;
|
||||||
|
onNavigate?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertical nav that replaces the chat list while the user is inside a section.
|
||||||
|
*
|
||||||
|
* The only back affordance at this level is ``onBack``, which exits the
|
||||||
|
* section; anything deeper (a tool's config, a team's detail) keeps its own
|
||||||
|
* breadcrumb in the content column so the two never compete.
|
||||||
|
*/
|
||||||
|
export default function SectionNav({
|
||||||
|
section,
|
||||||
|
activeItemKey,
|
||||||
|
isAdmin,
|
||||||
|
onBack,
|
||||||
|
backLabel,
|
||||||
|
onNavigate,
|
||||||
|
}: SectionNavProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const groups = getVisibleGroups(section, { isAdmin });
|
||||||
|
|
||||||
|
const renderItem = (item: SectionItem) => {
|
||||||
|
const isActive = item.key === activeItemKey;
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.key}
|
||||||
|
to={item.path}
|
||||||
|
onClick={onNavigate}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
className={cn(
|
||||||
|
'hover:bg-sidebar-accent mx-4 my-1 flex h-9 cursor-pointer items-center gap-2.5 rounded-3xl pl-3',
|
||||||
|
isActive && 'bg-sidebar-accent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className="text-muted-foreground size-5 shrink-0"
|
||||||
|
strokeWidth={1.75}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<p className="text-foreground dark:text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
|
||||||
|
{t(item.labelKey)}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
className="group border-sidebar-border hover:border-sidebar-border mx-4 mt-4 flex shrink-0 cursor-pointer items-center gap-2.5 rounded-3xl border p-3 text-left"
|
||||||
|
>
|
||||||
|
<ArrowLeft
|
||||||
|
className="text-muted-foreground group-hover:text-foreground size-5 shrink-0"
|
||||||
|
strokeWidth={1.75}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground group-hover:text-foreground text-sm">
|
||||||
|
{backLabel}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
<p className="text-foreground mt-6 ml-8 shrink-0 text-sm font-semibold dark:text-white">
|
||||||
|
{t(section.titleKey)}
|
||||||
|
</p>
|
||||||
|
<nav
|
||||||
|
aria-label={t(section.titleKey)}
|
||||||
|
className="scrollbar-overlay mt-3 flex-1 overflow-x-hidden overflow-y-auto pb-4"
|
||||||
|
>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<div key={group.key} className="mt-5 first:mt-0">
|
||||||
|
{group.labelKey && (
|
||||||
|
<p className="text-muted-foreground mb-1 ml-8 text-xs font-medium">
|
||||||
|
{t(group.labelKey)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{group.items.map(renderItem)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useMediaQuery } from '@/hooks';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
import type { Section, SectionItem } from './sections';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Back to the section's index. Only rendered below ``lg``, where the sidebar
|
||||||
|
* is an overlay and the section nav would otherwise sit behind the hamburger.
|
||||||
|
* On desktop the sidebar itself is the way back, so this stays out of the way.
|
||||||
|
*/
|
||||||
|
export function SectionBackLink({
|
||||||
|
section,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
section: Section;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isMobile, isTablet } = useMediaQuery();
|
||||||
|
|
||||||
|
if (!(isMobile || isTablet)) return null;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={section.rootPath}
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground hover:text-foreground mb-4 inline-flex items-center gap-2 text-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4 shrink-0" strokeWidth={1.75} aria-hidden />
|
||||||
|
{t(section.titleKey)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Title block for a section page: the active item's name, preceded on small
|
||||||
|
* screens by a link back to the section index.
|
||||||
|
*/
|
||||||
|
export default function SectionPageHeader({
|
||||||
|
section,
|
||||||
|
item,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
section: Section;
|
||||||
|
item: SectionItem | null;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col', className)}>
|
||||||
|
<SectionBackLink section={section} />
|
||||||
|
<h1 className="text-foreground dark:text-foreground text-2xl font-bold">
|
||||||
|
{t(item?.labelKey ?? section.titleKey)}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
import { getSectionItems, type Section } from './sections';
|
||||||
|
|
||||||
|
type SectionRailProps = {
|
||||||
|
section: Section;
|
||||||
|
activeItemKey?: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
onBack: () => void;
|
||||||
|
backLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapsed-sidebar counterpart to ``SectionNav``: the same destinations as
|
||||||
|
* icons, so collapsing the sidebar inside a section still leaves the section
|
||||||
|
* navigable instead of stranding the user on one page.
|
||||||
|
*/
|
||||||
|
export default function SectionRail({
|
||||||
|
section,
|
||||||
|
activeItemKey,
|
||||||
|
isAdmin,
|
||||||
|
onBack,
|
||||||
|
backLabel,
|
||||||
|
}: SectionRailProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const items = getSectionItems(section, { isAdmin });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onBack}
|
||||||
|
aria-label={backLabel}
|
||||||
|
title={backLabel}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-5" strokeWidth={1.75} />
|
||||||
|
</Button>
|
||||||
|
<div className="bg-border my-1 h-px w-6 shrink-0" aria-hidden />
|
||||||
|
{items.map((item) => {
|
||||||
|
const label = t(item.labelKey);
|
||||||
|
const isActive = item.key === activeItemKey;
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.key}
|
||||||
|
to={item.path}
|
||||||
|
aria-label={label}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
title={label}
|
||||||
|
className={cn(
|
||||||
|
'hover:bg-sidebar-accent text-muted-foreground hover:text-foreground flex size-9 items-center justify-center rounded-full',
|
||||||
|
isActive && 'bg-sidebar-accent text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-5" strokeWidth={1.75} aria-hidden />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ADMIN_SECTION,
|
||||||
|
getActiveItem,
|
||||||
|
getSectionForPath,
|
||||||
|
getSectionItems,
|
||||||
|
getVisibleGroups,
|
||||||
|
SETTINGS_SECTION,
|
||||||
|
} from './sections';
|
||||||
|
|
||||||
|
describe('getSectionForPath', () => {
|
||||||
|
it('claims the section root and everything under it', () => {
|
||||||
|
expect(getSectionForPath('/settings')?.key).toBe('settings');
|
||||||
|
expect(getSectionForPath('/settings/tools')?.key).toBe('settings');
|
||||||
|
expect(getSectionForPath('/admin/users')?.key).toBe('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('claims routes the section owns outside its root', () => {
|
||||||
|
expect(getSectionForPath('/teams')?.key).toBe('settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves ordinary app routes alone', () => {
|
||||||
|
expect(getSectionForPath('/')).toBeNull();
|
||||||
|
expect(getSectionForPath('/c/abc123')).toBeNull();
|
||||||
|
expect(getSectionForPath('/agents/edit/1')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches whole path segments, not string prefixes', () => {
|
||||||
|
expect(getSectionForPath('/settings-export')).toBeNull();
|
||||||
|
expect(getSectionForPath('/administrators')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getActiveItem', () => {
|
||||||
|
it('resolves the section root through the alias', () => {
|
||||||
|
expect(getActiveItem(SETTINGS_SECTION, '/settings')?.key).toBe('general');
|
||||||
|
expect(getActiveItem(ADMIN_SECTION, '/admin')?.key).toBe('overview');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the deepest match over a shorter alias', () => {
|
||||||
|
expect(getActiveItem(SETTINGS_SECTION, '/settings/tools')?.key).toBe(
|
||||||
|
'tools',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
getActiveItem(SETTINGS_SECTION, '/settings/access-tokens')?.key,
|
||||||
|
).toBe('accessTokens');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the parent item active inside a detail route', () => {
|
||||||
|
expect(getActiveItem(SETTINGS_SECTION, '/settings/tools/slack')?.key).toBe(
|
||||||
|
'tools',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips every configured item', () => {
|
||||||
|
for (const section of [SETTINGS_SECTION, ADMIN_SECTION]) {
|
||||||
|
for (const item of getSectionItems(section)) {
|
||||||
|
// Items pointing at another section are drill-throughs, not
|
||||||
|
// destinations this section can be "on".
|
||||||
|
if (item.leavesSection) continue;
|
||||||
|
expect(getActiveItem(section, item.path)?.key).toBe(item.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getVisibleGroups', () => {
|
||||||
|
it('hides admin-only entries and the group left empty by them', () => {
|
||||||
|
const groups = getVisibleGroups(SETTINGS_SECTION, { isAdmin: false });
|
||||||
|
const keys = groups.flatMap((group) => group.items.map((i) => i.key));
|
||||||
|
|
||||||
|
expect(keys).not.toContain('admin');
|
||||||
|
expect(groups.map((group) => group.key)).not.toContain('administration');
|
||||||
|
expect(keys).toContain('general');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows them to admins', () => {
|
||||||
|
const keys = getVisibleGroups(SETTINGS_SECTION, { isAdmin: true }).flatMap(
|
||||||
|
(group) => group.items.map((i) => i.key),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(keys).toContain('admin');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import {
|
||||||
|
BarChart3,
|
||||||
|
Boxes,
|
||||||
|
ChartNoAxesColumn,
|
||||||
|
Database,
|
||||||
|
FileClock,
|
||||||
|
Gauge,
|
||||||
|
KeyRound,
|
||||||
|
LayoutDashboard,
|
||||||
|
ScrollText,
|
||||||
|
Settings2,
|
||||||
|
ShieldCheck,
|
||||||
|
UserCog,
|
||||||
|
Users,
|
||||||
|
Wrench,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
/** A single destination in a section's vertical nav. */
|
||||||
|
export type SectionItem = {
|
||||||
|
key: string;
|
||||||
|
path: string;
|
||||||
|
labelKey: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
/** Extra pathnames that also mark this item active (e.g. the section root). */
|
||||||
|
aliases?: string[];
|
||||||
|
/** Hidden from users without the global admin role. */
|
||||||
|
adminOnly?: boolean;
|
||||||
|
/** Jumps to a different section rather than navigating within this one. */
|
||||||
|
leavesSection?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Items sharing a heading in the nav. */
|
||||||
|
export type SectionGroup = {
|
||||||
|
key: string;
|
||||||
|
labelKey?: string;
|
||||||
|
items: SectionItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A top-level area that takes over the sidebar while the user is inside it.
|
||||||
|
* `matches` lists the pathnames the section owns — entering any of them swaps
|
||||||
|
* the sidebar from the chat list to this section's nav.
|
||||||
|
*/
|
||||||
|
export type Section = {
|
||||||
|
key: string;
|
||||||
|
rootPath: string;
|
||||||
|
titleKey: string;
|
||||||
|
matches: string[];
|
||||||
|
groups: SectionGroup[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SETTINGS_SECTION: Section = {
|
||||||
|
key: 'settings',
|
||||||
|
rootPath: '/settings',
|
||||||
|
titleKey: 'settings.label',
|
||||||
|
matches: ['/settings', '/teams'],
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
key: 'personal',
|
||||||
|
labelKey: 'navigation.sections.groups.personal',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'general',
|
||||||
|
path: '/settings/general',
|
||||||
|
labelKey: 'settings.general.label',
|
||||||
|
icon: Settings2,
|
||||||
|
aliases: ['/settings'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'accessTokens',
|
||||||
|
path: '/settings/access-tokens',
|
||||||
|
labelKey: 'settings.accessTokens.label',
|
||||||
|
icon: KeyRound,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'workspace',
|
||||||
|
labelKey: 'navigation.sections.groups.workspace',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'sources',
|
||||||
|
path: '/settings/sources',
|
||||||
|
labelKey: 'settings.sources.label',
|
||||||
|
icon: Database,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tools',
|
||||||
|
path: '/settings/tools',
|
||||||
|
labelKey: 'settings.tools.label',
|
||||||
|
icon: Wrench,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'customModels',
|
||||||
|
path: '/settings/custom-models',
|
||||||
|
labelKey: 'settings.customModels.label',
|
||||||
|
icon: Boxes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'teams',
|
||||||
|
path: '/teams',
|
||||||
|
labelKey: 'settings.teams.label',
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'insights',
|
||||||
|
labelKey: 'navigation.sections.groups.insights',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'analytics',
|
||||||
|
path: '/settings/analytics',
|
||||||
|
labelKey: 'settings.analytics.label',
|
||||||
|
icon: ChartNoAxesColumn,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'logs',
|
||||||
|
path: '/settings/logs',
|
||||||
|
labelKey: 'settings.logs.label',
|
||||||
|
icon: ScrollText,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'administration',
|
||||||
|
labelKey: 'navigation.sections.groups.administration',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'admin',
|
||||||
|
path: '/admin',
|
||||||
|
labelKey: 'admin.label',
|
||||||
|
icon: ShieldCheck,
|
||||||
|
adminOnly: true,
|
||||||
|
leavesSection: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ADMIN_SECTION: Section = {
|
||||||
|
key: 'admin',
|
||||||
|
rootPath: '/admin',
|
||||||
|
titleKey: 'admin.label',
|
||||||
|
matches: ['/admin'],
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
key: 'admin',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'overview',
|
||||||
|
path: '/admin/overview',
|
||||||
|
labelKey: 'admin.tabs.overview',
|
||||||
|
icon: LayoutDashboard,
|
||||||
|
aliases: ['/admin'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'users',
|
||||||
|
path: '/admin/users',
|
||||||
|
labelKey: 'admin.tabs.users',
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'admins',
|
||||||
|
path: '/admin/roles',
|
||||||
|
labelKey: 'admin.tabs.admins',
|
||||||
|
icon: UserCog,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'usage',
|
||||||
|
path: '/admin/usage',
|
||||||
|
labelKey: 'admin.tabs.usage',
|
||||||
|
icon: BarChart3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'quotas',
|
||||||
|
path: '/admin/quotas',
|
||||||
|
labelKey: 'admin.tabs.quotas',
|
||||||
|
icon: Gauge,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'audit',
|
||||||
|
path: '/admin/audit',
|
||||||
|
labelKey: 'admin.tabs.audit',
|
||||||
|
icon: FileClock,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SECTIONS: Section[] = [SETTINGS_SECTION, ADMIN_SECTION];
|
||||||
|
|
||||||
|
const pathMatches = (pathname: string, path: string): boolean =>
|
||||||
|
pathname === path || pathname.startsWith(`${path}/`);
|
||||||
|
|
||||||
|
/** The section owning ``pathname``, or null when it is an ordinary app route. */
|
||||||
|
export function getSectionForPath(pathname: string): Section | null {
|
||||||
|
return (
|
||||||
|
SECTIONS.find((section) =>
|
||||||
|
section.matches.some((match) => pathMatches(pathname, match)),
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flattened items of a section, optionally dropping admin-only entries. */
|
||||||
|
export function getSectionItems(
|
||||||
|
section: Section,
|
||||||
|
{ isAdmin = true }: { isAdmin?: boolean } = {},
|
||||||
|
): SectionItem[] {
|
||||||
|
return section.groups
|
||||||
|
.flatMap((group) => group.items)
|
||||||
|
.filter((item) => !item.adminOnly || isAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Groups with admin-only entries removed, dropping any group left empty. */
|
||||||
|
export function getVisibleGroups(
|
||||||
|
section: Section,
|
||||||
|
{ isAdmin = true }: { isAdmin?: boolean } = {},
|
||||||
|
): SectionGroup[] {
|
||||||
|
return section.groups
|
||||||
|
.map((group) => ({
|
||||||
|
...group,
|
||||||
|
items: group.items.filter((item) => !item.adminOnly || isAdmin),
|
||||||
|
}))
|
||||||
|
.filter((group) => group.items.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The nav item ``pathname`` belongs to. Longest match wins, so a deeper route
|
||||||
|
* (``/settings/tools/slack``) beats a shorter alias (``/settings``).
|
||||||
|
*/
|
||||||
|
export function getActiveItem(
|
||||||
|
section: Section,
|
||||||
|
pathname: string,
|
||||||
|
): SectionItem | null {
|
||||||
|
let best: SectionItem | null = null;
|
||||||
|
let bestLength = -1;
|
||||||
|
for (const item of getSectionItems(section)) {
|
||||||
|
for (const candidate of [item.path, ...(item.aliases ?? [])]) {
|
||||||
|
if (pathMatches(pathname, candidate) && candidate.length > bestLength) {
|
||||||
|
best = item;
|
||||||
|
bestLength = candidate.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Route-derived section state — no extra store, so deep links keep working. */
|
||||||
|
export function useActiveSection(): {
|
||||||
|
section: Section | null;
|
||||||
|
item: SectionItem | null;
|
||||||
|
} {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const section = getSectionForPath(pathname);
|
||||||
|
return {
|
||||||
|
section,
|
||||||
|
item: section ? getActiveItem(section, pathname) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { getSectionForPath } from './sections';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remembers the last route outside any section, so leaving a section returns
|
||||||
|
* the user to the conversation they were in rather than to a blank chat.
|
||||||
|
*/
|
||||||
|
export function useLastAppPath(fallback = '/') {
|
||||||
|
const location = useLocation();
|
||||||
|
const lastAppPath = useRef(fallback);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!getSectionForPath(location.pathname))
|
||||||
|
lastAppPath.current = `${location.pathname}${location.search}`;
|
||||||
|
}, [location.pathname, location.search]);
|
||||||
|
|
||||||
|
return lastAppPath;
|
||||||
|
}
|
||||||
@@ -7,10 +7,10 @@ import devicesService, {
|
|||||||
AuditEntry,
|
AuditEntry,
|
||||||
Device,
|
Device,
|
||||||
} from '../api/services/devicesService';
|
} from '../api/services/devicesService';
|
||||||
import ArrowLeft from '../assets/arrow-left.svg';
|
|
||||||
import CopyButton from '../components/CopyButton';
|
import CopyButton from '../components/CopyButton';
|
||||||
import Spinner from '../components/Spinner';
|
import Spinner from '../components/Spinner';
|
||||||
import ToolIcon from '../components/ToolIcon';
|
import ToolIcon from '../components/ToolIcon';
|
||||||
|
import DetailBreadcrumb from '../navigation/DetailBreadcrumb';
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
@@ -194,19 +194,12 @@ export default function RemoteDeviceConfig({ tool, handleGoBack }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="scrollbar-overlay mt-8 flex flex-col gap-6">
|
<div className="scrollbar-overlay mt-8 flex flex-col gap-6">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
<div className="text-foreground dark:text-foreground flex items-center gap-3 text-sm">
|
<DetailBreadcrumb
|
||||||
<Button
|
parentLabel={t('settings.tools.label')}
|
||||||
type="button"
|
currentLabel={tool.displayName || tool.customName || tool.name}
|
||||||
variant="outline"
|
onParentClick={handleGoBack}
|
||||||
size="icon"
|
/>
|
||||||
className="text-muted-foreground rounded-full p-3"
|
|
||||||
onClick={handleGoBack}
|
|
||||||
>
|
|
||||||
<img src={ArrowLeft} alt="left-arrow" className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<p className="mt-px">{t('settings.tools.backToAllTools')}</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-full px-3 py-2 text-xs text-nowrap text-white sm:px-4 sm:py-2"
|
className="rounded-full px-3 py-2 text-xs text-nowrap text-white sm:px-4 sm:py-2"
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
ArrowLeft,
|
|
||||||
Bot,
|
Bot,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -24,6 +23,9 @@ import userService from '../api/services/userService';
|
|||||||
import NoFilesDarkIcon from '../assets/no-files-dark.svg';
|
import NoFilesDarkIcon from '../assets/no-files-dark.svg';
|
||||||
import NoFilesIcon from '../assets/no-files.svg';
|
import NoFilesIcon from '../assets/no-files.svg';
|
||||||
import SkeletonLoader from '../components/SkeletonLoader';
|
import SkeletonLoader from '../components/SkeletonLoader';
|
||||||
|
import DetailBreadcrumb from '../navigation/DetailBreadcrumb';
|
||||||
|
import { SectionBackLink } from '../navigation/SectionPageHeader';
|
||||||
|
import { SETTINGS_SECTION } from '../navigation/sections';
|
||||||
import { Avatar } from '../components/ui/avatar';
|
import { Avatar } from '../components/ui/avatar';
|
||||||
import { Button } from '../components/ui/button';
|
import { Button } from '../components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -511,6 +513,9 @@ export default function Teams() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-auto p-4 md:p-12">
|
<div className="h-full overflow-auto p-4 md:p-12">
|
||||||
|
<div className="mx-auto w-full max-w-5xl">
|
||||||
|
<SectionBackLink section={SETTINGS_SECTION} />
|
||||||
|
</div>
|
||||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="mx-auto flex w-full max-w-5xl flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-foreground text-2xl font-bold">
|
<p className="text-foreground text-2xl font-bold">
|
||||||
@@ -607,20 +612,16 @@ export default function Teams() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mx-auto mt-8 max-w-5xl space-y-6">
|
<div className="mx-auto mt-8 max-w-5xl space-y-6">
|
||||||
<Button
|
<DetailBreadcrumb
|
||||||
variant="ghost"
|
parentLabel={t('settings.teams.label')}
|
||||||
size="sm"
|
currentLabel={selected.name}
|
||||||
className="text-muted-foreground -ml-2"
|
onParentClick={() => {
|
||||||
onClick={() => {
|
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
setMembers([]);
|
setMembers([]);
|
||||||
setGrants([]);
|
setGrants([]);
|
||||||
setError(null);
|
setError(null);
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<ArrowLeft size={16} strokeWidth={1.75} aria-hidden />
|
|
||||||
{t('settings.teams.backToTeams')}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
import userService from '../api/services/userService';
|
import userService from '../api/services/userService';
|
||||||
import ArrowLeft from '../assets/arrow-left.svg';
|
|
||||||
import ChevronRight from '../assets/chevron-right.svg';
|
import ChevronRight from '../assets/chevron-right.svg';
|
||||||
import CircleCheck from '../assets/circle-check.svg';
|
import CircleCheck from '../assets/circle-check.svg';
|
||||||
import CircleX from '../assets/circle-x.svg';
|
import CircleX from '../assets/circle-x.svg';
|
||||||
@@ -24,6 +23,7 @@ import { Switch } from '../components/ui/switch';
|
|||||||
import { useDarkTheme } from '../hooks';
|
import { useDarkTheme } from '../hooks';
|
||||||
import AddActionModal from '../modals/AddActionModal';
|
import AddActionModal from '../modals/AddActionModal';
|
||||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||||
|
import DetailBreadcrumb from '../navigation/DetailBreadcrumb';
|
||||||
import ImportSpecModal from '../modals/ImportSpecModal';
|
import ImportSpecModal from '../modals/ImportSpecModal';
|
||||||
import { ActiveState } from '../models/misc';
|
import { ActiveState } from '../models/misc';
|
||||||
import { selectToken } from '../preferences/preferenceSlice';
|
import { selectToken } from '../preferences/preferenceSlice';
|
||||||
@@ -330,19 +330,12 @@ export default function ToolConfig({
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="scrollbar-overlay mt-8 flex flex-col gap-4">
|
<div className="scrollbar-overlay mt-8 flex flex-col gap-4">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
<div className="text-foreground dark:text-foreground flex items-center gap-3 text-sm">
|
<DetailBreadcrumb
|
||||||
<Button
|
parentLabel={t('settings.tools.label')}
|
||||||
type="button"
|
currentLabel={tool.displayName || tool.customName || tool.name}
|
||||||
variant="outline"
|
onParentClick={handleBackClick}
|
||||||
size="icon"
|
/>
|
||||||
className="text-muted-foreground rounded-full p-3"
|
|
||||||
onClick={handleBackClick}
|
|
||||||
>
|
|
||||||
<img src={ArrowLeft} alt="left-arrow" className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<p className="mt-px">{t('settings.tools.backToAllTools')}</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-full px-3 py-2 text-xs text-nowrap text-white sm:px-4 sm:py-2"
|
className="rounded-full px-3 py-2 text-xs text-nowrap text-white sm:px-4 sm:py-2"
|
||||||
|
|||||||
+48
-157
@@ -1,21 +1,12 @@
|
|||||||
import React, { useCallback, useRef, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import {
|
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
|
||||||
Navigate,
|
|
||||||
Route,
|
|
||||||
Routes,
|
|
||||||
useLocation,
|
|
||||||
useNavigate,
|
|
||||||
} from 'react-router-dom';
|
|
||||||
|
|
||||||
import userService from '../api/services/userService';
|
import userService from '../api/services/userService';
|
||||||
import ArrowLeft from '../assets/arrow-left.svg';
|
import { useMediaQuery } from '../hooks';
|
||||||
import ArrowRight from '../assets/arrow-right.svg';
|
|
||||||
import { Button } from '../components/ui/button';
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from '../components/ui/tabs';
|
|
||||||
import i18n from '../locale/i18n';
|
|
||||||
import { Doc } from '../models/misc';
|
import { Doc } from '../models/misc';
|
||||||
|
import SectionIndexPage from '../navigation/SectionIndexPage';
|
||||||
|
import SectionPageHeader from '../navigation/SectionPageHeader';
|
||||||
|
import { getActiveItem, SETTINGS_SECTION } from '../navigation/sections';
|
||||||
import {
|
import {
|
||||||
selectPaginatedDocuments,
|
selectPaginatedDocuments,
|
||||||
selectSourceDocs,
|
selectSourceDocs,
|
||||||
@@ -25,90 +16,26 @@ import {
|
|||||||
} from '../preferences/preferenceSlice';
|
} from '../preferences/preferenceSlice';
|
||||||
import Analytics from './Analytics';
|
import Analytics from './Analytics';
|
||||||
import CustomModels from './CustomModels';
|
import CustomModels from './CustomModels';
|
||||||
import Sources from './Sources';
|
|
||||||
import General from './General';
|
import General from './General';
|
||||||
import Logs from './Logs';
|
import Logs from './Logs';
|
||||||
import PersonalAccessTokens from './PersonalAccessTokens';
|
import PersonalAccessTokens from './PersonalAccessTokens';
|
||||||
|
import Sources from './Sources';
|
||||||
import Tools from './Tools';
|
import Tools from './Tools';
|
||||||
|
|
||||||
type HiddenGradientType = 'left' | 'right' | undefined;
|
/**
|
||||||
|
* Settings shell. The section's destinations live in the sidebar
|
||||||
|
* (see `navigation/SectionNav`), so this only renders the active page and
|
||||||
|
* its title — except below `lg`, where the sidebar is an overlay and
|
||||||
|
* `/settings` shows the destination list as page content instead.
|
||||||
|
*/
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { isMobile, isTablet } = useMediaQuery();
|
||||||
|
|
||||||
const getActiveTabFromPath = () => {
|
const activeItem = getActiveItem(SETTINGS_SECTION, location.pathname);
|
||||||
const path = location.pathname;
|
const showIndex =
|
||||||
if (path.includes('/settings/sources')) return t('settings.sources.label');
|
(isMobile || isTablet) && location.pathname === SETTINGS_SECTION.rootPath;
|
||||||
if (path.includes('/settings/analytics'))
|
|
||||||
return t('settings.analytics.label');
|
|
||||||
if (path.includes('/settings/logs')) return t('settings.logs.label');
|
|
||||||
if (path.includes('/settings/tools')) return t('settings.tools.label');
|
|
||||||
if (path.includes('/settings/custom-models'))
|
|
||||||
return t('settings.customModels.label');
|
|
||||||
if (path.includes('/settings/access-tokens'))
|
|
||||||
return t('settings.accessTokens.label');
|
|
||||||
return t('settings.general.label');
|
|
||||||
};
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = React.useState(getActiveTabFromPath());
|
|
||||||
const tabsList = [
|
|
||||||
t('settings.general.label'),
|
|
||||||
t('settings.sources.label'),
|
|
||||||
t('settings.analytics.label'),
|
|
||||||
t('settings.logs.label'),
|
|
||||||
t('settings.tools.label'),
|
|
||||||
t('settings.customModels.label'),
|
|
||||||
t('settings.accessTokens.label'),
|
|
||||||
];
|
|
||||||
const [hiddenGradient, setHiddenGradient] =
|
|
||||||
useState<HiddenGradientType>('left');
|
|
||||||
const containerRef = useRef<null | HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const scrollTabs = useCallback(
|
|
||||||
(direction: number) => {
|
|
||||||
if (containerRef.current) {
|
|
||||||
const container = containerRef.current;
|
|
||||||
container.scrollLeft += direction * 100;
|
|
||||||
if (container.scrollLeft === 0) {
|
|
||||||
setHiddenGradient('left');
|
|
||||||
} else if (
|
|
||||||
container.scrollLeft + container.offsetWidth ===
|
|
||||||
container.scrollWidth
|
|
||||||
) {
|
|
||||||
setHiddenGradient('right');
|
|
||||||
} else {
|
|
||||||
setHiddenGradient(undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[containerRef],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTabChange = (tab: string) => {
|
|
||||||
setActiveTab(tab);
|
|
||||||
if (tab === t('settings.general.label')) navigate('/settings');
|
|
||||||
else if (tab === t('settings.sources.label')) navigate('/settings/sources');
|
|
||||||
else if (tab === t('settings.analytics.label'))
|
|
||||||
navigate('/settings/analytics');
|
|
||||||
else if (tab === t('settings.logs.label')) navigate('/settings/logs');
|
|
||||||
else if (tab === t('settings.tools.label')) navigate('/settings/tools');
|
|
||||||
else if (tab === t('settings.customModels.label'))
|
|
||||||
navigate('/settings/custom-models');
|
|
||||||
else if (tab === t('settings.accessTokens.label'))
|
|
||||||
navigate('/settings/access-tokens');
|
|
||||||
};
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
setActiveTab(getActiveTabFromPath());
|
|
||||||
}, [location.pathname]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const newActiveTab = getActiveTabFromPath();
|
|
||||||
setActiveTab(newActiveTab);
|
|
||||||
}, [i18n.language]);
|
|
||||||
|
|
||||||
const token = useSelector(selectToken);
|
const token = useSelector(selectToken);
|
||||||
const documents = useSelector(selectSourceDocs);
|
const documents = useSelector(selectSourceDocs);
|
||||||
@@ -139,74 +66,38 @@ export default function Settings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-auto p-4 md:p-12">
|
<div className="h-full overflow-auto p-4 md:p-12">
|
||||||
<p className="text-foreground dark:text-foreground text-2xl font-bold">
|
<div className="mx-auto w-full max-w-6xl">
|
||||||
{t('settings.label')}
|
{showIndex ? (
|
||||||
</p>
|
<SectionIndexPage section={SETTINGS_SECTION} />
|
||||||
<Tabs
|
) : (
|
||||||
value={activeTab}
|
<>
|
||||||
onValueChange={(tab) => handleTabChange(tab)}
|
<SectionPageHeader section={SETTINGS_SECTION} item={activeItem} />
|
||||||
className="relative mt-6 flex flex-row items-center space-x-1 overflow-auto md:space-x-0"
|
<Routes>
|
||||||
>
|
<Route index element={<General />} />
|
||||||
<div
|
<Route path="general" element={<General />} />
|
||||||
className={`${hiddenGradient === 'left' ? 'hidden' : ''} dark:from-background pointer-events-none absolute inset-y-0 left-6 w-14 bg-linear-to-r from-white md:hidden`}
|
<Route
|
||||||
></div>
|
path="sources"
|
||||||
<div
|
element={
|
||||||
className={`${hiddenGradient === 'right' ? 'hidden' : ''} dark:from-background pointer-events-none absolute inset-y-0 right-6 w-14 bg-linear-to-l from-white md:hidden`}
|
<Sources
|
||||||
></div>
|
paginatedDocuments={paginatedDocuments}
|
||||||
<div className="z-10 md:hidden">
|
handleDeleteDocument={handleDeleteClick}
|
||||||
<Button
|
/>
|
||||||
type="button"
|
}
|
||||||
variant="ghost"
|
/>
|
||||||
size="icon-sm"
|
<Route path="analytics" element={<Analytics />} />
|
||||||
onClick={() => scrollTabs(-1)}
|
<Route path="logs" element={<Logs />} />
|
||||||
className="hover:bg-muted dark:hover:bg-accent h-6 w-6 rounded-full"
|
<Route path="tools" element={<Tools />} />
|
||||||
aria-label={t('settings.scrollTabsLeft')}
|
<Route
|
||||||
>
|
path="devices"
|
||||||
<img src={ArrowLeft} alt="left-arrow" className="h-3" />
|
element={<Navigate to="/settings/tools" replace />}
|
||||||
</Button>
|
/>
|
||||||
</div>
|
<Route path="custom-models" element={<CustomModels />} />
|
||||||
<TabsList ref={containerRef} aria-label={t('settings.tabsAriaLabel')}>
|
<Route path="access-tokens" element={<PersonalAccessTokens />} />
|
||||||
{tabsList.map((tab) => (
|
<Route path="*" element={<Navigate to="/settings" replace />} />
|
||||||
<TabsTrigger key={tab} value={tab}>
|
</Routes>
|
||||||
{tab}
|
</>
|
||||||
</TabsTrigger>
|
)}
|
||||||
))}
|
</div>
|
||||||
</TabsList>
|
|
||||||
<div className="z-10 md:hidden">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
onClick={() => scrollTabs(1)}
|
|
||||||
className="hover:bg-muted dark:hover:bg-accent h-6 w-6 rounded-full"
|
|
||||||
aria-label={t('settings.scrollTabsRight')}
|
|
||||||
>
|
|
||||||
<img src={ArrowRight} alt="right-arrow" className="h-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Tabs>
|
|
||||||
<Routes>
|
|
||||||
<Route index element={<General />} />
|
|
||||||
<Route
|
|
||||||
path="sources"
|
|
||||||
element={
|
|
||||||
<Sources
|
|
||||||
paginatedDocuments={paginatedDocuments}
|
|
||||||
handleDeleteDocument={handleDeleteClick}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route path="analytics" element={<Analytics />} />
|
|
||||||
<Route path="logs" element={<Logs />} />
|
|
||||||
<Route path="tools" element={<Tools />} />
|
|
||||||
<Route
|
|
||||||
path="devices"
|
|
||||||
element={<Navigate to="/settings/tools" replace />}
|
|
||||||
/>
|
|
||||||
<Route path="custom-models" element={<CustomModels />} />
|
|
||||||
<Route path="access-tokens" element={<PersonalAccessTokens />} />
|
|
||||||
<Route path="*" element={<Navigate to="/settings" replace />} />
|
|
||||||
</Routes>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user