feat: implement dark/light mode with system detection
- Install and configure next-themes for theme management - Add ThemeProvider wrapper component with suppressHydrationWarning - Define light theme CSS variables (off-white backgrounds, green accents) - Define dark theme CSS variables (hacker theme: terminal blacks, matrix green) - Update scrollbar styles for both themes - Add CRT scanline/glow effects (dark mode only) - Create ThemeToggle component with system/light/dark cycling - Add theme toggle to Toolbox sidebar - Update CandleChart to apply theme-specific colors - Chart colors update dynamically on theme change - All components use CSS variables for theme compatibility - FOUC prevention via next-themes inline script
This commit is contained in:
parent
4dad24845e
commit
0d235602af
9 changed files with 310 additions and 59 deletions
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useEffect, useRef, useState, useImperativeHandle, forwardRef } from 'react';
|
||||
import { createChart, IChartApi, ISeriesApi, CandlestickData, Time } from 'lightweight-charts';
|
||||
import { useTheme } from 'next-themes';
|
||||
import SvgOverlay from './SvgOverlay';
|
||||
|
||||
interface Candle {
|
||||
|
|
@ -56,6 +57,13 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
|||
const [annotations, setAnnotations] = useState<Annotation[]>([]);
|
||||
const [annotationTypes, setAnnotationTypes] = useState<AnnotationType[]>([]);
|
||||
const [isEmpty, setIsEmpty] = useState(true);
|
||||
const { theme, resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Track mounted state to avoid hydration mismatch
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Fetch candles from API
|
||||
const fetchCandles = async () => {
|
||||
|
|
@ -106,31 +114,74 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
|||
},
|
||||
}));
|
||||
|
||||
// Get theme-specific colors
|
||||
const getChartColors = () => {
|
||||
const isDark = resolvedTheme === 'dark';
|
||||
|
||||
if (isDark) {
|
||||
return {
|
||||
layout: {
|
||||
background: { color: '#000000' },
|
||||
textColor: '#00ff41',
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: '#003311' },
|
||||
horzLines: { color: '#003311' },
|
||||
},
|
||||
timeScale: {
|
||||
borderColor: '#003311',
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderColor: '#003311',
|
||||
},
|
||||
crosshair: {
|
||||
color: '#00ff41',
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
layout: {
|
||||
background: { color: '#ffffff' },
|
||||
textColor: '#1a1a2e',
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: '#d1d5db' },
|
||||
horzLines: { color: '#d1d5db' },
|
||||
},
|
||||
timeScale: {
|
||||
borderColor: '#d1d5db',
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderColor: '#d1d5db',
|
||||
},
|
||||
crosshair: {
|
||||
color: '#16a34a',
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize chart
|
||||
useEffect(() => {
|
||||
if (!chartContainerRef.current || isEmpty) return;
|
||||
if (!chartContainerRef.current || isEmpty || !mounted) return;
|
||||
|
||||
const colors = getChartColors();
|
||||
|
||||
const chart = createChart(chartContainerRef.current, {
|
||||
width: chartContainerRef.current.clientWidth,
|
||||
height: chartContainerRef.current.clientHeight,
|
||||
layout: {
|
||||
background: { color: '#ffffff' },
|
||||
textColor: '#1e293b',
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: '#f1f5f9' },
|
||||
horzLines: { color: '#f1f5f9' },
|
||||
},
|
||||
layout: colors.layout,
|
||||
grid: colors.grid,
|
||||
crosshair: {
|
||||
mode: 1,
|
||||
},
|
||||
timeScale: {
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
borderColor: '#e2e8f0',
|
||||
borderColor: colors.timeScale.borderColor,
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderColor: '#e2e8f0',
|
||||
borderColor: colors.rightPriceScale.borderColor,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -160,7 +211,7 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
|||
resizeObserver.disconnect();
|
||||
chart.remove();
|
||||
};
|
||||
}, [isEmpty]);
|
||||
}, [isEmpty, mounted, resolvedTheme]);
|
||||
|
||||
// Load candle data into chart
|
||||
useEffect(() => {
|
||||
|
|
|
|||
8
src/components/ThemeProvider.tsx
Normal file
8
src/components/ThemeProvider.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import { type ThemeProviderProps } from "next-themes/dist/types";
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
64
src/components/ThemeToggle.tsx
Normal file
64
src/components/ThemeToggle.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Monitor, Sun, Moon } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Avoid hydration mismatch by only rendering after mount
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<button
|
||||
className="p-3 rounded-lg border border-border bg-card hover:bg-muted transition-colors"
|
||||
disabled
|
||||
>
|
||||
<Monitor className="h-5 w-5 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const cycleTheme = () => {
|
||||
if (theme === "system") {
|
||||
setTheme("light");
|
||||
} else if (theme === "light") {
|
||||
setTheme("dark");
|
||||
} else {
|
||||
setTheme("system");
|
||||
}
|
||||
};
|
||||
|
||||
const Icon = theme === "light" ? Sun : theme === "dark" ? Moon : Monitor;
|
||||
const themeName = theme === "light" ? "Light" : theme === "dark" ? "Dark" : "System";
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={cycleTheme}
|
||||
className="p-3 rounded-lg border border-border bg-card hover:bg-muted transition-colors"
|
||||
aria-label={`Current theme: ${themeName}. Click to cycle.`}
|
||||
>
|
||||
<Icon className="h-5 w-5 text-foreground" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Theme: {themeName}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
|||
import { ArrowUpCircle, ArrowDownCircle, TrendingUp, Trash2, Download, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||
|
||||
export type Tool = string | 'delete' | null;
|
||||
|
||||
|
|
@ -330,6 +331,11 @@ export default function Toolbox({
|
|||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Theme toggle */}
|
||||
<div className="pt-2">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue