feat: implement comprehensive theming system
- Add ThemeContext with support for dark, gray, light, and custom themes - Create theme switching UI in Settings with theme selector - Add custom color editor for custom theme mode - Update styles.css with theme-specific CSS variables - Add theme storage API methods for persistence - Update syntax highlighting to match selected theme - Wrap App with ThemeProvider for global theme access The theming system allows users to switch between predefined themes or create their own custom theme with live color editing.
This commit is contained in:
@@ -4,6 +4,7 @@ import { Plus, Loader2, Bot, FolderCode } from "lucide-react";
|
||||
import { api, type Project, type Session, type ClaudeMdFile } from "@/lib/api";
|
||||
import { OutputCacheProvider } from "@/lib/outputCache";
|
||||
import { TabProvider } from "@/contexts/TabContext";
|
||||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { ProjectList } from "@/components/ProjectList";
|
||||
@@ -508,11 +509,13 @@ function AppContent() {
|
||||
*/
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<OutputCacheProvider>
|
||||
<TabProvider>
|
||||
<AppContent />
|
||||
</TabProvider>
|
||||
</OutputCacheProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -14,6 +14,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
api,
|
||||
type ClaudeSettings,
|
||||
@@ -25,6 +26,7 @@ import { ClaudeVersionSelector } from "./ClaudeVersionSelector";
|
||||
import { StorageTab } from "./StorageTab";
|
||||
import { HooksEditor } from "./HooksEditor";
|
||||
import { SlashCommandsManager } from "./SlashCommandsManager";
|
||||
import { useTheme } from "@/hooks";
|
||||
|
||||
interface SettingsProps {
|
||||
/**
|
||||
@@ -77,6 +79,9 @@ export const Settings: React.FC<SettingsProps> = ({
|
||||
const [userHooksChanged, setUserHooksChanged] = useState(false);
|
||||
const getUserHooks = React.useRef<(() => any) | null>(null);
|
||||
|
||||
// Theme hook
|
||||
const { theme, setTheme, customColors, setCustomColors } = useTheme();
|
||||
|
||||
// Load settings on mount
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
@@ -375,6 +380,155 @@ export const Settings: React.FC<SettingsProps> = ({
|
||||
<h3 className="text-base font-semibold mb-4">General Settings</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Theme Selector */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="theme">Theme</Label>
|
||||
<Select
|
||||
value={theme}
|
||||
onValueChange={(value) => setTheme(value as any)}
|
||||
>
|
||||
<SelectTrigger id="theme" className="w-full">
|
||||
<SelectValue placeholder="Select a theme" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
<SelectItem value="gray">Gray</SelectItem>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="custom">Custom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose your preferred color theme for the interface
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Custom Color Editor */}
|
||||
{theme === 'custom' && (
|
||||
<div className="space-y-4 p-4 border rounded-lg bg-muted/20">
|
||||
<h4 className="text-sm font-medium">Custom Theme Colors</h4>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Background Color */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="color-background" className="text-xs">Background</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="color-background"
|
||||
type="text"
|
||||
value={customColors.background}
|
||||
onChange={(e) => setCustomColors({ background: e.target.value })}
|
||||
placeholder="oklch(0.12 0.01 240)"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<div
|
||||
className="w-10 h-10 rounded border"
|
||||
style={{ backgroundColor: customColors.background }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Foreground Color */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="color-foreground" className="text-xs">Foreground</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="color-foreground"
|
||||
type="text"
|
||||
value={customColors.foreground}
|
||||
onChange={(e) => setCustomColors({ foreground: e.target.value })}
|
||||
placeholder="oklch(0.98 0.01 240)"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<div
|
||||
className="w-10 h-10 rounded border"
|
||||
style={{ backgroundColor: customColors.foreground }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Primary Color */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="color-primary" className="text-xs">Primary</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="color-primary"
|
||||
type="text"
|
||||
value={customColors.primary}
|
||||
onChange={(e) => setCustomColors({ primary: e.target.value })}
|
||||
placeholder="oklch(0.98 0.01 240)"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<div
|
||||
className="w-10 h-10 rounded border"
|
||||
style={{ backgroundColor: customColors.primary }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card Color */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="color-card" className="text-xs">Card</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="color-card"
|
||||
type="text"
|
||||
value={customColors.card}
|
||||
onChange={(e) => setCustomColors({ card: e.target.value })}
|
||||
placeholder="oklch(0.14 0.01 240)"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<div
|
||||
className="w-10 h-10 rounded border"
|
||||
style={{ backgroundColor: customColors.card }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accent Color */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="color-accent" className="text-xs">Accent</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="color-accent"
|
||||
type="text"
|
||||
value={customColors.accent}
|
||||
onChange={(e) => setCustomColors({ accent: e.target.value })}
|
||||
placeholder="oklch(0.16 0.01 240)"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<div
|
||||
className="w-10 h-10 rounded border"
|
||||
style={{ backgroundColor: customColors.accent }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Destructive Color */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="color-destructive" className="text-xs">Destructive</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="color-destructive"
|
||||
type="text"
|
||||
value={customColors.destructive}
|
||||
onChange={(e) => setCustomColors({ destructive: e.target.value })}
|
||||
placeholder="oklch(0.6 0.2 25)"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<div
|
||||
className="w-10 h-10 rounded border"
|
||||
style={{ backgroundColor: customColors.destructive }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use CSS color values (hex, rgb, oklch, etc.). Changes apply immediately.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Include Co-authored By */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5 flex-1">
|
||||
|
@@ -11,7 +11,8 @@ import { cn } from "@/lib/utils";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { claudeSyntaxTheme } from "@/lib/claudeSyntaxTheme";
|
||||
import { getClaudeSyntaxTheme } from "@/lib/claudeSyntaxTheme";
|
||||
import { useTheme } from "@/hooks";
|
||||
import type { ClaudeStreamMessage } from "./AgentExecution";
|
||||
import {
|
||||
TodoWidget,
|
||||
@@ -54,6 +55,10 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, classNa
|
||||
// State to track tool results mapped by tool call ID
|
||||
const [toolResults, setToolResults] = useState<Map<string, any>>(new Map());
|
||||
|
||||
// Get current theme
|
||||
const { theme } = useTheme();
|
||||
const syntaxTheme = getClaudeSyntaxTheme(theme);
|
||||
|
||||
// Extract all tool results from stream messages
|
||||
useEffect(() => {
|
||||
const results = new Map<string, any>();
|
||||
@@ -131,7 +136,7 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, classNa
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
return !inline && match ? (
|
||||
<SyntaxHighlighter
|
||||
style={claudeSyntaxTheme}
|
||||
style={syntaxTheme}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
{...props}
|
||||
@@ -660,7 +665,7 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, classNa
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
return !inline && match ? (
|
||||
<SyntaxHighlighter
|
||||
style={claudeSyntaxTheme}
|
||||
style={syntaxTheme}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
{...props}
|
||||
|
@@ -51,7 +51,8 @@ import {
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { claudeSyntaxTheme } from "@/lib/claudeSyntaxTheme";
|
||||
import { getClaudeSyntaxTheme } from "@/lib/claudeSyntaxTheme";
|
||||
import { useTheme } from "@/hooks";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { createPortal } from "react-dom";
|
||||
import * as Diff from 'diff';
|
||||
@@ -400,6 +401,8 @@ export const ReadWidget: React.FC<{ filePath: string; result?: any }> = ({ fileP
|
||||
*/
|
||||
export const ReadResultWidget: React.FC<{ content: string; filePath?: string }> = ({ content, filePath }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const { theme } = useTheme();
|
||||
const syntaxTheme = getClaudeSyntaxTheme(theme);
|
||||
|
||||
// Extract file extension for syntax highlighting
|
||||
const getLanguage = (path?: string) => {
|
||||
@@ -530,7 +533,7 @@ export const ReadResultWidget: React.FC<{ content: string; filePath?: string }>
|
||||
<div className="relative overflow-x-auto">
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={claudeSyntaxTheme}
|
||||
style={syntaxTheme}
|
||||
showLineNumbers
|
||||
startingLineNumber={startLineNumber}
|
||||
wrapLongLines={false}
|
||||
@@ -629,6 +632,9 @@ export const BashWidget: React.FC<{
|
||||
description?: string;
|
||||
result?: any;
|
||||
}> = ({ command, description, result }) => {
|
||||
const { theme } = useTheme();
|
||||
const syntaxTheme = getClaudeSyntaxTheme(theme);
|
||||
|
||||
// Extract result content if available
|
||||
let resultContent = '';
|
||||
let isError = false;
|
||||
@@ -695,6 +701,8 @@ export const BashWidget: React.FC<{
|
||||
*/
|
||||
export const WriteWidget: React.FC<{ filePath: string; content: string; result?: any }> = ({ filePath, content, result: _result }) => {
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
const { theme } = useTheme();
|
||||
const syntaxTheme = getClaudeSyntaxTheme(theme);
|
||||
|
||||
// Extract file extension for syntax highlighting
|
||||
const getLanguage = (path: string) => {
|
||||
@@ -776,7 +784,7 @@ export const WriteWidget: React.FC<{ filePath: string; content: string; result?:
|
||||
<div className="flex-1 overflow-auto">
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={claudeSyntaxTheme}
|
||||
style={syntaxTheme}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1.5rem',
|
||||
@@ -827,7 +835,7 @@ export const WriteWidget: React.FC<{ filePath: string; content: string; result?:
|
||||
<div className="overflow-auto flex-1">
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={claudeSyntaxTheme}
|
||||
style={syntaxTheme}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1rem',
|
||||
@@ -1121,6 +1129,8 @@ export const EditWidget: React.FC<{
|
||||
new_string: string;
|
||||
result?: any;
|
||||
}> = ({ file_path, old_string, new_string, result: _result }) => {
|
||||
const { theme } = useTheme();
|
||||
const syntaxTheme = getClaudeSyntaxTheme(theme);
|
||||
|
||||
const diffResult = Diff.diffLines(old_string || '', new_string || '', {
|
||||
newlineIsToken: true,
|
||||
@@ -1165,7 +1175,7 @@ export const EditWidget: React.FC<{
|
||||
<div className="flex-1">
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={claudeSyntaxTheme}
|
||||
style={syntaxTheme}
|
||||
PreTag="div"
|
||||
wrapLongLines={false}
|
||||
customStyle={{
|
||||
@@ -1196,6 +1206,9 @@ export const EditWidget: React.FC<{
|
||||
* Widget for Edit tool result - shows a diff view
|
||||
*/
|
||||
export const EditResultWidget: React.FC<{ content: string }> = ({ content }) => {
|
||||
const { theme } = useTheme();
|
||||
const syntaxTheme = getClaudeSyntaxTheme(theme);
|
||||
|
||||
// Parse the content to extract file path and code snippet
|
||||
const lines = content.split('\n');
|
||||
let filePath = '';
|
||||
@@ -1245,7 +1258,7 @@ export const EditResultWidget: React.FC<{ content: string }> = ({ content }) =>
|
||||
<div className="overflow-x-auto max-h-[440px]">
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={claudeSyntaxTheme}
|
||||
style={syntaxTheme}
|
||||
showLineNumbers
|
||||
startingLineNumber={startLineNumber}
|
||||
wrapLongLines={false}
|
||||
@@ -1282,6 +1295,8 @@ export const MCPWidget: React.FC<{
|
||||
result?: any;
|
||||
}> = ({ toolName, input, result: _result }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const { theme } = useTheme();
|
||||
const syntaxTheme = getClaudeSyntaxTheme(theme);
|
||||
|
||||
// Parse the tool name to extract components
|
||||
// Format: mcp__namespace__method
|
||||
@@ -1396,7 +1411,7 @@ export const MCPWidget: React.FC<{
|
||||
)}>
|
||||
<SyntaxHighlighter
|
||||
language="json"
|
||||
style={claudeSyntaxTheme}
|
||||
style={syntaxTheme}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '0.75rem',
|
||||
@@ -1585,6 +1600,8 @@ export const MultiEditWidget: React.FC<{
|
||||
}> = ({ file_path, edits, result: _result }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const language = getLanguage(file_path);
|
||||
const { theme } = useTheme();
|
||||
const syntaxTheme = getClaudeSyntaxTheme(theme);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -1645,7 +1662,7 @@ export const MultiEditWidget: React.FC<{
|
||||
<div className="flex-1">
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={claudeSyntaxTheme}
|
||||
style={syntaxTheme}
|
||||
PreTag="div"
|
||||
wrapLongLines={false}
|
||||
customStyle={{
|
||||
|
182
src/contexts/ThemeContext.tsx
Normal file
182
src/contexts/ThemeContext.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import React, { createContext, useState, useContext, useCallback, useEffect } from 'react';
|
||||
import { api } from '../lib/api';
|
||||
|
||||
export type ThemeMode = 'dark' | 'gray' | 'light' | 'custom';
|
||||
|
||||
export interface CustomThemeColors {
|
||||
background: string;
|
||||
foreground: string;
|
||||
card: string;
|
||||
cardForeground: string;
|
||||
primary: string;
|
||||
primaryForeground: string;
|
||||
secondary: string;
|
||||
secondaryForeground: string;
|
||||
muted: string;
|
||||
mutedForeground: string;
|
||||
accent: string;
|
||||
accentForeground: string;
|
||||
destructive: string;
|
||||
destructiveForeground: string;
|
||||
border: string;
|
||||
input: string;
|
||||
ring: string;
|
||||
}
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: ThemeMode;
|
||||
customColors: CustomThemeColors;
|
||||
setTheme: (theme: ThemeMode) => Promise<void>;
|
||||
setCustomColors: (colors: Partial<CustomThemeColors>) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
const THEME_STORAGE_KEY = 'theme_preference';
|
||||
const CUSTOM_COLORS_STORAGE_KEY = 'theme_custom_colors';
|
||||
|
||||
// Default custom theme colors (based on current dark theme)
|
||||
const DEFAULT_CUSTOM_COLORS: CustomThemeColors = {
|
||||
background: 'oklch(0.12 0.01 240)',
|
||||
foreground: 'oklch(0.98 0.01 240)',
|
||||
card: 'oklch(0.14 0.01 240)',
|
||||
cardForeground: 'oklch(0.98 0.01 240)',
|
||||
primary: 'oklch(0.98 0.01 240)',
|
||||
primaryForeground: 'oklch(0.12 0.01 240)',
|
||||
secondary: 'oklch(0.16 0.01 240)',
|
||||
secondaryForeground: 'oklch(0.98 0.01 240)',
|
||||
muted: 'oklch(0.16 0.01 240)',
|
||||
mutedForeground: 'oklch(0.65 0.01 240)',
|
||||
accent: 'oklch(0.16 0.01 240)',
|
||||
accentForeground: 'oklch(0.98 0.01 240)',
|
||||
destructive: 'oklch(0.6 0.2 25)',
|
||||
destructiveForeground: 'oklch(0.98 0.01 240)',
|
||||
border: 'oklch(0.16 0.01 240)',
|
||||
input: 'oklch(0.16 0.01 240)',
|
||||
ring: 'oklch(0.98 0.01 240)',
|
||||
};
|
||||
|
||||
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [theme, setThemeState] = useState<ThemeMode>('dark');
|
||||
const [customColors, setCustomColorsState] = useState<CustomThemeColors>(DEFAULT_CUSTOM_COLORS);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Load theme preference and custom colors from storage
|
||||
useEffect(() => {
|
||||
const loadTheme = async () => {
|
||||
try {
|
||||
// Load theme preference
|
||||
const savedTheme = await api.getSetting(THEME_STORAGE_KEY);
|
||||
|
||||
if (savedTheme) {
|
||||
const themeMode = savedTheme as ThemeMode;
|
||||
setThemeState(themeMode);
|
||||
applyTheme(themeMode, customColors);
|
||||
}
|
||||
|
||||
// Load custom colors
|
||||
const savedColors = await api.getSetting(CUSTOM_COLORS_STORAGE_KEY);
|
||||
|
||||
if (savedColors) {
|
||||
const colors = JSON.parse(savedColors) as CustomThemeColors;
|
||||
setCustomColorsState(colors);
|
||||
if (theme === 'custom') {
|
||||
applyTheme('custom', colors);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load theme settings:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadTheme();
|
||||
}, []);
|
||||
|
||||
// Apply theme to document
|
||||
const applyTheme = useCallback((themeMode: ThemeMode, colors: CustomThemeColors) => {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Remove all theme classes
|
||||
root.classList.remove('theme-dark', 'theme-gray', 'theme-light', 'theme-custom');
|
||||
|
||||
// Add new theme class
|
||||
root.classList.add(`theme-${themeMode}`);
|
||||
|
||||
// If custom theme, apply custom colors as CSS variables
|
||||
if (themeMode === 'custom') {
|
||||
Object.entries(colors).forEach(([key, value]) => {
|
||||
const cssVarName = `--color-${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
|
||||
root.style.setProperty(cssVarName, value);
|
||||
});
|
||||
} else {
|
||||
// Clear custom CSS variables when not using custom theme
|
||||
Object.keys(colors).forEach((key) => {
|
||||
const cssVarName = `--color-${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
|
||||
root.style.removeProperty(cssVarName);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setTheme = useCallback(async (newTheme: ThemeMode) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Apply theme immediately
|
||||
setThemeState(newTheme);
|
||||
applyTheme(newTheme, customColors);
|
||||
|
||||
// Save to storage
|
||||
await api.saveSetting(THEME_STORAGE_KEY, newTheme);
|
||||
} catch (error) {
|
||||
console.error('Failed to save theme preference:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [customColors, applyTheme]);
|
||||
|
||||
const setCustomColors = useCallback(async (colors: Partial<CustomThemeColors>) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const newColors = { ...customColors, ...colors };
|
||||
setCustomColorsState(newColors);
|
||||
|
||||
// Apply immediately if custom theme is active
|
||||
if (theme === 'custom') {
|
||||
applyTheme('custom', newColors);
|
||||
}
|
||||
|
||||
// Save to storage
|
||||
await api.saveSetting(CUSTOM_COLORS_STORAGE_KEY, JSON.stringify(newColors));
|
||||
} catch (error) {
|
||||
console.error('Failed to save custom colors:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [theme, customColors, applyTheme]);
|
||||
|
||||
const value: ThemeContextType = {
|
||||
theme,
|
||||
customColors,
|
||||
setTheme,
|
||||
setCustomColors,
|
||||
isLoading,
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={value}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useThemeContext = () => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useThemeContext must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
@@ -3,3 +3,4 @@ export { useLoadingState } from './useLoadingState';
|
||||
export { useDebounce, useDebouncedCallback } from './useDebounce';
|
||||
export { useApiCall } from './useApiCall';
|
||||
export { usePagination } from './usePagination';
|
||||
export { useTheme } from './useTheme';
|
24
src/hooks/useTheme.ts
Normal file
24
src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useThemeContext } from '../contexts/ThemeContext';
|
||||
|
||||
/**
|
||||
* Hook to access and control the theme system
|
||||
*
|
||||
* @returns {Object} Theme utilities and state
|
||||
* @returns {ThemeMode} theme - Current theme mode ('dark' | 'gray' | 'light' | 'custom')
|
||||
* @returns {CustomThemeColors} customColors - Custom theme color configuration
|
||||
* @returns {Function} setTheme - Function to change the theme mode
|
||||
* @returns {Function} setCustomColors - Function to update custom theme colors
|
||||
* @returns {boolean} isLoading - Whether theme operations are in progress
|
||||
*
|
||||
* @example
|
||||
* const { theme, setTheme } = useTheme();
|
||||
*
|
||||
* // Change theme
|
||||
* await setTheme('light');
|
||||
*
|
||||
* // Update custom colors
|
||||
* await setCustomColors({ background: 'oklch(0.98 0.01 240)' });
|
||||
*/
|
||||
export const useTheme = () => {
|
||||
return useThemeContext();
|
||||
};
|
@@ -1685,6 +1685,50 @@ export const api = {
|
||||
}
|
||||
},
|
||||
|
||||
// Theme settings helpers
|
||||
|
||||
/**
|
||||
* Gets a setting from the app_settings table
|
||||
* @param key - The setting key to retrieve
|
||||
* @returns Promise resolving to the setting value or null if not found
|
||||
*/
|
||||
async getSetting(key: string): Promise<string | null> {
|
||||
try {
|
||||
// Use storageReadTable to safely query the app_settings table
|
||||
const result = await this.storageReadTable('app_settings', 1, 1000);
|
||||
const setting = result?.data?.find((row: any) => row.key === key);
|
||||
return setting?.value || null;
|
||||
} catch (error) {
|
||||
console.error(`Failed to get setting ${key}:`, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Saves a setting to the app_settings table (insert or update)
|
||||
* @param key - The setting key
|
||||
* @param value - The setting value
|
||||
* @returns Promise resolving when the setting is saved
|
||||
*/
|
||||
async saveSetting(key: string, value: string): Promise<void> {
|
||||
try {
|
||||
// Try to update first
|
||||
try {
|
||||
await this.storageUpdateRow(
|
||||
'app_settings',
|
||||
{ key },
|
||||
{ value }
|
||||
);
|
||||
} catch (updateError) {
|
||||
// If update fails (row doesn't exist), insert new row
|
||||
await this.storageInsertRow('app_settings', { key, value });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to save setting ${key}:`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get hooks configuration for a specific scope
|
||||
* @param scope - The configuration scope: 'user', 'project', or 'local'
|
||||
|
@@ -1,11 +1,88 @@
|
||||
import { ThemeMode } from '@/contexts/ThemeContext';
|
||||
|
||||
/**
|
||||
* Claude-themed syntax highlighting theme
|
||||
* Features orange, purple, and violet colors to match Claude's aesthetic
|
||||
* Claude-themed syntax highlighting theme factory
|
||||
* Returns different syntax themes based on the current theme mode
|
||||
*
|
||||
* @param theme - The current theme mode
|
||||
* @returns Prism syntax highlighting theme object
|
||||
*/
|
||||
export const claudeSyntaxTheme: any = {
|
||||
'code[class*="language-"]': {
|
||||
color: '#e3e8f0',
|
||||
export const getClaudeSyntaxTheme = (theme: ThemeMode): any => {
|
||||
const themes = {
|
||||
dark: {
|
||||
base: '#e3e8f0',
|
||||
background: 'transparent',
|
||||
comment: '#6b7280',
|
||||
punctuation: '#9ca3af',
|
||||
property: '#f59e0b', // Amber/Orange
|
||||
tag: '#8b5cf6', // Violet
|
||||
string: '#10b981', // Emerald Green
|
||||
function: '#818cf8', // Indigo
|
||||
keyword: '#c084fc', // Light Violet
|
||||
variable: '#a78bfa', // Light Purple
|
||||
operator: '#9ca3af',
|
||||
},
|
||||
gray: {
|
||||
base: '#e3e8f0',
|
||||
background: 'transparent',
|
||||
comment: '#71717a',
|
||||
punctuation: '#a1a1aa',
|
||||
property: '#fbbf24', // Yellow
|
||||
tag: '#a78bfa', // Light Purple
|
||||
string: '#34d399', // Green
|
||||
function: '#93bbfc', // Light Blue
|
||||
keyword: '#d8b4fe', // Light Purple
|
||||
variable: '#c084fc', // Purple
|
||||
operator: '#a1a1aa',
|
||||
},
|
||||
light: {
|
||||
base: '#1f2937',
|
||||
background: 'transparent',
|
||||
comment: '#9ca3af',
|
||||
punctuation: '#6b7280',
|
||||
property: '#dc2626', // Red
|
||||
tag: '#7c3aed', // Purple
|
||||
string: '#059669', // Green
|
||||
function: '#2563eb', // Blue
|
||||
keyword: '#9333ea', // Purple
|
||||
variable: '#8b5cf6', // Violet
|
||||
operator: '#6b7280',
|
||||
},
|
||||
white: {
|
||||
base: '#000000',
|
||||
background: 'transparent',
|
||||
comment: '#6b7280',
|
||||
punctuation: '#374151',
|
||||
property: '#dc2626', // Red
|
||||
tag: '#5b21b6', // Deep Purple
|
||||
string: '#047857', // Dark Green
|
||||
function: '#1e40af', // Dark Blue
|
||||
keyword: '#6b21a8', // Dark Purple
|
||||
variable: '#6d28d9', // Dark Violet
|
||||
operator: '#374151',
|
||||
},
|
||||
custom: {
|
||||
// Default to dark theme colors for custom
|
||||
base: '#e3e8f0',
|
||||
background: 'transparent',
|
||||
comment: '#6b7280',
|
||||
punctuation: '#9ca3af',
|
||||
property: '#f59e0b',
|
||||
tag: '#8b5cf6',
|
||||
string: '#10b981',
|
||||
function: '#818cf8',
|
||||
keyword: '#c084fc',
|
||||
variable: '#a78bfa',
|
||||
operator: '#9ca3af',
|
||||
}
|
||||
};
|
||||
|
||||
const colors = themes[theme] || themes.dark;
|
||||
|
||||
return {
|
||||
'code[class*="language-"]': {
|
||||
color: colors.base,
|
||||
background: colors.background,
|
||||
textShadow: 'none',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.875em',
|
||||
@@ -24,8 +101,8 @@ export const claudeSyntaxTheme: any = {
|
||||
hyphens: 'none',
|
||||
},
|
||||
'pre[class*="language-"]': {
|
||||
color: '#e3e8f0',
|
||||
background: 'transparent',
|
||||
color: colors.base,
|
||||
background: colors.background,
|
||||
textShadow: 'none',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.875em',
|
||||
@@ -47,100 +124,102 @@ export const claudeSyntaxTheme: any = {
|
||||
overflow: 'auto',
|
||||
},
|
||||
':not(pre) > code[class*="language-"]': {
|
||||
background: 'rgba(139, 92, 246, 0.1)',
|
||||
background: theme === 'light' || theme === 'white'
|
||||
? 'rgba(139, 92, 246, 0.1)'
|
||||
: 'rgba(139, 92, 246, 0.1)',
|
||||
padding: '0.1em 0.3em',
|
||||
borderRadius: '0.3em',
|
||||
whiteSpace: 'normal',
|
||||
},
|
||||
'comment': {
|
||||
color: '#6b7280',
|
||||
color: colors.comment,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'prolog': {
|
||||
color: '#6b7280',
|
||||
color: colors.comment,
|
||||
},
|
||||
'doctype': {
|
||||
color: '#6b7280',
|
||||
color: colors.comment,
|
||||
},
|
||||
'cdata': {
|
||||
color: '#6b7280',
|
||||
color: colors.comment,
|
||||
},
|
||||
'punctuation': {
|
||||
color: '#9ca3af',
|
||||
color: colors.punctuation,
|
||||
},
|
||||
'namespace': {
|
||||
opacity: '0.7',
|
||||
},
|
||||
'property': {
|
||||
color: '#f59e0b', // Amber/Orange
|
||||
color: colors.property,
|
||||
},
|
||||
'tag': {
|
||||
color: '#8b5cf6', // Violet
|
||||
color: colors.tag,
|
||||
},
|
||||
'boolean': {
|
||||
color: '#f59e0b', // Amber/Orange
|
||||
color: colors.property,
|
||||
},
|
||||
'number': {
|
||||
color: '#f59e0b', // Amber/Orange
|
||||
color: colors.property,
|
||||
},
|
||||
'constant': {
|
||||
color: '#f59e0b', // Amber/Orange
|
||||
color: colors.property,
|
||||
},
|
||||
'symbol': {
|
||||
color: '#f59e0b', // Amber/Orange
|
||||
color: colors.property,
|
||||
},
|
||||
'deleted': {
|
||||
color: '#ef4444',
|
||||
},
|
||||
'selector': {
|
||||
color: '#a78bfa', // Light Purple
|
||||
color: colors.variable,
|
||||
},
|
||||
'attr-name': {
|
||||
color: '#a78bfa', // Light Purple
|
||||
color: colors.variable,
|
||||
},
|
||||
'string': {
|
||||
color: '#10b981', // Emerald Green
|
||||
color: colors.string,
|
||||
},
|
||||
'char': {
|
||||
color: '#10b981', // Emerald Green
|
||||
color: colors.string,
|
||||
},
|
||||
'builtin': {
|
||||
color: '#8b5cf6', // Violet
|
||||
color: colors.tag,
|
||||
},
|
||||
'url': {
|
||||
color: '#10b981', // Emerald Green
|
||||
color: colors.string,
|
||||
},
|
||||
'inserted': {
|
||||
color: '#10b981', // Emerald Green
|
||||
color: colors.string,
|
||||
},
|
||||
'entity': {
|
||||
color: '#a78bfa', // Light Purple
|
||||
color: colors.variable,
|
||||
cursor: 'help',
|
||||
},
|
||||
'atrule': {
|
||||
color: '#c084fc', // Light Violet
|
||||
color: colors.keyword,
|
||||
},
|
||||
'attr-value': {
|
||||
color: '#10b981', // Emerald Green
|
||||
color: colors.string,
|
||||
},
|
||||
'keyword': {
|
||||
color: '#c084fc', // Light Violet
|
||||
color: colors.keyword,
|
||||
},
|
||||
'function': {
|
||||
color: '#818cf8', // Indigo
|
||||
color: colors.function,
|
||||
},
|
||||
'class-name': {
|
||||
color: '#f59e0b', // Amber/Orange
|
||||
color: colors.property,
|
||||
},
|
||||
'regex': {
|
||||
color: '#06b6d4', // Cyan
|
||||
},
|
||||
'important': {
|
||||
color: '#f59e0b', // Amber/Orange
|
||||
color: colors.property,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'variable': {
|
||||
color: '#a78bfa', // Light Purple
|
||||
color: colors.variable,
|
||||
},
|
||||
'bold': {
|
||||
fontWeight: 'bold',
|
||||
@@ -149,27 +228,31 @@ export const claudeSyntaxTheme: any = {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'operator': {
|
||||
color: '#9ca3af',
|
||||
color: colors.operator,
|
||||
},
|
||||
'script': {
|
||||
color: '#e3e8f0',
|
||||
color: colors.base,
|
||||
},
|
||||
'parameter': {
|
||||
color: '#fbbf24', // Yellow
|
||||
color: colors.property,
|
||||
},
|
||||
'method': {
|
||||
color: '#818cf8', // Indigo
|
||||
color: colors.function,
|
||||
},
|
||||
'field': {
|
||||
color: '#f59e0b', // Amber/Orange
|
||||
color: colors.property,
|
||||
},
|
||||
'annotation': {
|
||||
color: '#6b7280',
|
||||
color: colors.comment,
|
||||
},
|
||||
'type': {
|
||||
color: '#a78bfa', // Light Purple
|
||||
color: colors.variable,
|
||||
},
|
||||
'module': {
|
||||
color: '#8b5cf6', // Violet
|
||||
color: colors.tag,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Export default dark theme for backward compatibility
|
||||
export const claudeSyntaxTheme = getClaudeSyntaxTheme('dark');
|
108
src/styles.css
108
src/styles.css
@@ -43,6 +43,95 @@
|
||||
--ease-bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
}
|
||||
|
||||
/* Theme Variations */
|
||||
/* Default is dark theme - already defined above */
|
||||
|
||||
/* Light Theme */
|
||||
.theme-light {
|
||||
--color-background: oklch(0.98 0.01 240);
|
||||
--color-foreground: oklch(0.12 0.01 240);
|
||||
--color-card: oklch(0.96 0.01 240);
|
||||
--color-card-foreground: oklch(0.12 0.01 240);
|
||||
--color-popover: oklch(0.98 0.01 240);
|
||||
--color-popover-foreground: oklch(0.12 0.01 240);
|
||||
--color-primary: oklch(0.12 0.01 240);
|
||||
--color-primary-foreground: oklch(0.98 0.01 240);
|
||||
--color-secondary: oklch(0.94 0.01 240);
|
||||
--color-secondary-foreground: oklch(0.12 0.01 240);
|
||||
--color-muted: oklch(0.94 0.01 240);
|
||||
--color-muted-foreground: oklch(0.45 0.01 240);
|
||||
--color-accent: oklch(0.94 0.01 240);
|
||||
--color-accent-foreground: oklch(0.12 0.01 240);
|
||||
--color-destructive: oklch(0.6 0.2 25);
|
||||
--color-destructive-foreground: oklch(0.98 0.01 240);
|
||||
--color-border: oklch(0.90 0.01 240);
|
||||
--color-input: oklch(0.90 0.01 240);
|
||||
--color-ring: oklch(0.52 0.015 240);
|
||||
|
||||
/* Additional colors for status messages */
|
||||
--color-green-500: oklch(0.62 0.20 142);
|
||||
--color-green-600: oklch(0.54 0.22 142);
|
||||
}
|
||||
|
||||
/* Gray Theme */
|
||||
.theme-gray {
|
||||
--color-background: oklch(0.22 0.01 240);
|
||||
--color-foreground: oklch(0.98 0.01 240);
|
||||
--color-card: oklch(0.26 0.01 240);
|
||||
--color-card-foreground: oklch(0.98 0.01 240);
|
||||
--color-popover: oklch(0.22 0.01 240);
|
||||
--color-popover-foreground: oklch(0.98 0.01 240);
|
||||
--color-primary: oklch(0.98 0.01 240);
|
||||
--color-primary-foreground: oklch(0.22 0.01 240);
|
||||
--color-secondary: oklch(0.30 0.01 240);
|
||||
--color-secondary-foreground: oklch(0.98 0.01 240);
|
||||
--color-muted: oklch(0.30 0.01 240);
|
||||
--color-muted-foreground: oklch(0.70 0.01 240);
|
||||
--color-accent: oklch(0.30 0.01 240);
|
||||
--color-accent-foreground: oklch(0.98 0.01 240);
|
||||
--color-destructive: oklch(0.6 0.2 25);
|
||||
--color-destructive-foreground: oklch(0.98 0.01 240);
|
||||
--color-border: oklch(0.30 0.01 240);
|
||||
--color-input: oklch(0.30 0.01 240);
|
||||
--color-ring: oklch(0.60 0.015 240);
|
||||
|
||||
/* Additional colors for status messages */
|
||||
--color-green-500: oklch(0.72 0.20 142);
|
||||
--color-green-600: oklch(0.64 0.22 142);
|
||||
}
|
||||
|
||||
/* White Theme (High Contrast Light) */
|
||||
.theme-white {
|
||||
--color-background: oklch(1.0 0 240);
|
||||
--color-foreground: oklch(0.0 0 240);
|
||||
--color-card: oklch(0.98 0.01 240);
|
||||
--color-card-foreground: oklch(0.0 0 240);
|
||||
--color-popover: oklch(1.0 0 240);
|
||||
--color-popover-foreground: oklch(0.0 0 240);
|
||||
--color-primary: oklch(0.0 0 240);
|
||||
--color-primary-foreground: oklch(1.0 0 240);
|
||||
--color-secondary: oklch(0.96 0.01 240);
|
||||
--color-secondary-foreground: oklch(0.0 0 240);
|
||||
--color-muted: oklch(0.96 0.01 240);
|
||||
--color-muted-foreground: oklch(0.35 0.01 240);
|
||||
--color-accent: oklch(0.96 0.01 240);
|
||||
--color-accent-foreground: oklch(0.0 0 240);
|
||||
--color-destructive: oklch(0.55 0.25 25);
|
||||
--color-destructive-foreground: oklch(1.0 0 240);
|
||||
--color-border: oklch(0.85 0.01 240);
|
||||
--color-input: oklch(0.85 0.01 240);
|
||||
--color-ring: oklch(0.40 0.015 240);
|
||||
|
||||
/* Additional colors for status messages */
|
||||
--color-green-500: oklch(0.55 0.25 142);
|
||||
--color-green-600: oklch(0.47 0.27 142);
|
||||
}
|
||||
|
||||
/* Custom Theme - CSS variables will be set dynamically by ThemeContext */
|
||||
.theme-custom {
|
||||
/* Custom theme variables are applied dynamically via JavaScript */
|
||||
}
|
||||
|
||||
/* Reset and base styles */
|
||||
* {
|
||||
border-color: var(--color-border);
|
||||
@@ -157,8 +246,10 @@ button:focus-visible,
|
||||
}
|
||||
}
|
||||
|
||||
/* Markdown Editor Dark Mode Styles */
|
||||
[data-color-mode="dark"] {
|
||||
/* Markdown Editor Theme-aware Styles */
|
||||
[data-color-mode="dark"],
|
||||
.theme-dark [data-color-mode="dark"],
|
||||
.theme-gray [data-color-mode="dark"] {
|
||||
--color-border-default: rgb(48, 54, 61);
|
||||
--color-canvas-default: rgb(13, 17, 23);
|
||||
--color-canvas-subtle: rgb(22, 27, 34);
|
||||
@@ -169,6 +260,19 @@ button:focus-visible,
|
||||
--color-danger-fg: rgb(248, 81, 73);
|
||||
}
|
||||
|
||||
[data-color-mode="light"],
|
||||
.theme-light [data-color-mode="light"],
|
||||
.theme-white [data-color-mode="light"] {
|
||||
--color-border-default: rgb(216, 222, 228);
|
||||
--color-canvas-default: rgb(255, 255, 255);
|
||||
--color-canvas-subtle: rgb(246, 248, 250);
|
||||
--color-fg-default: rgb(31, 35, 40);
|
||||
--color-fg-muted: rgb(101, 109, 118);
|
||||
--color-fg-subtle: rgb(149, 157, 165);
|
||||
--color-accent-fg: rgb(9, 105, 218);
|
||||
--color-danger-fg: rgb(207, 34, 46);
|
||||
}
|
||||
|
||||
.w-md-editor {
|
||||
background-color: transparent !important;
|
||||
color: var(--color-foreground) !important;
|
||||
|
Reference in New Issue
Block a user