mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
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:
@@ -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)}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user