mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
feat(web): add collapsible sidebar for the dashboard (#33421)
* feat(web): add collapsible sidebar for the dashboard The desktop sidebar can now be collapsed to an icon-only rail via a toggle button in the sidebar header. State is persisted in localStorage so it survives page reloads. When collapsed (lg+ only): - Sidebar shrinks from w-64 to w-14 with a smooth width transition - Nav items show only their icon with a native title tooltip - Brand text, plugin headings, system actions, theme/language switchers, auth widget, and footer are hidden - Mobile drawer behavior is unchanged (always full-width) Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): align sidebar tooltips to sidebar edge consistently Tooltip left position now uses the sidebar's right edge instead of the anchor element's right edge, so narrow anchors (theme/language switchers) align with full-width anchors (nav links, system actions). Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web): add tooltip animations, restore theme label, rename Sessions tab - Sidebar tooltips now animate in with a subtle 120ms ease-out slide; subsequent tooltips within the same hover sequence appear instantly (no delay/animation) following Emil Kowalski's tooltip pattern - Restore theme name label when sidebar is expanded - Rename Sessions segment tab to "History" across all 16 locales Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): smooth sidebar collapse animation - Remove icon centering on collapse; icons stay left-aligned at px-5 so they don't jump during the width transition - Text labels fade out with opacity transition instead of instant display:none, clipped naturally by overflow-hidden - Slow collapse duration from 450ms to 600ms for a more relaxed feel - Gateway dot always rendered with opacity toggle so it doesn't slide in from the right on collapse - Pin gateway dot at fixed left offset (pl-[1.625rem]) to align with nav icons - Align header toggle button with justify-center when collapsed - Bottom switchers use items-start when collapsed to prevent reflow Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
c341a2d107
commit
c9410b3462
24 changed files with 564 additions and 171 deletions
481
web/src/App.tsx
481
web/src/App.tsx
|
|
@ -2,10 +2,12 @@ import {
|
|||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
Routes,
|
||||
Route,
|
||||
|
|
@ -31,6 +33,8 @@ import {
|
|||
Menu,
|
||||
MessageSquare,
|
||||
Package,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Puzzle,
|
||||
RotateCw,
|
||||
Settings,
|
||||
|
|
@ -44,14 +48,15 @@ import {
|
|||
Zap,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { ListItem } from "@nous-research/ui/ui/components/list-item";
|
||||
import { SelectionSwitcher } from "@nous-research/ui/ui/components/selection-switcher";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { Typography } from "@/components/NouiTypography";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Backdrop } from "@/components/Backdrop";
|
||||
import { SidebarFooter } from "@/components/SidebarFooter";
|
||||
import { SidebarStatusStrip } from "@/components/SidebarStatusStrip";
|
||||
import { SidebarStatusStrip, gatewayLine } from "@/components/SidebarStatusStrip";
|
||||
import { useBelowBreakpoint } from "@/hooks/useBelowBreakpoint";
|
||||
import { useSidebarStatus } from "@/hooks/useSidebarStatus";
|
||||
import { AuthWidget } from "@/components/AuthWidget";
|
||||
import { PageHeaderProvider } from "@/contexts/PageHeaderProvider";
|
||||
import { useSystemActions } from "@/contexts/useSystemActions";
|
||||
|
|
@ -77,6 +82,7 @@ import type { PluginManifest } from "@/plugins";
|
|||
import { useTheme } from "@/themes";
|
||||
import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags";
|
||||
import { api } from "@/lib/api";
|
||||
import type { StatusResponse } from "@/lib/api";
|
||||
|
||||
function RootRedirect() {
|
||||
return <Navigate to="/sessions" replace />;
|
||||
|
|
@ -306,6 +312,8 @@ function buildRoutes(
|
|||
return routes;
|
||||
}
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = "hermes-sidebar-collapsed";
|
||||
|
||||
export default function App() {
|
||||
const { t } = useI18n();
|
||||
const { pathname } = useLocation();
|
||||
|
|
@ -313,6 +321,27 @@ export default function App() {
|
|||
const { theme } = useTheme();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const closeMobile = useCallback(() => setMobileOpen(false), []);
|
||||
|
||||
const [collapsed, setCollapsed] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const toggleCollapsed = useCallback(() => {
|
||||
setCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
try {
|
||||
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(next));
|
||||
} catch { /* localStorage may be unavailable in private browsing */ }
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const isMobile = useBelowBreakpoint(1024);
|
||||
const isDesktopCollapsed = collapsed && !isMobile;
|
||||
const tooltipWarmRef = useRef(0);
|
||||
const sidebarStatus = useSidebarStatus();
|
||||
const isDocsRoute = pathname === "/docs" || pathname === "/docs/";
|
||||
const normalizedPath = pathname.replace(/\/$/, "") || "/";
|
||||
const isChatRoute = normalizedPath === "/chat";
|
||||
|
|
@ -483,9 +512,11 @@ export default function App() {
|
|||
"fixed top-0 left-0 z-50 flex h-dvh max-h-dvh w-64 min-h-0 flex-col",
|
||||
"border-r border-current/20",
|
||||
"bg-background-base/95 backdrop-blur-sm",
|
||||
"transition-transform duration-200 ease-out",
|
||||
"transition-[transform] duration-200 ease-out",
|
||||
mobileOpen ? "translate-x-0" : "-translate-x-full",
|
||||
"lg:sticky lg:top-0 lg:translate-x-0 lg:shrink-0",
|
||||
"lg:sticky lg:top-0 lg:translate-x-0 lg:shrink-0 lg:overflow-hidden",
|
||||
"lg:transition-[width] lg:duration-[600ms] lg:ease-[cubic-bezier(0.33,1.35,0.62,1)]",
|
||||
collapsed && "lg:w-14",
|
||||
)}
|
||||
style={{
|
||||
background: "var(--component-sidebar-background)",
|
||||
|
|
@ -495,11 +526,17 @@ export default function App() {
|
|||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-14 shrink-0 items-center justify-between gap-2 px-4",
|
||||
"flex h-14 shrink-0 items-center gap-2",
|
||||
"border-b border-current/20",
|
||||
collapsed ? "lg:justify-center lg:px-0" : "px-4 justify-between",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
collapsed && "lg:hidden",
|
||||
)}
|
||||
>
|
||||
<PluginSlot name="header-left" />
|
||||
|
||||
<Typography
|
||||
|
|
@ -521,6 +558,22 @@ export default function App() {
|
|||
>
|
||||
<X />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
onClick={toggleCollapsed}
|
||||
aria-label={
|
||||
collapsed ? t.common.expand : t.common.collapse
|
||||
}
|
||||
className="hidden lg:flex text-text-secondary hover:text-midground"
|
||||
>
|
||||
{collapsed ? (
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
|
|
@ -531,9 +584,11 @@ export default function App() {
|
|||
{sidebarNav.coreItems.map((item) => (
|
||||
<SidebarNavLink
|
||||
closeMobile={closeMobile}
|
||||
collapsed={isDesktopCollapsed}
|
||||
item={item}
|
||||
key={item.path}
|
||||
t={t}
|
||||
tooltipWarmRef={tooltipWarmRef}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
|
@ -548,6 +603,7 @@ export default function App() {
|
|||
className={cn(
|
||||
"px-5 pt-2.5 pb-1",
|
||||
"font-mondwest text-display text-xs tracking-[0.12em] text-text-tertiary",
|
||||
isDesktopCollapsed && "lg:hidden",
|
||||
)}
|
||||
id="hermes-sidebar-plugin-nav-heading"
|
||||
>
|
||||
|
|
@ -558,9 +614,11 @@ export default function App() {
|
|||
{sidebarNav.pluginItems.map((item) => (
|
||||
<SidebarNavLink
|
||||
closeMobile={closeMobile}
|
||||
collapsed={isDesktopCollapsed}
|
||||
item={item}
|
||||
key={item.path}
|
||||
t={t}
|
||||
tooltipWarmRef={tooltipWarmRef}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
|
@ -568,24 +626,58 @@ export default function App() {
|
|||
)}
|
||||
</nav>
|
||||
|
||||
<SidebarSystemActions onNavigate={closeMobile} />
|
||||
<SidebarSystemActions
|
||||
collapsed={isDesktopCollapsed}
|
||||
onNavigate={closeMobile}
|
||||
status={sidebarStatus}
|
||||
tooltipWarmRef={tooltipWarmRef}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-between gap-2",
|
||||
"flex shrink-0 items-center gap-2",
|
||||
"px-3 py-2",
|
||||
"border-t border-current/20",
|
||||
isDesktopCollapsed
|
||||
? "lg:flex-col lg:items-start lg:gap-3 lg:py-3"
|
||||
: "justify-between",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-2",
|
||||
isDesktopCollapsed && "lg:flex-col lg:items-start",
|
||||
)}
|
||||
>
|
||||
<PluginSlot name="header-right" />
|
||||
<ThemeSwitcher dropUp />
|
||||
<LanguageSwitcher dropUp />
|
||||
|
||||
<SidebarIconWithTooltip
|
||||
collapsed={isDesktopCollapsed}
|
||||
label={t.theme?.switchTheme ?? "Switch theme"}
|
||||
tooltipWarmRef={tooltipWarmRef}
|
||||
>
|
||||
<ThemeSwitcher collapsed={isDesktopCollapsed} dropUp />
|
||||
</SidebarIconWithTooltip>
|
||||
|
||||
<SidebarIconWithTooltip
|
||||
collapsed={isDesktopCollapsed}
|
||||
label={t.language.switchTo}
|
||||
tooltipWarmRef={tooltipWarmRef}
|
||||
>
|
||||
<LanguageSwitcher collapsed={isDesktopCollapsed} dropUp />
|
||||
</SidebarIconWithTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AuthWidget />
|
||||
<SidebarFooter />
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 flex-col",
|
||||
isDesktopCollapsed && "lg:hidden",
|
||||
)}
|
||||
>
|
||||
<AuthWidget />
|
||||
<SidebarFooter status={sidebarStatus} />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<PageHeaderProvider pluginTabs={pluginTabMeta}>
|
||||
|
|
@ -660,22 +752,37 @@ export default function App() {
|
|||
);
|
||||
}
|
||||
|
||||
function SidebarNavLink({ closeMobile, item, t }: SidebarNavLinkProps) {
|
||||
function SidebarNavLink({
|
||||
closeMobile,
|
||||
collapsed,
|
||||
item,
|
||||
tooltipWarmRef,
|
||||
t,
|
||||
}: SidebarNavLinkProps) {
|
||||
const { path, label, labelKey, icon: Icon } = item;
|
||||
const liRef = useRef<HTMLLIElement>(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const navLabel = labelKey
|
||||
? ((t.app.nav as Record<string, string>)[labelKey] ?? label)
|
||||
: label;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<li
|
||||
ref={liRef}
|
||||
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
|
||||
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
|
||||
>
|
||||
<NavLink
|
||||
to={path}
|
||||
end={path === "/sessions"}
|
||||
onClick={closeMobile}
|
||||
aria-label={collapsed ? navLabel : undefined}
|
||||
onFocus={collapsed ? () => setHovered(true) : undefined}
|
||||
onBlur={collapsed ? () => setHovered(false) : undefined}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"group relative flex items-center gap-3",
|
||||
"group/nav relative flex items-center gap-3",
|
||||
"px-5 py-2.5",
|
||||
"font-mondwest text-display uppercase text-sm tracking-[0.12em]",
|
||||
"whitespace-nowrap transition-colors cursor-pointer",
|
||||
|
|
@ -692,11 +799,19 @@ function SidebarNavLink({ closeMobile, item, t }: SidebarNavLinkProps) {
|
|||
{({ isActive }) => (
|
||||
<>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{navLabel}</span>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"truncate transition-opacity duration-300",
|
||||
collapsed ? "lg:opacity-0" : "lg:opacity-100",
|
||||
)}
|
||||
>
|
||||
{navLabel}
|
||||
</span>
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
|
||||
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover/nav:opacity-5"
|
||||
/>
|
||||
|
||||
{isActive && (
|
||||
|
|
@ -709,11 +824,20 @@ function SidebarNavLink({ closeMobile, item, t }: SidebarNavLinkProps) {
|
|||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
{collapsed && hovered && liRef.current && (
|
||||
<SidebarTooltip anchor={liRef.current} label={navLabel} warmRef={tooltipWarmRef} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSystemActions({ onNavigate }: { onNavigate: () => void }) {
|
||||
function SidebarSystemActions({
|
||||
collapsed,
|
||||
onNavigate,
|
||||
status,
|
||||
tooltipWarmRef,
|
||||
}: SidebarSystemActionsProps) {
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const { activeAction, isBusy, isRunning, pendingAction, runAction } =
|
||||
|
|
@ -755,75 +879,248 @@ function SidebarSystemActions({ onNavigate }: { onNavigate: () => void }) {
|
|||
className={cn(
|
||||
"px-5 pt-0.5 pb-0.5",
|
||||
"font-mondwest text-display text-xs tracking-[0.12em] text-text-tertiary",
|
||||
collapsed && "lg:hidden",
|
||||
)}
|
||||
>
|
||||
{t.app.system}
|
||||
</span>
|
||||
|
||||
<SidebarStatusStrip />
|
||||
<div className={cn(collapsed && "lg:hidden")}>
|
||||
<SidebarStatusStrip status={status} />
|
||||
</div>
|
||||
|
||||
<GatewayDot collapsed={collapsed} status={status} tooltipWarmRef={tooltipWarmRef} />
|
||||
|
||||
<ul className="flex flex-col">
|
||||
{items.map(({ action, icon: Icon, label, runningLabel, spin }) => {
|
||||
const isPending = pendingAction === action;
|
||||
const isActionRunning =
|
||||
activeAction === action && isRunning && !isPending;
|
||||
const busy = isPending || isActionRunning;
|
||||
const displayLabel = isActionRunning ? runningLabel : label;
|
||||
const disabled = isBusy && !busy;
|
||||
|
||||
return (
|
||||
<li key={action}>
|
||||
<ListItem
|
||||
onClick={() => handleClick(action)}
|
||||
disabled={disabled}
|
||||
aria-busy={busy}
|
||||
active={busy}
|
||||
className={cn(
|
||||
"gap-3 px-5 py-1.5 whitespace-nowrap",
|
||||
"font-mondwest text-display text-xs tracking-[0.1em]",
|
||||
"transition-colors",
|
||||
busy
|
||||
? "text-midground"
|
||||
: "text-text-secondary hover:text-midground",
|
||||
"disabled:text-text-disabled",
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<Spinner className="shrink-0 text-[0.875rem]" />
|
||||
) : isActionRunning && spin ? (
|
||||
<Spinner className="shrink-0 text-[0.875rem]" />
|
||||
) : (
|
||||
<Icon
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0",
|
||||
isActionRunning && !spin && "animate-pulse",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<span className="truncate">{displayLabel}</span>
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
|
||||
/>
|
||||
|
||||
{busy && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
/>
|
||||
)}
|
||||
</ListItem>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{items.map((item) => (
|
||||
<SystemActionButton
|
||||
key={item.action}
|
||||
collapsed={collapsed}
|
||||
disabled={isBusy && !(pendingAction === item.action || (activeAction === item.action && isRunning))}
|
||||
tooltipWarmRef={tooltipWarmRef}
|
||||
isPending={pendingAction === item.action}
|
||||
isRunning={activeAction === item.action && isRunning && pendingAction !== item.action}
|
||||
item={item}
|
||||
onClick={() => handleClick(item.action)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemActionButton({
|
||||
collapsed,
|
||||
disabled,
|
||||
isPending,
|
||||
isRunning: isActionRunning,
|
||||
item,
|
||||
onClick,
|
||||
tooltipWarmRef,
|
||||
}: SystemActionButtonProps) {
|
||||
const { icon: Icon, label, runningLabel, spin } = item;
|
||||
const liRef = useRef<HTMLLIElement>(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const busy = isPending || isActionRunning;
|
||||
const displayLabel = isActionRunning ? runningLabel : label;
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={liRef}
|
||||
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
|
||||
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
|
||||
>
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-busy={busy}
|
||||
aria-label={collapsed ? displayLabel : undefined}
|
||||
onFocus={collapsed ? () => setHovered(true) : undefined}
|
||||
onBlur={collapsed ? () => setHovered(false) : undefined}
|
||||
type="button"
|
||||
className={cn(
|
||||
"group/action relative flex w-full items-center gap-3",
|
||||
"px-5 py-2.5",
|
||||
"font-mondwest text-display text-xs tracking-[0.1em]",
|
||||
"whitespace-nowrap transition-colors cursor-pointer",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
||||
busy
|
||||
? "text-midground"
|
||||
: "text-text-secondary hover:text-midground",
|
||||
"disabled:text-text-disabled disabled:cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<Spinner className="shrink-0 text-[0.875rem]" />
|
||||
) : isActionRunning && spin ? (
|
||||
<Spinner className="shrink-0 text-[0.875rem]" />
|
||||
) : (
|
||||
<Icon
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0",
|
||||
isActionRunning && !spin && "animate-pulse",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<span className={cn(
|
||||
"truncate transition-opacity duration-300",
|
||||
collapsed ? "lg:opacity-0" : "lg:opacity-100",
|
||||
)}>
|
||||
{displayLabel}
|
||||
</span>
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover/action:opacity-5"
|
||||
/>
|
||||
|
||||
{busy && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{collapsed && hovered && liRef.current && (
|
||||
<SidebarTooltip anchor={liRef.current} label={displayLabel} warmRef={tooltipWarmRef} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarIconWithTooltip({
|
||||
children,
|
||||
collapsed,
|
||||
label,
|
||||
tooltipWarmRef,
|
||||
}: SidebarIconWithTooltipProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative w-fit",
|
||||
collapsed && "group/icon",
|
||||
)}
|
||||
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
|
||||
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
|
||||
>
|
||||
{children}
|
||||
|
||||
{collapsed && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-0 inset-x-[-0.375rem] bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover/icon:opacity-5 hidden lg:block"
|
||||
/>
|
||||
)}
|
||||
|
||||
{collapsed && hovered && ref.current && (
|
||||
<SidebarTooltip anchor={ref.current} label={label} warmRef={tooltipWarmRef} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GatewayDot({ collapsed, status, tooltipWarmRef }: GatewayDotProps) {
|
||||
const { t } = useI18n();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const toneToColor: Record<string, string> = {
|
||||
"text-success": "bg-success",
|
||||
"text-warning": "bg-warning",
|
||||
"text-destructive": "bg-destructive",
|
||||
"text-muted-foreground": "bg-muted-foreground",
|
||||
};
|
||||
|
||||
let color: string;
|
||||
let label: string;
|
||||
|
||||
if (!status) {
|
||||
color = "bg-midground/20";
|
||||
label = t.status.gateway;
|
||||
} else {
|
||||
const gw = gatewayLine(status, t);
|
||||
color = toneToColor[gw.tone] ?? "bg-muted-foreground";
|
||||
label = `${t.status.gateway} ${gw.label}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"hidden lg:flex py-3 pl-[1.625rem] transition-opacity duration-300",
|
||||
collapsed ? "lg:opacity-100" : "lg:opacity-0 lg:h-0 lg:py-0 lg:overflow-hidden",
|
||||
)}
|
||||
role="status"
|
||||
aria-label={label}
|
||||
tabIndex={collapsed ? 0 : -1}
|
||||
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
|
||||
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
|
||||
onFocus={collapsed ? () => setHovered(true) : undefined}
|
||||
onBlur={collapsed ? () => setHovered(false) : undefined}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("h-1.5 w-1.5 rounded-full", color)}
|
||||
/>
|
||||
|
||||
{hovered && ref.current && (
|
||||
<SidebarTooltip anchor={ref.current} label={label} warmRef={tooltipWarmRef} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarTooltip({ anchor, label, warmRef }: SidebarTooltipProps) {
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const sidebar = document.getElementById("app-sidebar");
|
||||
const sidebarRight = sidebar?.getBoundingClientRect().right ?? rect.right;
|
||||
|
||||
const isWarm = warmRef ? Date.now() - warmRef.current < 300 : false;
|
||||
|
||||
useEffect(() => {
|
||||
if (warmRef) warmRef.current = Date.now();
|
||||
return () => {
|
||||
if (warmRef) warmRef.current = Date.now();
|
||||
};
|
||||
}, [warmRef]);
|
||||
|
||||
return createPortal(
|
||||
<span
|
||||
className={cn(
|
||||
"fixed z-[100] pointer-events-none",
|
||||
"px-2 py-1",
|
||||
"bg-background-base/95 border border-current/20 backdrop-blur-sm shadow-lg",
|
||||
"font-mondwest text-display text-xs tracking-[0.1em] text-midground uppercase",
|
||||
)}
|
||||
style={{
|
||||
top: rect.top + rect.height / 2,
|
||||
left: sidebarRight + 8,
|
||||
transform: "translateY(-50%)",
|
||||
opacity: isWarm ? 1 : undefined,
|
||||
animation: isWarm ? "none" : "sidebar-tooltip-in 120ms ease-out",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
type TooltipWarmRef = React.RefObject<number>;
|
||||
|
||||
interface GatewayDotProps {
|
||||
collapsed: boolean;
|
||||
status: StatusResponse | null;
|
||||
tooltipWarmRef: TooltipWarmRef;
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
|
|
@ -831,10 +1128,42 @@ interface NavItem {
|
|||
path: string;
|
||||
}
|
||||
|
||||
interface SidebarIconWithTooltipProps {
|
||||
children: ReactNode;
|
||||
collapsed: boolean;
|
||||
label: string;
|
||||
tooltipWarmRef: TooltipWarmRef;
|
||||
}
|
||||
|
||||
interface SidebarNavLinkProps {
|
||||
closeMobile: () => void;
|
||||
collapsed: boolean;
|
||||
item: NavItem;
|
||||
t: Translations;
|
||||
tooltipWarmRef: TooltipWarmRef;
|
||||
}
|
||||
|
||||
interface SidebarSystemActionsProps {
|
||||
collapsed: boolean;
|
||||
onNavigate: () => void;
|
||||
status: StatusResponse | null;
|
||||
tooltipWarmRef: TooltipWarmRef;
|
||||
}
|
||||
|
||||
interface SidebarTooltipProps {
|
||||
anchor: HTMLElement;
|
||||
label: string;
|
||||
warmRef?: TooltipWarmRef;
|
||||
}
|
||||
|
||||
interface SystemActionButtonProps {
|
||||
collapsed: boolean;
|
||||
disabled: boolean;
|
||||
isPending: boolean;
|
||||
isRunning: boolean;
|
||||
item: SystemActionItem;
|
||||
onClick: () => void;
|
||||
tooltipWarmRef: TooltipWarmRef;
|
||||
}
|
||||
|
||||
interface SystemActionItem {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { useState, useRef, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Check } from "lucide-react";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { BottomPickSheet } from "@/components/BottomPickSheet";
|
||||
import { Typography } from "@/components/NouiTypography";
|
||||
|
|
@ -25,10 +27,11 @@ import { cn } from "@/lib/utils";
|
|||
* viewport / overflow ancestors. Below the `sm` breakpoint, `dropUp` uses a
|
||||
* bottom sheet portaled to `document.body` instead of an anchored dropdown.
|
||||
*/
|
||||
export function LanguageSwitcher({ dropUp = false }: LanguageSwitcherProps) {
|
||||
export function LanguageSwitcher({ collapsed = false, dropUp = false }: LanguageSwitcherProps) {
|
||||
const { locale, setLocale, t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const narrowViewport = useBelowBreakpoint(640);
|
||||
const useMobileSheet = Boolean(dropUp && narrowViewport);
|
||||
|
||||
|
|
@ -41,15 +44,14 @@ export function LanguageSwitcher({ dropUp = false }: LanguageSwitcherProps) {
|
|||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open]);
|
||||
|
||||
// Outside-click closing only for anchored dropdown — sheet uses backdrop + portal.
|
||||
useEffect(() => {
|
||||
if (!open || useMobileSheet) return;
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (!containerRef.current) return;
|
||||
if (!containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
const target = e.target as Node;
|
||||
if (containerRef.current?.contains(target)) return;
|
||||
if (dropdownRef.current?.contains(target)) return;
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
document.addEventListener("pointerdown", onPointerDown);
|
||||
|
|
@ -69,7 +71,10 @@ export function LanguageSwitcher({ dropUp = false }: LanguageSwitcherProps) {
|
|||
aria-label={t.language.switchTo}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
className="px-2 py-1 normal-case tracking-normal font-normal text-xs text-text-secondary hover:text-foreground"
|
||||
className={cn(
|
||||
"px-2 py-1 normal-case tracking-normal font-normal text-xs text-text-secondary hover:text-foreground",
|
||||
collapsed && "hover:bg-transparent",
|
||||
)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Typography
|
||||
|
|
@ -99,23 +104,33 @@ export function LanguageSwitcher({ dropUp = false }: LanguageSwitcherProps) {
|
|||
</BottomPickSheet>
|
||||
)}
|
||||
|
||||
{open && !useMobileSheet && (
|
||||
<div
|
||||
aria-label={sheetTitle}
|
||||
className={cn(
|
||||
"absolute right-0 z-50 min-w-[10rem] rounded-md border border-border bg-popover shadow-md py-1 max-h-80 overflow-y-auto",
|
||||
dropUp ? "bottom-full mb-1" : "top-full mt-1",
|
||||
)}
|
||||
role="listbox"
|
||||
>
|
||||
<LanguageSwitcherOptions
|
||||
allLocales={allLocales}
|
||||
locale={locale}
|
||||
setLocale={setLocale}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{open && !useMobileSheet && (() => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
const dropdown = (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
aria-label={sheetTitle}
|
||||
className={cn(
|
||||
"min-w-[10rem] border border-border bg-popover shadow-md py-1 max-h-80 overflow-y-auto",
|
||||
dropUp ? "fixed z-[100]" : "absolute z-50 right-0 top-full mt-1",
|
||||
)}
|
||||
role="listbox"
|
||||
style={
|
||||
dropUp && rect
|
||||
? { bottom: window.innerHeight - rect.top + 4, left: rect.left }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<LanguageSwitcherOptions
|
||||
allLocales={allLocales}
|
||||
locale={locale}
|
||||
setLocale={setLocale}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return dropUp ? createPortal(dropdown, document.body) : dropdown;
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -134,10 +149,12 @@ function LanguageSwitcherOptions({
|
|||
return (
|
||||
<button
|
||||
aria-selected={selected}
|
||||
className={
|
||||
"w-full text-left px-3 py-1.5 text-xs flex items-center gap-2 hover:bg-accent hover:text-accent-foreground transition-colors " +
|
||||
(selected ? "font-semibold text-foreground" : "text-muted-foreground")
|
||||
}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-1.5 flex items-center gap-2 cursor-pointer",
|
||||
"font-mondwest text-display text-xs tracking-[0.08em]",
|
||||
"hover:bg-accent hover:text-accent-foreground transition-colors",
|
||||
selected ? "font-semibold text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
key={code}
|
||||
onClick={() => {
|
||||
setLocale(code);
|
||||
|
|
@ -148,7 +165,7 @@ function LanguageSwitcherOptions({
|
|||
>
|
||||
<span className="truncate">{meta.name}</span>
|
||||
|
||||
{selected && <span className="ml-auto text-xs">✓</span>}
|
||||
{selected && <Check className="ml-auto h-3 w-3 shrink-0 text-midground" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
@ -164,5 +181,6 @@ interface LanguageSwitcherOptionsProps {
|
|||
}
|
||||
|
||||
interface LanguageSwitcherProps {
|
||||
collapsed?: boolean;
|
||||
dropUp?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { Typography } from "@/components/NouiTypography";
|
||||
import { useSidebarStatus } from "@/hooks/useSidebarStatus";
|
||||
import type { StatusResponse } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
export function SidebarFooter() {
|
||||
const status = useSidebarStatus();
|
||||
export function SidebarFooter({ status }: SidebarFooterProps) {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
|
|
@ -37,3 +36,7 @@ export function SidebarFooter() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarFooterProps {
|
||||
status: StatusResponse | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import type { StatusResponse } from "@/lib/api";
|
||||
import { useSidebarStatus } from "@/hooks/useSidebarStatus";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
/** Gateway + session summary for the System sidebar block (no separate strip chrome). */
|
||||
export function SidebarStatusStrip() {
|
||||
const status = useSidebarStatus();
|
||||
export function SidebarStatusStrip({ status }: SidebarStatusStripProps) {
|
||||
const { t } = useI18n();
|
||||
|
||||
if (status === null) {
|
||||
|
|
@ -50,7 +48,7 @@ export function SidebarStatusStrip() {
|
|||
);
|
||||
}
|
||||
|
||||
function gatewayLine(
|
||||
export function gatewayLine(
|
||||
status: StatusResponse,
|
||||
t: ReturnType<typeof useI18n>["t"],
|
||||
): { label: string; tone: string } {
|
||||
|
|
@ -68,3 +66,7 @@ function gatewayLine(
|
|||
? { label: g.running, tone: "text-success" }
|
||||
: { label: g.off, tone: "text-muted-foreground" };
|
||||
}
|
||||
|
||||
interface SidebarStatusStripProps {
|
||||
status: StatusResponse | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Palette, Check } from "lucide-react";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { ListItem } from "@nous-research/ui/ui/components/list-item";
|
||||
|
|
@ -23,11 +24,12 @@ import { cn } from "@/lib/utils";
|
|||
* bottom sheet portaled to `document.body` so the picker is not clipped by
|
||||
* the sidebar (same idea as a responsive Drawer).
|
||||
*/
|
||||
export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
|
||||
export function ThemeSwitcher({ collapsed = false, dropUp = false }: ThemeSwitcherProps) {
|
||||
const { themeName, availableThemes, setTheme } = useTheme();
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const narrowViewport = useBelowBreakpoint(640);
|
||||
const useMobileSheet = Boolean(dropUp && narrowViewport);
|
||||
|
||||
|
|
@ -45,12 +47,10 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
|
|||
useEffect(() => {
|
||||
if (!open || useMobileSheet) return;
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (
|
||||
wrapperRef.current &&
|
||||
!wrapperRef.current.contains(e.target as Node)
|
||||
) {
|
||||
close();
|
||||
}
|
||||
const target = e.target as Node;
|
||||
if (wrapperRef.current?.contains(target)) return;
|
||||
if (dropdownRef.current?.contains(target)) return;
|
||||
close();
|
||||
};
|
||||
document.addEventListener("mousedown", onMouseDown);
|
||||
return () => document.removeEventListener("mousedown", onMouseDown);
|
||||
|
|
@ -64,9 +64,14 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
|
|||
<div ref={wrapperRef} className="relative">
|
||||
<Button
|
||||
ghost
|
||||
size={collapsed ? "icon" : undefined}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="px-2 py-1 normal-case tracking-normal font-normal text-xs text-text-secondary hover:text-foreground"
|
||||
title={t.theme?.switchTheme ?? "Switch theme"}
|
||||
className={cn(
|
||||
collapsed
|
||||
? "text-text-secondary hover:text-foreground hover:bg-transparent"
|
||||
: "px-2 py-1 normal-case tracking-normal font-normal text-xs text-text-secondary hover:text-foreground",
|
||||
)}
|
||||
title={`${t.theme?.switchTheme ?? "Switch theme"}: ${label}`}
|
||||
aria-label={t.theme?.switchTheme ?? "Switch theme"}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
|
|
@ -74,12 +79,14 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
|
|||
<span className="inline-flex items-center gap-1.5">
|
||||
<Palette className="h-3.5 w-3.5" />
|
||||
|
||||
<Typography
|
||||
mondwest
|
||||
className="hidden sm:inline text-display tracking-wide text-xs"
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
{!collapsed && (
|
||||
<Typography
|
||||
mondwest
|
||||
className="hidden sm:inline text-display tracking-wide text-xs"
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
|
|
@ -101,34 +108,44 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
|
|||
</BottomPickSheet>
|
||||
)}
|
||||
|
||||
{open && !useMobileSheet && (
|
||||
<div
|
||||
aria-label={sheetTitle}
|
||||
className={cn(
|
||||
"absolute z-50 min-w-[240px] max-h-[70dvh] overflow-y-auto",
|
||||
dropUp ? "left-0 bottom-full mb-1" : "right-0 top-full mt-1",
|
||||
"border border-current/20 bg-background-base/95 backdrop-blur-sm",
|
||||
"shadow-[0_12px_32px_-8px_rgba(0,0,0,0.6)]",
|
||||
)}
|
||||
role="listbox"
|
||||
>
|
||||
<div className="border-b border-current/20 px-3 py-2">
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-display text-xs tracking-[0.12em] text-text-tertiary"
|
||||
>
|
||||
{sheetTitle}
|
||||
</Typography>
|
||||
</div>
|
||||
{open && !useMobileSheet && (() => {
|
||||
const rect = wrapperRef.current?.getBoundingClientRect();
|
||||
const dropdown = (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
aria-label={sheetTitle}
|
||||
className={cn(
|
||||
"min-w-[240px] max-h-[70dvh] overflow-y-auto",
|
||||
"border border-current/20 bg-background-base/95 backdrop-blur-sm",
|
||||
"shadow-[0_12px_32px_-8px_rgba(0,0,0,0.6)]",
|
||||
dropUp ? "fixed z-[100]" : "absolute z-50 right-0 top-full mt-1",
|
||||
)}
|
||||
role="listbox"
|
||||
style={
|
||||
dropUp && rect
|
||||
? { bottom: window.innerHeight - rect.top + 4, left: rect.left }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="border-b border-current/20 px-3 py-2">
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-display text-xs tracking-[0.12em] text-text-tertiary"
|
||||
>
|
||||
{sheetTitle}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<ThemeSwitcherOptions
|
||||
availableThemes={availableThemes}
|
||||
close={close}
|
||||
setTheme={setTheme}
|
||||
themeName={themeName}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ThemeSwitcherOptions
|
||||
availableThemes={availableThemes}
|
||||
close={close}
|
||||
setTheme={setTheme}
|
||||
themeName={themeName}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return dropUp ? createPortal(dropdown, document.body) : dropdown;
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -221,5 +238,6 @@ interface ThemeSwitcherOptionsProps {
|
|||
}
|
||||
|
||||
interface ThemeSwitcherProps {
|
||||
collapsed?: boolean;
|
||||
dropUp?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ export const af: Translations = {
|
|||
|
||||
sessions: {
|
||||
title: "Sessies",
|
||||
history: "Geskiedenis",
|
||||
overview: "Oorsig",
|
||||
searchPlaceholder: "Soek boodskap-inhoud...",
|
||||
noSessions: "Nog geen sessies nie",
|
||||
|
|
@ -422,7 +423,7 @@ export const af: Translations = {
|
|||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Skakel oor na Engels",
|
||||
switchTo: "Verander taal",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ export const de: Translations = {
|
|||
|
||||
sessions: {
|
||||
title: "Sitzungen",
|
||||
history: "Verlauf",
|
||||
overview: "Übersicht",
|
||||
searchPlaceholder: "Nachrichteninhalt suchen...",
|
||||
noSessions: "Noch keine Sitzungen",
|
||||
|
|
@ -422,7 +423,7 @@ export const de: Translations = {
|
|||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Zu Englisch wechseln",
|
||||
switchTo: "Sprache wechseln",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ export const en: Translations = {
|
|||
|
||||
sessions: {
|
||||
title: "Sessions",
|
||||
history: "History",
|
||||
overview: "Overview",
|
||||
searchPlaceholder: "Search message content...",
|
||||
noSessions: "No sessions yet",
|
||||
|
|
@ -422,7 +423,7 @@ export const en: Translations = {
|
|||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Switch to Chinese",
|
||||
switchTo: "Switch language",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ export const es: Translations = {
|
|||
|
||||
sessions: {
|
||||
title: "Sesiones",
|
||||
history: "Historial",
|
||||
overview: "Resumen",
|
||||
searchPlaceholder: "Buscar contenido de mensajes...",
|
||||
noSessions: "Aún no hay sesiones",
|
||||
|
|
@ -422,7 +423,7 @@ export const es: Translations = {
|
|||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Cambiar a inglés",
|
||||
switchTo: "Cambiar idioma",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ export const fr: Translations = {
|
|||
|
||||
sessions: {
|
||||
title: "Sessions",
|
||||
history: "Historique",
|
||||
overview: "Aperçu",
|
||||
searchPlaceholder: "Rechercher dans les messages...",
|
||||
noSessions: "Aucune session pour l'instant",
|
||||
|
|
@ -422,7 +423,7 @@ export const fr: Translations = {
|
|||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Passer à l'anglais",
|
||||
switchTo: "Changer de langue",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ export const ga: Translations = {
|
|||
|
||||
sessions: {
|
||||
title: "Seisiúin",
|
||||
history: "Stair",
|
||||
overview: "Forbhreathnú",
|
||||
searchPlaceholder: "Cuardaigh ábhar teachtaireachta...",
|
||||
noSessions: "Gan seisiúin go fóill",
|
||||
|
|
@ -422,7 +423,7 @@ export const ga: Translations = {
|
|||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Athraigh go Béarla",
|
||||
switchTo: "Athraigh teanga",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ export const hu: Translations = {
|
|||
|
||||
sessions: {
|
||||
title: "Munkamenetek",
|
||||
history: "Előzmények",
|
||||
overview: "Áttekintés",
|
||||
searchPlaceholder: "Keresés üzenettartalomban...",
|
||||
noSessions: "Még nincsenek munkamenetek",
|
||||
|
|
@ -422,7 +423,7 @@ export const hu: Translations = {
|
|||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Váltás angolra",
|
||||
switchTo: "Nyelv váltása",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ export const it: Translations = {
|
|||
|
||||
sessions: {
|
||||
title: "Sessioni",
|
||||
history: "Cronologia",
|
||||
overview: "Panoramica",
|
||||
searchPlaceholder: "Cerca nel contenuto dei messaggi...",
|
||||
noSessions: "Nessuna sessione",
|
||||
|
|
@ -422,7 +423,7 @@ export const it: Translations = {
|
|||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Passa all'inglese",
|
||||
switchTo: "Cambia lingua",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ export const ja: Translations = {
|
|||
|
||||
sessions: {
|
||||
title: "セッション",
|
||||
history: "履歴",
|
||||
overview: "概要",
|
||||
searchPlaceholder: "メッセージ内容を検索...",
|
||||
noSessions: "まだセッションがありません",
|
||||
|
|
@ -422,7 +423,7 @@ export const ja: Translations = {
|
|||
},
|
||||
|
||||
language: {
|
||||
switchTo: "英語に切り替え",
|
||||
switchTo: "言語を切り替え",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ export const ko: Translations = {
|
|||
|
||||
sessions: {
|
||||
title: "세션",
|
||||
history: "기록",
|
||||
overview: "개요",
|
||||
searchPlaceholder: "메시지 내용 검색...",
|
||||
noSessions: "아직 세션이 없습니다",
|
||||
|
|
@ -422,7 +423,7 @@ export const ko: Translations = {
|
|||
},
|
||||
|
||||
language: {
|
||||
switchTo: "영어로 전환",
|
||||
switchTo: "언어 변경",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ export const pt: Translations = {
|
|||
|
||||
sessions: {
|
||||
title: "Sessões",
|
||||
history: "Histórico",
|
||||
overview: "Visão geral",
|
||||
searchPlaceholder: "Pesquisar conteúdo das mensagens...",
|
||||
noSessions: "Ainda não há sessões",
|
||||
|
|
@ -422,7 +423,7 @@ export const pt: Translations = {
|
|||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Mudar para inglês",
|
||||
switchTo: "Mudar idioma",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ export const ru: Translations = {
|
|||
|
||||
sessions: {
|
||||
title: "Сессии",
|
||||
history: "История",
|
||||
overview: "Обзор",
|
||||
searchPlaceholder: "Поиск по содержимому сообщений...",
|
||||
noSessions: "Сессий пока нет",
|
||||
|
|
@ -422,7 +423,7 @@ export const ru: Translations = {
|
|||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Переключиться на английский",
|
||||
switchTo: "Сменить язык",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ export const tr: Translations = {
|
|||
|
||||
sessions: {
|
||||
title: "Oturumlar",
|
||||
history: "Geçmiş",
|
||||
overview: "Genel bakış",
|
||||
searchPlaceholder: "Mesaj içeriğinde ara...",
|
||||
noSessions: "Henüz oturum yok",
|
||||
|
|
@ -422,7 +423,7 @@ export const tr: Translations = {
|
|||
},
|
||||
|
||||
language: {
|
||||
switchTo: "İngilizce'ye geç",
|
||||
switchTo: "Dil değiştir",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ export interface Translations {
|
|||
// ── Sessions page ──
|
||||
sessions: {
|
||||
title: string;
|
||||
history: string;
|
||||
overview: string;
|
||||
searchPlaceholder: string;
|
||||
noSessions: string;
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ export const uk: Translations = {
|
|||
|
||||
sessions: {
|
||||
title: "Сесії",
|
||||
history: "Історія",
|
||||
overview: "Огляд",
|
||||
searchPlaceholder: "Пошук у вмісті повідомлень...",
|
||||
noSessions: "Поки немає сесій",
|
||||
|
|
@ -422,7 +423,7 @@ export const uk: Translations = {
|
|||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Перемкнути на англійську",
|
||||
switchTo: "Змінити мову",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ export const zhHant: Translations = {
|
|||
|
||||
sessions: {
|
||||
title: "工作階段",
|
||||
history: "歷史",
|
||||
overview: "總覽",
|
||||
searchPlaceholder: "搜尋訊息內容...",
|
||||
noSessions: "尚無工作階段",
|
||||
|
|
@ -422,7 +423,7 @@ export const zhHant: Translations = {
|
|||
},
|
||||
|
||||
language: {
|
||||
switchTo: "切換為英文",
|
||||
switchTo: "切換語言",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@ export const zh: Translations = {
|
|||
|
||||
sessions: {
|
||||
title: "会话",
|
||||
history: "历史",
|
||||
overview: "概览",
|
||||
searchPlaceholder: "搜索消息内容...",
|
||||
noSessions: "暂无会话",
|
||||
|
|
@ -417,7 +418,7 @@ export const zh: Translations = {
|
|||
},
|
||||
|
||||
language: {
|
||||
switchTo: "切换到英文",
|
||||
switchTo: "切换语言",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
|
|
|||
|
|
@ -170,6 +170,12 @@ code { font-size: 0.875rem; }
|
|||
}
|
||||
|
||||
|
||||
/* Collapsed sidebar tooltip entrance — skipped when moving between items. */
|
||||
@keyframes sidebar-tooltip-in {
|
||||
from { opacity: 0; transform: translateY(-50%) translateX(-4px); }
|
||||
to { opacity: 1; transform: translateY(-50%) translateX(0); }
|
||||
}
|
||||
|
||||
/* Toast animations used by `components/Toast.tsx`. */
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateX(16px); }
|
||||
|
|
|
|||
|
|
@ -778,7 +778,7 @@ export default function SessionsPage() {
|
|||
onChange={setView}
|
||||
options={[
|
||||
{ value: "overview", label: t.sessions.overview },
|
||||
{ value: "list", label: t.sessions.title },
|
||||
{ value: "list", label: t.sessions.history },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue