Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion src/app/dashboard/settings/preferences/page.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
"use client";

import { useState, useEffect } from "react";
import { Globe, Clock, DollarSign, Save, X, AlertCircle, CheckCircle2 } from "lucide-react";
import { Globe, Clock, DollarSign, Save, X, AlertCircle, CheckCircle2, Sun, Moon, Monitor } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { mockAudit } from "@/lib/mock-audit";
import { SettingsSectionSkeleton } from "@/components/ui/Skeleton";
import { useTheme, ThemeMode } from "@/contexts/ThemeProvider";

interface PreferencesData {
locale: string;
timezone: string;
currencyFormat: string;
theme: ThemeMode;
}

const LOCALES = [
Expand Down Expand Up @@ -46,6 +48,7 @@ const DEFAULT: PreferencesData = {
locale: "en-US",
timezone: "UTC",
currencyFormat: "USD",
theme: "system",
};

export default function PreferencesPage() {
Expand Down Expand Up @@ -162,6 +165,60 @@ export default function PreferencesPage() {
</div>
</div>

<div className="settings-card">
<div className="settings-card-header">
<div className="settings-card-icon">
<Monitor size={18} />
</div>
<div>
<h2 className="settings-card-title">Appearance</h2>
<p className="settings-card-desc">Theme and visual display preferences</p>
</div>
</div>

<div className="settings-card-body">
<div className="settings-field">
<label className="settings-label">
Theme
</label>
{editing ? (
<div className="theme-options">
<button
type="button"
onClick={() => setDraft({ ...draft, theme: "light" })}
className={`theme-option ${draft.theme === "light" ? "active" : ""}`}
>
<Sun size={16} />
<span>Light</span>
</button>
<button
type="button"
onClick={() => setDraft({ ...draft, theme: "dark" })}
className={`theme-option ${draft.theme === "dark" ? "active" : ""}`}
>
<Moon size={16} />
<span>Dark</span>
</button>
<button
type="button"
onClick={() => setDraft({ ...draft, theme: "system" })}
className={`theme-option ${draft.theme === "system" ? "active" : ""}`}
>
<Monitor size={16} />
<span>System</span>
</button>
</div>
) : (
<p className="settings-value">
{saved.theme === "light" && "Light"}
{saved.theme === "dark" && "Dark"}
{saved.theme === "system" && "System"}
</p>
)}
</div>
</div>
</div>

<div className="settings-card">
<div className="settings-card-header">
<div className="settings-card-icon">
Expand Down
3 changes: 3 additions & 0 deletions src/components/ClientProviders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import { ReactNode } from "react";
import { AuthProvider } from "@/contexts";
import { WalletProvider } from "@/contexts";
import { I18nProvider } from "@/contexts/I18nContext";
import { ThemeProvider } from "@/contexts/ThemeProvider";
import { ToastProvider } from "@/components/notifications/ToastProvider";
import { CookieConsentProvider } from "@/contexts/CookieConsentContext";
import { CookieBanner, PrivacyModal } from "@/components/cookie";

export function ClientProviders({ children }: { children: ReactNode }) {
return (
<ThemeProvider>
<I18nProvider>
<AuthProvider>
<WalletProvider>
Expand All @@ -22,5 +24,6 @@ export function ClientProviders({ children }: { children: ReactNode }) {
</WalletProvider>
</AuthProvider>
</I18nProvider>
</ThemeProvider>
);
}
29 changes: 13 additions & 16 deletions src/components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,29 @@
"use client";

import { useEffect, useState } from "react";
import { useTheme } from "@/contexts/ThemeProvider";

export function ThemeToggle() {
const [isDark, setIsDark] = useState(true);

useEffect(() => {
const stored = localStorage.getItem("nw-theme");
const dark = stored !== "light";
setIsDark(dark);
document.documentElement.classList.toggle("dark", dark);
}, []);
const { theme, resolvedTheme, setTheme } = useTheme();

function toggle() {
const next = !isDark;
setIsDark(next);
document.documentElement.classList.toggle("dark", next);
localStorage.setItem("nw-theme", next ? "dark" : "light");
if (theme === "light") {
setTheme("dark");
} else if (theme === "dark") {
setTheme("light");
} else {
// If system, toggle to opposite of current resolved theme
setTheme(resolvedTheme === "light" ? "dark" : "light");
}
}

return (
<button
onClick={toggle}
className="rounded-lg p-2 text-slate-400 hover:bg-white/5 hover:text-white transition-colors"
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
title={isDark ? "Light mode" : "Dark mode"}
aria-label={resolvedTheme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
title={resolvedTheme === "dark" ? "Light mode" : "Dark mode"}
>
{isDark ? (
{resolvedTheme === "dark" ? (
/* sun icon */
<svg
xmlns="http://www.w3.org/2000/svg"
Expand Down
101 changes: 101 additions & 0 deletions src/contexts/ThemeProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"use client";

import { createContext, useCallback, useContext, useEffect, useState, ReactNode, useMemo } from "react";

export type ThemeMode = "light" | "dark" | "system";

interface ThemeContextType {
theme: ThemeMode;
resolvedTheme: "light" | "dark";
setTheme: (theme: ThemeMode) => void;
}

const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

const STORAGE_KEY = "nw-theme";

function getStoredTheme(): ThemeMode {
if (typeof globalThis.window === "undefined") return "system";
try {
const stored = globalThis.window.localStorage.getItem(STORAGE_KEY);
if (stored === "light" || stored === "dark" || stored === "system") {
return stored;
}
} catch {}
return "system";
}

function getSystemTheme(): "light" | "dark" {
if (typeof globalThis.window === "undefined") return "light";
return globalThis.window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}

function resolveTheme(theme: ThemeMode): "light" | "dark" {
if (theme === "system") {
return getSystemTheme();
}
return theme;
}

function applyTheme(resolvedTheme: "light" | "dark") {
if (typeof globalThis.window === "undefined") return;
const root = globalThis.window.document.documentElement;
root.classList.remove("light", "dark");
root.classList.add(resolvedTheme);
}

interface ThemeProviderProps {
readonly children: ReactNode;
}

export function ThemeProvider({ children }: ThemeProviderProps) {
const [theme, setThemeState] = useState<ThemeMode>(getStoredTheme);
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">(() => resolveTheme(theme));

const setTheme = useCallback((newTheme: ThemeMode) => {
setThemeState(newTheme);
globalThis.window?.localStorage.setItem(STORAGE_KEY, newTheme);
const resolved = resolveTheme(newTheme);
setResolvedTheme(resolved);
applyTheme(resolved);
}, []);

useEffect(() => {
// Apply initial theme immediately to prevent flash
applyTheme(resolvedTheme);

// Listen for system theme changes if using system preference
if (theme === "system") {
const mediaQuery = globalThis.window?.matchMedia("(prefers-color-scheme: dark)");
if (!mediaQuery) return;

const handleChange = () => {
const newResolved = getSystemTheme();
setResolvedTheme(newResolved);
applyTheme(newResolved);
};
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}
}, [theme, resolvedTheme]);

const contextValue = useMemo(() => ({
theme,
resolvedTheme,
setTheme,
}), [theme, resolvedTheme, setTheme]);

return (
<ThemeContext.Provider value={contextValue}>
{children}
</ThemeContext.Provider>
);
}

export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}
Loading