Files
Netcatty/components/TopTabsQuickControls.tsx
T
陈大猫 e4d182ddb9 feat: add a dedicated top-bar quick controls panel (#2374)
* feat: fold top-bar utilities into a control panel

Collapse External MCP, window opacity, theme, and cloud sync into one top-bar control panel so the right rail only keeps AI, controls, and settings.

* fix: keep the cloud sync panel and append quick controls

Restore the original detailed cloud sync popover as the host surface, and place External MCP, window opacity, and theme controls in a footer section beneath it.

* fix: split quick controls from the cloud sync panel

Restore the original cloud sync popover and move External MCP, window opacity, and theme into a dedicated quick-controls entry so the two surfaces stay separate.

* fix: simplify quick controls panel chrome

Drop the explanatory subtitle and card-heavy rows so the panel reads as a compact control list.

* fix: separate appearance controls from External MCP

Group window opacity and theme under one appearance section, with a divider from the External MCP switch.

* fix: compact window opacity controls in the quick panel

Replace the tall slider-plus-presets block with a single-row preset selector to free vertical space.

* fix: make window opacity controls expandable

Keep the opacity row compact by default and reveal the slider plus presets only when the row is expanded.

* fix: put appearance controls above External MCP

Reorder the quick controls panel so window opacity and theme sit first, with External MCP below the divider.

* fix: harden quick controls opacity matching and drop dead copy

Compare opacity presets by percent to avoid float mismatch, and remove the unused quick-controls description strings.

* fix: clean quick controls accessibility and dead top-bar opacity code

Move Switch accessible names onto the focusable control, restore popover autofocus, delete the unused window opacity button, and drop obsolete top-bar theme copy.

* fix: tighten quick controls peer-window and switch labeling

Assert showExternalMcpToggle is forwarded into the quick panel, and name the External MCP switch from its visible label only.

* fix: add icon to quick controls panel title

Prefix the panel header with the same sliders icon used by the top-bar entry.

* fix: remove icon from quick controls panel title

Keep the header as plain text after the title icon felt unnecessary.
2026-07-21 23:03:25 +08:00

198 lines
7.3 KiB
TypeScript

import React, { useState } from 'react';
import {
ChevronDown,
Droplets,
Moon,
Plug,
SlidersHorizontal,
Sun,
} from 'lucide-react';
import { useI18n } from '../application/i18n/I18nProvider';
import { cn } from '../lib/utils';
import { Button } from './ui/button';
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
import { Switch } from './ui/switch';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
const OPACITY_PRESETS = [
{ label: '100%', value: 1 },
{ label: '85%', value: 0.85 },
{ label: '70%', value: 0.7 },
] as const;
export interface TopTabsQuickControlsProps {
theme: 'dark' | 'light';
onToggleTheme: () => void;
externalMcpEnabled: boolean;
onToggleExternalMcp: (enabled: boolean) => void;
showExternalMcpToggle?: boolean;
windowOpacity: number;
setWindowOpacity: (opacity: number) => void;
className?: string;
style?: React.CSSProperties;
}
export const TopTabsQuickControls: React.FC<TopTabsQuickControlsProps> = ({
theme,
onToggleTheme,
externalMcpEnabled,
onToggleExternalMcp,
showExternalMcpToggle = true,
windowOpacity,
setWindowOpacity,
className,
style,
}) => {
const { t } = useI18n();
const [isOpen, setIsOpen] = useState(false);
const [isOpacityExpanded, setIsOpacityExpanded] = useState(false);
const opacityPercent = Math.round(windowOpacity * 100);
const isPresetActive = (value: number) => Math.round(value * 100) === opacityPercent;
const isDark = theme === 'dark';
const externalMcpLabelId = React.useId();
const triggerActive = (
(showExternalMcpToggle && externalMcpEnabled)
|| opacityPercent < 100
);
return (
<Popover
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (!open) setIsOpacityExpanded(false);
}}
>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn('h-7 w-7 shrink-0 app-no-drag top-tab-utility-btn', className)}
style={{
...style,
color: triggerActive
? 'hsl(var(--primary))'
: (style?.color ?? 'var(--top-tabs-muted, hsl(var(--muted-foreground)))'),
}}
aria-label={t('topTabs.controlPanel')}
data-section="top-tabs-quick-controls"
>
<SlidersHorizontal size={16} />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{t('topTabs.controlPanel')}</TooltipContent>
</Tooltip>
<PopoverContent
className="w-64 p-0 app-no-drag"
align="end"
sideOffset={6}
>
<div className="px-3 py-2 border-b border-border/60">
<div className="text-sm font-medium">{t('topTabs.controlPanel')}</div>
</div>
<div className="p-2">
<div>
<div className="rounded-md">
<button
type="button"
className="flex w-full items-center justify-between gap-3 rounded-md px-2 py-2 text-left hover:bg-muted/40"
aria-expanded={isOpacityExpanded}
onClick={() => setIsOpacityExpanded((current) => !current)}
>
<div className="flex min-w-0 items-center gap-2 text-sm">
<Droplets size={14} className="shrink-0 text-muted-foreground" />
<span className="truncate">{t('topTabs.windowOpacity')}</span>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<span className="text-xs tabular-nums text-muted-foreground">{opacityPercent}%</span>
<ChevronDown
size={14}
className={cn(
'text-muted-foreground transition-transform',
isOpacityExpanded && 'rotate-180',
)}
/>
</div>
</button>
{isOpacityExpanded ? (
<div className="space-y-2 px-2 pb-2">
<div className="flex items-center gap-2">
<input
type="range"
min={50}
max={100}
step={1}
value={opacityPercent}
onChange={(event) => setWindowOpacity(Number(event.target.value) / 100)}
className="w-full accent-primary"
aria-label={t('topTabs.windowOpacity')}
/>
</div>
<div className="flex items-center gap-1" role="group" aria-label={t('topTabs.windowOpacity')}>
{OPACITY_PRESETS.map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => setWindowOpacity(preset.value)}
aria-pressed={isPresetActive(preset.value)}
className={cn(
'h-6 flex-1 rounded-md text-[11px] font-medium transition-colors',
isPresetActive(preset.value)
? 'bg-primary text-primary-foreground'
: 'bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground',
)}
>
{preset.label}
</button>
))}
</div>
</div>
) : null}
</div>
<div className="flex items-center justify-between gap-3 rounded-md px-2 py-2 hover:bg-muted/40">
<div className="flex min-w-0 items-center gap-2 text-sm">
{isDark
? <Sun size={14} className="shrink-0 text-muted-foreground" />
: <Moon size={14} className="shrink-0 text-muted-foreground" />}
<span className="truncate">{t('topTabs.controlPanel.theme')}</span>
</div>
<button
type="button"
onClick={onToggleTheme}
className="h-6 shrink-0 rounded-md bg-muted/50 px-2 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
{isDark ? t('topTabs.controlPanel.theme.light') : t('topTabs.controlPanel.theme.dark')}
</button>
</div>
</div>
{showExternalMcpToggle ? (
<div className="mt-1 border-t border-border/60 pt-1">
<div className="flex items-center justify-between gap-3 rounded-md px-2 py-2 hover:bg-muted/40">
<div className="flex min-w-0 items-center gap-2 text-sm">
<Plug size={14} className="shrink-0 text-muted-foreground" />
<span id={externalMcpLabelId} className="truncate">{t('topTabs.controlPanel.externalMcp')}</span>
</div>
<Switch
checked={externalMcpEnabled}
onCheckedChange={onToggleExternalMcp}
aria-labelledby={externalMcpLabelId}
/>
</div>
</div>
) : null}
</div>
</PopoverContent>
</Popover>
);
};
export default TopTabsQuickControls;