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:
Marko Djordjevic 2026-02-12 23:31:51 +01:00
parent 4dad24845e
commit 0d235602af
9 changed files with 310 additions and 59 deletions

View file

@ -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(() => {

View 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>;
}

View 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>
);
}

View file

@ -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>
);
}