feat: enhance theme customization and UI components

- Implemented a new color generation module to create CSS variables based on a single hue value, supporting both light and dark modes.
- Added unit tests for the color generation logic to ensure accuracy and robustness.
- Replaced dropdown location filter with tab navigation in RulesManagerPage and SkillsManagerPage for improved UX.
- Updated app store to manage custom theme hues and states, allowing for dynamic theme adjustments.
- Sanitized notification content before persisting to localStorage to prevent sensitive data exposure.
- Refactored memory retrieval logic to handle archived status more flexibly.
- Improved Tailwind CSS configuration with new gradient utilities and animations.
- Minor adjustments to SettingsPage layout for better visual consistency.
This commit is contained in:
catlog22
2026-02-04 17:20:40 +08:00
parent 88616224e0
commit e260a3f77b
30 changed files with 1377 additions and 388 deletions

View File

@@ -1,9 +1,9 @@
// ========================================
// TabBar Component
// ========================================
// Tab management for CLI viewer panes
// Tab management for CLI viewer panes with drag-and-drop support
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
import { X, Pin, PinOff, MoreHorizontal, SplitSquareHorizontal, SplitSquareVertical } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -14,7 +14,7 @@ import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/DropdownMenu';
} from '@/components/ui/Dropdown';
import {
useViewerStore,
useViewerPanes,
@@ -32,6 +32,7 @@ export interface TabBarProps {
interface TabItemProps {
tab: TabState;
paneId: PaneId;
isActive: boolean;
onSelect: () => void;
onClose: (e: React.MouseEvent) => void;
@@ -49,10 +50,23 @@ const STATUS_COLORS = {
// ========== Helper Components ==========
// Data transfer key for tab drag-and-drop
const TAB_DRAG_DATA_TYPE = 'application/x-cli-viewer-tab';
interface TabDragData {
tabId: string;
sourcePaneId: string;
}
/**
* Individual tab item
* Individual tab item with drag-and-drop support
*/
function TabItem({ tab, isActive, onSelect, onClose, onTogglePin }: TabItemProps) {
function TabItem({ tab, paneId, isActive, onSelect, onClose, onTogglePin }: TabItemProps) {
const [isDragging, setIsDragging] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const moveTab = useViewerStore((state) => state.moveTab);
const panes = useViewerPanes();
// Simplify title for display
const displayTitle = useMemo(() => {
// If title contains tool name pattern, extract it
@@ -60,17 +74,93 @@ function TabItem({ tab, isActive, onSelect, onClose, onTogglePin }: TabItemProps
return parts[0] || tab.title;
}, [tab.title]);
// Drag start handler
const handleDragStart = useCallback((e: React.DragEvent) => {
const dragData: TabDragData = {
tabId: tab.id,
sourcePaneId: paneId,
};
e.dataTransfer.setData(TAB_DRAG_DATA_TYPE, JSON.stringify(dragData));
e.dataTransfer.effectAllowed = 'move';
setIsDragging(true);
}, [tab.id, paneId]);
// Drag end handler
const handleDragEnd = useCallback(() => {
setIsDragging(false);
}, []);
// Drag over handler
const handleDragOver = useCallback((e: React.DragEvent) => {
if (e.dataTransfer.types.includes(TAB_DRAG_DATA_TYPE)) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setIsDragOver(true);
}
}, []);
// Drag leave handler
const handleDragLeave = useCallback(() => {
setIsDragOver(false);
}, []);
// Drop handler
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const rawData = e.dataTransfer.getData(TAB_DRAG_DATA_TYPE);
if (!rawData) return;
try {
const dragData: TabDragData = JSON.parse(rawData);
const { tabId: sourceTabId, sourcePaneId } = dragData;
// Don't do anything if dropping on the same tab
if (sourceTabId === tab.id) return;
// Find the target index
const targetPane = panes[paneId];
if (!targetPane) return;
const targetIndex = targetPane.tabs.findIndex((t) => t.id === tab.id);
if (targetIndex === -1) return;
// Move the tab
moveTab(sourcePaneId, sourceTabId, paneId, targetIndex);
} catch (err) {
console.error('[TabBar] Failed to parse drag data:', err);
}
}, [tab.id, paneId, panes, moveTab]);
return (
<button
<div
role="tab"
tabIndex={0}
draggable={!tab.isPinned}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={onSelect}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect();
}
}}
className={cn(
'group relative flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs',
'border border-border/50 shrink-0 min-w-0 max-w-[160px]',
'transition-all duration-150',
'transition-all duration-150 select-none',
isActive
? 'bg-slate-100 dark:bg-slate-800 border-slate-300 dark:border-slate-600 shadow-sm'
: 'bg-muted/30 hover:bg-muted/50 border-border/30',
tab.isPinned && 'border-amber-500/50'
tab.isPinned && 'border-amber-500/50',
isDragging && 'opacity-50 cursor-grabbing',
isDragOver && 'border-primary border-dashed bg-primary/10',
!tab.isPinned && 'cursor-grab'
)}
title={tab.title}
>
@@ -111,7 +201,7 @@ function TabItem({ tab, isActive, onSelect, onClose, onTogglePin }: TabItemProps
</button>
)}
</div>
</button>
</div>
);
}
@@ -125,10 +215,12 @@ function TabItem({ tab, isActive, onSelect, onClose, onTogglePin }: TabItemProps
* - Active tab highlighting
* - Close button on hover
* - Pin/unpin functionality
* - Drag-and-drop tab reordering and moving between panes
* - Pane actions dropdown
*/
export function TabBar({ paneId, className }: TabBarProps) {
const { formatMessage } = useIntl();
const [isDragOver, setIsDragOver] = useState(false);
const panes = useViewerPanes();
const pane = panes[paneId];
const setActiveTab = useViewerStore((state) => state.setActiveTab);
@@ -136,6 +228,7 @@ export function TabBar({ paneId, className }: TabBarProps) {
const togglePinTab = useViewerStore((state) => state.togglePinTab);
const addPane = useViewerStore((state) => state.addPane);
const removePane = useViewerStore((state) => state.removePane);
const moveTab = useViewerStore((state) => state.moveTab);
const handleTabSelect = useCallback(
(tabId: string) => {
@@ -172,6 +265,43 @@ export function TabBar({ paneId, className }: TabBarProps) {
removePane(paneId);
}, [paneId, removePane]);
// Drag over handler for tab bar container (allows dropping to end of list)
const handleContainerDragOver = useCallback((e: React.DragEvent) => {
if (e.dataTransfer.types.includes(TAB_DRAG_DATA_TYPE)) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setIsDragOver(true);
}
}, []);
// Drag leave handler for container
const handleContainerDragLeave = useCallback((e: React.DragEvent) => {
// Only set false if leaving the container entirely, not just moving to a child
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setIsDragOver(false);
}
}, []);
// Drop handler for tab bar container (drops to end of list)
const handleContainerDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const rawData = e.dataTransfer.getData(TAB_DRAG_DATA_TYPE);
if (!rawData) return;
try {
const dragData: TabDragData = JSON.parse(rawData);
const { tabId: sourceTabId, sourcePaneId } = dragData;
// Move the tab to the end of this pane
const targetIndex = pane?.tabs.length || 0;
moveTab(sourcePaneId, sourceTabId, paneId, targetIndex);
} catch (err) {
console.error('[TabBar] Failed to parse drag data:', err);
}
}, [paneId, pane, moveTab]);
// Sort tabs: pinned first, then by order
const sortedTabs = useMemo(() => {
if (!pane) return [];
@@ -197,7 +327,15 @@ export function TabBar({ paneId, className }: TabBarProps) {
)}
>
{/* Tabs */}
<div className="flex items-center gap-1 flex-1 min-w-0 overflow-x-auto">
<div
onDragOver={handleContainerDragOver}
onDragLeave={handleContainerDragLeave}
onDrop={handleContainerDrop}
className={cn(
'flex items-center gap-1 flex-1 min-w-0 overflow-x-auto',
isDragOver && 'bg-primary/5 border border-primary border-dashed rounded'
)}
>
{sortedTabs.length === 0 ? (
<span className="text-xs text-muted-foreground px-2">
{formatMessage({ id: 'cliViewer.tabs.noTabs', defaultMessage: 'No tabs open' })}
@@ -207,6 +345,7 @@ export function TabBar({ paneId, className }: TabBarProps) {
<TabItem
key={tab.id}
tab={tab}
paneId={paneId}
isActive={pane.activeTabId === tab.id}
onSelect={() => handleTabSelect(tab.id)}
onClose={(e) => handleTabClose(e, tab.id)}

View File

@@ -32,8 +32,8 @@ export function CoordinatorEmptyState({
className
)}
>
{/* Animated Background - Using theme colors */}
<div className="absolute inset-0 bg-gradient-to-br from-background via-card to-background">
{/* Animated Background - Using theme colors with gradient utilities */}
<div className="absolute inset-0 bg-gradient-to-br from-background via-card to-background animate-slow-gradient">
{/* Grid Pattern */}
<div
className="absolute inset-0 opacity-10"
@@ -46,29 +46,16 @@ export function CoordinatorEmptyState({
}}
/>
{/* Animated Gradient Orbs - Using primary color */}
<div className="absolute top-20 left-20 w-72 h-72 rounded-full blur-3xl animate-pulse"
style={{
background: 'radial-gradient(circle, hsl(var(--primary)) 0%, transparent 70%)',
opacity: 0.15,
}}
/>
{/* Animated Gradient Orbs - Using gradient utility classes */}
<div className="absolute top-20 left-20 w-72 h-72 rounded-full blur-3xl animate-pulse bg-gradient-primary opacity-15" />
<div
className="absolute bottom-20 right-20 w-96 h-96 rounded-full blur-3xl animate-pulse"
style={{
className="absolute bottom-20 right-20 w-96 h-96 rounded-full blur-3xl animate-pulse opacity-15"
style={{
background: 'radial-gradient(circle, hsl(var(--secondary)) 0%, transparent 70%)',
animationDelay: '1s',
opacity: 0.15,
}}
/>
<div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-80 h-80 rounded-full blur-3xl animate-pulse"
style={{
background: 'radial-gradient(circle, hsl(var(--accent)) 0%, transparent 70%)',
animationDelay: '2s',
opacity: 0.1,
}}
/>
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-80 h-80 rounded-full blur-3xl animate-pulse bg-gradient-primary opacity-10" style={{ animationDelay: '2s' }} />
</div>
{/* Main Content */}

View File

@@ -130,6 +130,7 @@ export function AppShell({
};
window.addEventListener('resize', handleResize);
// Cleanup: Remove event listener on unmount to prevent memory leak
return () => window.removeEventListener('resize', handleResize);
}, []);

View File

@@ -1,32 +1,72 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import { useTheme } from '@/hooks/useTheme';
import { COLOR_SCHEMES, THEME_MODES, getThemeName } from '@/lib/theme';
import type { ColorScheme, ThemeMode } from '@/lib/theme';
import { generateThemeFromHue } from '@/lib/colorGenerator';
/**
* Theme Selector Component
* Allows users to select from 4 color schemes (blue/green/orange/purple)
* and 2 theme modes (light/dark)
* and 2 theme modes (light/dark), plus custom hue customization
*
* Features:
* - 8 total theme combinations
* - 8 preset theme combinations + custom hue support
* - Keyboard navigation support (Arrow keys)
* - ARIA labels for accessibility
* - Visual feedback for selected theme
* - System dark mode detection
* - Custom hue slider (0-360) with real-time preview
*/
export function ThemeSelector() {
const { formatMessage } = useIntl();
const { colorScheme, resolvedTheme, setColorScheme, setTheme } = useTheme();
const { colorScheme, resolvedTheme, customHue, isCustomTheme, setColorScheme, setTheme, setCustomHue } = useTheme();
// Local state for preview hue (uncommitted changes)
const [previewHue, setPreviewHue] = useState<number | null>(customHue);
// Sync preview with customHue from store
useEffect(() => {
setPreviewHue(customHue);
}, [customHue]);
// Resolved mode is either 'light' or 'dark'
const mode: ThemeMode = resolvedTheme;
// Get preview colors for the custom theme swatches
const getPreviewColor = (variable: string) => {
const hue = previewHue ?? 180; // Default to cyan if null
const colors = generateThemeFromHue(hue, mode);
const hslValue = colors[variable];
return hslValue ? `hsl(${hslValue})` : '#888';
};
const handleSchemeSelect = (scheme: ColorScheme) => {
// When selecting a preset scheme, reset custom hue
if (isCustomTheme) {
setCustomHue(null);
}
setColorScheme(scheme);
};
const handleCustomSelect = () => {
// Set custom hue to a default value if null
if (customHue === null) {
setCustomHue(180); // Default cyan
}
};
const handleHueSave = () => {
if (previewHue !== null) {
setCustomHue(previewHue);
}
};
const handleHueReset = () => {
setCustomHue(null);
setPreviewHue(null);
};
const handleModeSelect = (newMode: ThemeMode) => {
setTheme(newMode);
};
@@ -53,7 +93,7 @@ export function ThemeSelector() {
{formatMessage({ id: 'theme.title.colorScheme' })}
</h3>
<div
className="grid grid-cols-4 gap-3"
className="grid grid-cols-5 gap-3"
role="group"
aria-label="Color scheme selection"
onKeyDown={handleKeyDown}
@@ -63,12 +103,12 @@ export function ThemeSelector() {
key={scheme.id}
onClick={() => handleSchemeSelect(scheme.id)}
aria-label={formatMessage({ id: 'theme.select.colorScheme' }, { name: formatMessage({ id: `theme.colorScheme.${scheme.id}` }) })}
aria-selected={colorScheme === scheme.id}
aria-selected={colorScheme === scheme.id && !isCustomTheme}
role="radio"
className={`
flex flex-col items-center gap-2 p-3 rounded-lg
transition-all duration-200 border-2
${colorScheme === scheme.id
${colorScheme === scheme.id && !isCustomTheme
? 'border-accent bg-surface shadow-md'
: 'border-border bg-bg hover:bg-surface'
}
@@ -87,9 +127,124 @@ export function ThemeSelector() {
</span>
</button>
))}
{/* Custom Color Option */}
<button
onClick={handleCustomSelect}
aria-label={formatMessage({ id: 'theme.select.colorScheme' }, { name: formatMessage({ id: 'theme.colorScheme.custom' }) })}
aria-selected={isCustomTheme}
role="radio"
className={`
flex flex-col items-center gap-2 p-3 rounded-lg
transition-all duration-200 border-2
${isCustomTheme
? 'border-accent bg-surface shadow-md'
: 'border-border bg-bg hover:bg-surface'
}
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2
`}
>
{/* Gradient swatch showing current custom hue */}
<div
className="w-8 h-8 rounded-full border-2 border-border shadow-sm"
style={{
background: `linear-gradient(135deg, ${getPreviewColor('--accent')}, ${getPreviewColor('--primary')})`
}}
aria-hidden="true"
/>
{/* Label */}
<span className="text-xs font-medium text-text text-center">
{formatMessage({ id: 'theme.colorScheme.custom' })}
</span>
</button>
</div>
</div>
{/* Custom Hue Selection - Only shown when custom theme is active */}
{isCustomTheme && (
<div className="space-y-4">
<h3 className="text-sm font-medium text-text mb-3">
{formatMessage({ id: 'theme.title.customHue' })}
</h3>
{/* Hue Slider */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<label htmlFor="hue-slider" className="text-xs text-text-secondary">
{formatMessage({ id: 'theme.hueValue' }, { value: previewHue ?? 180 })}
</label>
</div>
<input
id="hue-slider"
type="range"
min="0"
max="360"
step="1"
value={previewHue ?? 180}
onChange={(e) => setPreviewHue(Number(e.target.value))}
className="w-full h-2 rounded-lg appearance-none cursor-pointer"
style={{
background: `linear-gradient(to right,
hsl(0, 70%, 60%), hsl(60, 70%, 60%), hsl(120, 70%, 60%),
hsl(180, 70%, 60%), hsl(240, 70%, 60%), hsl(300, 70%, 60%), hsl(360, 70%, 60%))`
}}
aria-label={formatMessage({ id: 'theme.title.customHue' })}
/>
{/* Preview Swatches */}
<div className="flex gap-2 items-center">
<span className="text-xs text-text-secondary mr-2">
{formatMessage({ id: 'theme.preview' })}:
</span>
<div
className="w-10 h-10 rounded border-2 border-border shadow-sm"
style={{ backgroundColor: getPreviewColor('--bg') }}
title="Background"
/>
<div
className="w-10 h-10 rounded border-2 border-border shadow-sm"
style={{ backgroundColor: getPreviewColor('--surface') }}
title="Surface"
/>
<div
className="w-10 h-10 rounded border-2 border-border shadow-sm"
style={{ backgroundColor: getPreviewColor('--accent') }}
title="Accent"
/>
</div>
{/* Save and Reset Buttons */}
<div className="flex gap-2 pt-2">
<button
onClick={handleHueSave}
disabled={previewHue === customHue}
className={`
flex-1 px-4 py-2 rounded-lg text-sm font-medium
transition-all duration-200
${previewHue === customHue
? 'bg-muted text-muted-text cursor-not-allowed'
: 'bg-accent text-white hover:bg-accent-hover focus:ring-2 focus:ring-accent focus:ring-offset-2'
}
`}
>
{formatMessage({ id: 'theme.save' })}
</button>
<button
onClick={handleHueReset}
className="
px-4 py-2 rounded-lg text-sm font-medium
border-2 border-border bg-bg text-text
hover:bg-surface transition-all duration-200
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2
"
>
{formatMessage({ id: 'theme.reset' })}
</button>
</div>
</div>
</div>
)}
{/* Theme Mode Selection */}
<div>
<h3 className="text-sm font-medium text-text mb-3">