feat(theme): implement full dark and light mode switching with ThemeContext and persistent storage

This commit is contained in:
Gabriel Menezes 2026-10-10 23:08:21 -04:00
parent d1b2ac4eef
commit eeccd176a1
6 changed files with 231 additions and 40 deletions

View file

@ -1,4 +1,5 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { ThemeProvider } from './contexts/ThemeContext'
import { AuthProvider } from './contexts/AuthContext'
import { TimerProvider } from './contexts/TimerContext'
import { LandingPage } from './pages/LandingPage'
@ -18,11 +19,12 @@ import { ProfilePage } from './pages/ProfilePage'
export function App() {
return (
<BrowserRouter>
<AuthProvider>
<TimerProvider>
<Routes>
{/* Rotas Públicas */}
<Route path="/" element={<LandingPage />} />
<ThemeProvider>
<AuthProvider>
<TimerProvider>
<Routes>
{/* Rotas Públicas */}
<Route path="/" element={<LandingPage />} />
<Route path="/login" element={<AuthPage />} />
<Route path="/register" element={<AuthPage />} />
@ -49,8 +51,9 @@ export function App() {
{/* Fallback */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</TimerProvider>
</AuthProvider>
</TimerProvider>
</AuthProvider>
</ThemeProvider>
</BrowserRouter>
)
}

View file

@ -1,8 +1,10 @@
import { Link } from "react-router-dom"
import { Button, Chip, ChipLabel } from "@heroui/react"
import { ArrowRight } from "lucide-react"
import { ArrowRight, Sun, Moon } from "lucide-react"
import { useTheme } from "../../contexts/ThemeContext"
export const Navbar = () => {
const { theme, toggleTheme } = useTheme()
return (
<header className="sticky top-0 z-50 backdrop-blur-md bg-surface/85 border-b border-border-soft transition-all duration-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-20 flex items-center justify-between">
@ -62,6 +64,20 @@ export const Navbar = () => {
{/* Actions */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={toggleTheme}
title={theme === 'dark' ? 'Mudar para tema claro' : 'Mudar para tema escuro'}
className="p-2 rounded-xl border border-border bg-surface text-muted hover:text-foreground hover:bg-surface-raised transition-colors shadow-xs"
aria-label="Alternar tema"
>
{theme === 'dark' ? (
<Sun className="w-4 h-4 text-amber-400" />
) : (
<Moon className="w-4 h-4 text-brand" />
)}
</button>
<Link
to="/login"
className="text-sm font-semibold text-text-main hover:text-brand px-3 py-2 transition-colors"

View file

@ -17,11 +17,14 @@ import {
PlusCircle,
Menu as MenuIcon,
Check,
Square,
Sun,
Moon,
} from 'lucide-react'
import { useAuth } from '../../contexts/AuthContext'
import { useTheme } from '../../contexts/ThemeContext'
import { useTimer } from '../../contexts/TimerContext'
import { formatSecondsToTimer } from '../../lib/timeUtils'
import { Square } from 'lucide-react'
interface HeaderProps {
onToggleMobileSidebar: () => void
@ -29,6 +32,7 @@ interface HeaderProps {
export const Header = ({ onToggleMobileSidebar }: HeaderProps) => {
const { user, activeOrg, setActiveOrg, logout } = useAuth()
const { theme, toggleTheme } = useTheme()
const { activeTask, activeSeconds, stopTimer } = useTimer()
const [showOrgDropdown, setShowOrgDropdown] = useState(false)
const [showUserDropdown, setShowUserDropdown] = useState(false)
@ -206,6 +210,21 @@ export const Header = ({ onToggleMobileSidebar }: HeaderProps) => {
<span className="hidden sm:inline">Tarefas</span>
</Button>
{/* Theme Toggle Button */}
<button
type="button"
onClick={toggleTheme}
title={theme === 'dark' ? 'Mudar para modo claro' : 'Mudar para modo escuro'}
className="p-2 rounded-xl border border-border bg-surface text-muted hover:text-foreground hover:bg-surface-raised transition-colors shadow-xs flex items-center justify-center"
aria-label="Alternar tema claro e escuro"
>
{theme === 'dark' ? (
<Sun className="w-4 h-4 text-amber-400 hover:rotate-45 transition-transform" />
) : (
<Moon className="w-4 h-4 text-brand hover:-rotate-12 transition-transform" />
)}
</button>
{/* User Profile Menu Dropdown */}
<div ref={userRef} className="relative">
<button
@ -270,6 +289,24 @@ export const Header = ({ onToggleMobileSidebar }: HeaderProps) => {
<Settings className="w-4 h-4 text-brand" />
Configurações do Workspace
</Link>
<button
type="button"
onClick={toggleTheme}
className="w-full flex items-center justify-between p-2 rounded-xl text-xs font-semibold text-text-muted hover:text-text-main hover:bg-surface-muted transition-colors text-left"
>
<div className="flex items-center gap-2.5">
{theme === 'dark' ? (
<Sun className="w-4 h-4 text-amber-400" />
) : (
<Moon className="w-4 h-4 text-brand" />
)}
<span>Tema {theme === 'dark' ? 'Claro' : 'Escuro'}</span>
</div>
<span className="text-[10px] uppercase font-bold text-muted bg-surface-raised px-1.5 py-0.5 rounded-md border border-border">
{theme}
</span>
</button>
</div>
<div className="border-t border-border-soft pt-1 mt-1">

View file

@ -9,9 +9,12 @@ import {
Settings,
X,
Building2,
Sun,
Moon,
} from 'lucide-react'
import { Chip, ChipLabel } from '@heroui/react'
import { useAuth } from '../../contexts/AuthContext'
import { useTheme } from '../../contexts/ThemeContext'
interface SidebarProps {
isMobileOpen: boolean
@ -20,6 +23,7 @@ interface SidebarProps {
export const Sidebar = ({ isMobileOpen, onCloseMobile }: SidebarProps) => {
const { activeOrg } = useAuth()
const { theme, toggleTheme } = useTheme()
const navItems = [
{
@ -111,18 +115,34 @@ export const Sidebar = ({ isMobileOpen, onCloseMobile }: SidebarProps) => {
{/* Bottom Workspace Card Info */}
<div className="p-4 border-t border-border-soft">
<div className="p-3 rounded-2xl bg-surface-muted/70 border border-border-soft flex items-center gap-3">
<div className="w-8 h-8 rounded-xl bg-brand-soft text-brand flex items-center justify-center font-bold text-xs shrink-0">
<Building2 className="w-4 h-4 text-brand" />
</div>
<div className="truncate flex-1 min-w-0">
<p className="text-xs font-bold text-text-main truncate">
{activeOrg?.name || 'Workspace Padrão'}
</p>
<span className="text-[10px] font-semibold text-text-muted uppercase tracking-wider">
{activeOrg?.roles[0] || 'Ativo'}
</span>
<div className="p-3 rounded-2xl bg-surface-muted/70 border border-border-soft flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5 truncate flex-1 min-w-0">
<div className="w-8 h-8 rounded-xl bg-brand-soft text-brand flex items-center justify-center font-bold text-xs shrink-0">
<Building2 className="w-4 h-4 text-brand" />
</div>
<div className="truncate flex-1 min-w-0">
<p className="text-xs font-bold text-text-main truncate">
{activeOrg?.name || 'Workspace Padrão'}
</p>
<span className="text-[10px] font-semibold text-text-muted uppercase tracking-wider">
{activeOrg?.roles[0] || 'Ativo'}
</span>
</div>
</div>
<button
type="button"
onClick={toggleTheme}
title={theme === 'dark' ? 'Mudar para tema claro' : 'Mudar para tema escuro'}
className="p-1.5 rounded-lg border border-border-soft bg-surface text-muted hover:text-foreground hover:bg-surface-raised transition-colors shrink-0"
aria-label="Alternar tema"
>
{theme === 'dark' ? (
<Sun className="w-3.5 h-3.5 text-amber-400" />
) : (
<Moon className="w-3.5 h-3.5 text-brand" />
)}
</button>
</div>
</div>
</div>

View file

@ -0,0 +1,56 @@
import React, { createContext, useContext, useState, useEffect } from 'react'
type Theme = 'light' | 'dark'
interface ThemeContextType {
theme: Theme
toggleTheme: () => void
setTheme: (theme: Theme) => void
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [theme, setThemeState] = useState<Theme>(() => {
const saved = localStorage.getItem('bee_theme') as Theme | null
if (saved === 'light' || saved === 'dark') {
return saved
}
// Default to dark theme for modern developer dashboard experience
return 'dark'
})
useEffect(() => {
const root = document.documentElement
if (theme === 'dark') {
root.classList.add('dark')
root.classList.remove('light')
} else {
root.classList.remove('dark')
root.classList.add('light')
}
localStorage.setItem('bee_theme', theme)
}, [theme])
const toggleTheme = () => {
setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark'))
}
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme)
}
return (
<ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
export const useTheme = (): ThemeContextType => {
const ctx = useContext(ThemeContext)
if (!ctx) {
throw new Error('useTheme must be used within a ThemeProvider')
}
return ctx
}

View file

@ -1,37 +1,85 @@
@import "tailwindcss";
@config "../tailwind.config.js";
:root {
--app-bg: #f5f1e8;
--app-bg-strong: #ebe4d7;
--surface: #ffffff;
--surface-muted: #f6efe2;
--surface-raised: #fbf8f1;
--surface-sunken: #ece5d6;
--border: #e2d7c3;
--border-soft: #e7dcc9;
--border-strong: #cdbba0;
--text-main: #1c1917;
--text-muted: #78716c;
--foreground: #1c1917;
--muted: #78716c;
--brand-soft: #ccfbf1;
--accent-soft: #ffedd5;
--shadow-panel: 0 22px 60px rgba(79, 62, 35, 0.08), 0 6px 18px rgba(79, 62, 35, 0.06);
color-scheme: light;
}
.dark {
--app-bg: #090d16;
--app-bg-strong: #0f141f;
--surface: #111726;
--surface-muted: #171e30;
--surface-raised: #182033;
--surface-sunken: #0c111c;
--border: #1e293b;
--border-soft: #1e293b;
--border-strong: #334155;
--text-main: #f8fafc;
--text-muted: #94a3b8;
--foreground: #f8fafc;
--muted: #94a3b8;
--brand-soft: rgba(15, 118, 110, 0.2);
--accent-soft: rgba(217, 119, 6, 0.2);
--shadow-panel: 0 22px 60px rgba(0, 0, 0, 0.4), 0 6px 18px rgba(0, 0, 0, 0.25);
color-scheme: dark;
}
@theme {
--font-sans: 'Plus Jakarta Sans', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
--color-app-bg: #f5f1e8;
--color-app-bg-strong: #ebe4d7;
--color-surface: #fffdf8;
--color-surface-muted: #f6efe2;
--color-border-soft: #e7dcc9;
--color-border-strong: #cdbba0;
--color-text-main: #1f2937;
--color-text-muted: #6b7280;
--color-app-bg: var(--app-bg);
--color-app-bg-strong: var(--app-bg-strong);
--color-surface: var(--surface);
--color-surface-muted: var(--surface-muted);
--color-surface-raised: var(--surface-raised);
--color-surface-sunken: var(--surface-sunken);
--color-border: var(--border);
--color-border-soft: var(--border-soft);
--color-border-strong: var(--border-strong);
--color-text-main: var(--text-main);
--color-text-muted: var(--text-muted);
--color-foreground: var(--foreground);
--color-muted: var(--muted);
--color-brand: #0f766e;
--color-brand-hover: #115e59;
--color-brand-strong: #115e59;
--color-brand-soft: #ccfbf1;
--color-brand-soft: var(--brand-soft);
--color-accent: #d97706;
--color-accent-soft: #ffedd5;
--color-accent-hover: #b45309;
--color-accent-soft: var(--accent-soft);
--color-success: #10b981;
--color-success-soft: #ecfdf5;
--color-success-soft: rgba(16, 185, 129, 0.15);
--color-warning: #f59e0b;
--color-warning-soft: #fffbeb;
--color-warning-soft: rgba(245, 158, 11, 0.15);
--color-danger: #ef4444;
--color-danger-soft: #fef2f2;
--color-danger-soft: rgba(239, 68, 68, 0.15);
--shadow-panel:
0 22px 60px rgba(79, 62, 35, 0.08), 0 6px 18px rgba(79, 62, 35, 0.06);
--shadow-panel: var(--shadow-panel);
}
:root {
color: var(--color-text-main);
color: var(--color-foreground);
background-color: var(--color-app-bg);
font-family: var(--font-sans);
font-synthesis: none;
@ -52,8 +100,19 @@ body,
body {
margin: 0;
background:
radial-gradient(circle at top left, rgba(217, 119, 6, 0.12), transparent 28%),
radial-gradient(circle at top right, rgba(15, 118, 110, 0.14), transparent 24%),
linear-gradient(180deg, var(--color-app-bg) 0%, #f8f5ef 100%);
transition: background-color 0.2s ease, color 0.2s ease;
}
html.dark body {
background:
radial-gradient(circle at top left, rgba(217, 119, 6, 0.08), transparent 28%),
radial-gradient(circle at top right, rgba(15, 118, 110, 0.12), transparent 24%),
linear-gradient(180deg, var(--app-bg) 0%, #060910 100%);
}
html:not(.dark) body {
background:
radial-gradient(circle at top left, rgba(217, 119, 6, 0.08), transparent 28%),
radial-gradient(circle at top right, rgba(15, 118, 110, 0.08), transparent 24%),
linear-gradient(180deg, var(--app-bg) 0%, #faf7f0 100%);
}