feat(theme): implement full dark and light mode switching with ThemeContext and persistent storage
This commit is contained in:
parent
d1b2ac4eef
commit
eeccd176a1
6 changed files with 231 additions and 40 deletions
|
|
@ -1,4 +1,5 @@
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import { ThemeProvider } from './contexts/ThemeContext'
|
||||||
import { AuthProvider } from './contexts/AuthContext'
|
import { AuthProvider } from './contexts/AuthContext'
|
||||||
import { TimerProvider } from './contexts/TimerContext'
|
import { TimerProvider } from './contexts/TimerContext'
|
||||||
import { LandingPage } from './pages/LandingPage'
|
import { LandingPage } from './pages/LandingPage'
|
||||||
|
|
@ -18,11 +19,12 @@ import { ProfilePage } from './pages/ProfilePage'
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<ThemeProvider>
|
||||||
<TimerProvider>
|
<AuthProvider>
|
||||||
<Routes>
|
<TimerProvider>
|
||||||
{/* Rotas Públicas */}
|
<Routes>
|
||||||
<Route path="/" element={<LandingPage />} />
|
{/* Rotas Públicas */}
|
||||||
|
<Route path="/" element={<LandingPage />} />
|
||||||
<Route path="/login" element={<AuthPage />} />
|
<Route path="/login" element={<AuthPage />} />
|
||||||
<Route path="/register" element={<AuthPage />} />
|
<Route path="/register" element={<AuthPage />} />
|
||||||
|
|
||||||
|
|
@ -49,8 +51,9 @@ export function App() {
|
||||||
{/* Fallback */}
|
{/* Fallback */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</TimerProvider>
|
</TimerProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
import { Button, Chip, ChipLabel } from "@heroui/react"
|
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 = () => {
|
export const Navbar = () => {
|
||||||
|
const { theme, toggleTheme } = useTheme()
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 backdrop-blur-md bg-surface/85 border-b border-border-soft transition-all duration-200">
|
<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">
|
<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 */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-3">
|
<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
|
<Link
|
||||||
to="/login"
|
to="/login"
|
||||||
className="text-sm font-semibold text-text-main hover:text-brand px-3 py-2 transition-colors"
|
className="text-sm font-semibold text-text-main hover:text-brand px-3 py-2 transition-colors"
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,14 @@ import {
|
||||||
PlusCircle,
|
PlusCircle,
|
||||||
Menu as MenuIcon,
|
Menu as MenuIcon,
|
||||||
Check,
|
Check,
|
||||||
|
Square,
|
||||||
|
Sun,
|
||||||
|
Moon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAuth } from '../../contexts/AuthContext'
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
|
import { useTheme } from '../../contexts/ThemeContext'
|
||||||
import { useTimer } from '../../contexts/TimerContext'
|
import { useTimer } from '../../contexts/TimerContext'
|
||||||
import { formatSecondsToTimer } from '../../lib/timeUtils'
|
import { formatSecondsToTimer } from '../../lib/timeUtils'
|
||||||
import { Square } from 'lucide-react'
|
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
onToggleMobileSidebar: () => void
|
onToggleMobileSidebar: () => void
|
||||||
|
|
@ -29,6 +32,7 @@ interface HeaderProps {
|
||||||
|
|
||||||
export const Header = ({ onToggleMobileSidebar }: HeaderProps) => {
|
export const Header = ({ onToggleMobileSidebar }: HeaderProps) => {
|
||||||
const { user, activeOrg, setActiveOrg, logout } = useAuth()
|
const { user, activeOrg, setActiveOrg, logout } = useAuth()
|
||||||
|
const { theme, toggleTheme } = useTheme()
|
||||||
const { activeTask, activeSeconds, stopTimer } = useTimer()
|
const { activeTask, activeSeconds, stopTimer } = useTimer()
|
||||||
const [showOrgDropdown, setShowOrgDropdown] = useState(false)
|
const [showOrgDropdown, setShowOrgDropdown] = useState(false)
|
||||||
const [showUserDropdown, setShowUserDropdown] = useState(false)
|
const [showUserDropdown, setShowUserDropdown] = useState(false)
|
||||||
|
|
@ -206,6 +210,21 @@ export const Header = ({ onToggleMobileSidebar }: HeaderProps) => {
|
||||||
<span className="hidden sm:inline">Tarefas</span>
|
<span className="hidden sm:inline">Tarefas</span>
|
||||||
</Button>
|
</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 */}
|
{/* User Profile Menu Dropdown */}
|
||||||
<div ref={userRef} className="relative">
|
<div ref={userRef} className="relative">
|
||||||
<button
|
<button
|
||||||
|
|
@ -270,6 +289,24 @@ export const Header = ({ onToggleMobileSidebar }: HeaderProps) => {
|
||||||
<Settings className="w-4 h-4 text-brand" />
|
<Settings className="w-4 h-4 text-brand" />
|
||||||
Configurações do Workspace
|
Configurações do Workspace
|
||||||
</Link>
|
</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>
|
||||||
|
|
||||||
<div className="border-t border-border-soft pt-1 mt-1">
|
<div className="border-t border-border-soft pt-1 mt-1">
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,12 @@ import {
|
||||||
Settings,
|
Settings,
|
||||||
X,
|
X,
|
||||||
Building2,
|
Building2,
|
||||||
|
Sun,
|
||||||
|
Moon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Chip, ChipLabel } from '@heroui/react'
|
import { Chip, ChipLabel } from '@heroui/react'
|
||||||
import { useAuth } from '../../contexts/AuthContext'
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
|
import { useTheme } from '../../contexts/ThemeContext'
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
isMobileOpen: boolean
|
isMobileOpen: boolean
|
||||||
|
|
@ -20,6 +23,7 @@ interface SidebarProps {
|
||||||
|
|
||||||
export const Sidebar = ({ isMobileOpen, onCloseMobile }: SidebarProps) => {
|
export const Sidebar = ({ isMobileOpen, onCloseMobile }: SidebarProps) => {
|
||||||
const { activeOrg } = useAuth()
|
const { activeOrg } = useAuth()
|
||||||
|
const { theme, toggleTheme } = useTheme()
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{
|
{
|
||||||
|
|
@ -111,18 +115,34 @@ export const Sidebar = ({ isMobileOpen, onCloseMobile }: SidebarProps) => {
|
||||||
|
|
||||||
{/* Bottom Workspace Card Info */}
|
{/* Bottom Workspace Card Info */}
|
||||||
<div className="p-4 border-t border-border-soft">
|
<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="p-3 rounded-2xl bg-surface-muted/70 border border-border-soft flex items-center justify-between gap-2">
|
||||||
<div className="w-8 h-8 rounded-xl bg-brand-soft text-brand flex items-center justify-center font-bold text-xs shrink-0">
|
<div className="flex items-center gap-2.5 truncate flex-1 min-w-0">
|
||||||
<Building2 className="w-4 h-4 text-brand" />
|
<div className="w-8 h-8 rounded-xl bg-brand-soft text-brand flex items-center justify-center font-bold text-xs shrink-0">
|
||||||
</div>
|
<Building2 className="w-4 h-4 text-brand" />
|
||||||
<div className="truncate flex-1 min-w-0">
|
</div>
|
||||||
<p className="text-xs font-bold text-text-main truncate">
|
<div className="truncate flex-1 min-w-0">
|
||||||
{activeOrg?.name || 'Workspace Padrão'}
|
<p className="text-xs font-bold text-text-main truncate">
|
||||||
</p>
|
{activeOrg?.name || 'Workspace Padrão'}
|
||||||
<span className="text-[10px] font-semibold text-text-muted uppercase tracking-wider">
|
</p>
|
||||||
{activeOrg?.roles[0] || 'Ativo'}
|
<span className="text-[10px] font-semibold text-text-muted uppercase tracking-wider">
|
||||||
</span>
|
{activeOrg?.roles[0] || 'Ativo'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
56
new-frontend/src/contexts/ThemeContext.tsx
Normal file
56
new-frontend/src/contexts/ThemeContext.tsx
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -1,37 +1,85 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@config "../tailwind.config.js";
|
@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 {
|
@theme {
|
||||||
--font-sans: 'Plus Jakarta Sans', ui-sans-serif, system-ui, sans-serif;
|
--font-sans: 'Plus Jakarta Sans', ui-sans-serif, system-ui, sans-serif;
|
||||||
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||||
|
|
||||||
--color-app-bg: #f5f1e8;
|
--color-app-bg: var(--app-bg);
|
||||||
--color-app-bg-strong: #ebe4d7;
|
--color-app-bg-strong: var(--app-bg-strong);
|
||||||
--color-surface: #fffdf8;
|
--color-surface: var(--surface);
|
||||||
--color-surface-muted: #f6efe2;
|
--color-surface-muted: var(--surface-muted);
|
||||||
--color-border-soft: #e7dcc9;
|
--color-surface-raised: var(--surface-raised);
|
||||||
--color-border-strong: #cdbba0;
|
--color-surface-sunken: var(--surface-sunken);
|
||||||
--color-text-main: #1f2937;
|
--color-border: var(--border);
|
||||||
--color-text-muted: #6b7280;
|
--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: #0f766e;
|
||||||
|
--color-brand-hover: #115e59;
|
||||||
--color-brand-strong: #115e59;
|
--color-brand-strong: #115e59;
|
||||||
--color-brand-soft: #ccfbf1;
|
--color-brand-soft: var(--brand-soft);
|
||||||
|
|
||||||
--color-accent: #d97706;
|
--color-accent: #d97706;
|
||||||
--color-accent-soft: #ffedd5;
|
--color-accent-hover: #b45309;
|
||||||
|
--color-accent-soft: var(--accent-soft);
|
||||||
|
|
||||||
--color-success: #10b981;
|
--color-success: #10b981;
|
||||||
--color-success-soft: #ecfdf5;
|
--color-success-soft: rgba(16, 185, 129, 0.15);
|
||||||
--color-warning: #f59e0b;
|
--color-warning: #f59e0b;
|
||||||
--color-warning-soft: #fffbeb;
|
--color-warning-soft: rgba(245, 158, 11, 0.15);
|
||||||
--color-danger: #ef4444;
|
--color-danger: #ef4444;
|
||||||
--color-danger-soft: #fef2f2;
|
--color-danger-soft: rgba(239, 68, 68, 0.15);
|
||||||
|
|
||||||
--shadow-panel:
|
--shadow-panel: var(--shadow-panel);
|
||||||
0 22px 60px rgba(79, 62, 35, 0.08), 0 6px 18px rgba(79, 62, 35, 0.06);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color: var(--color-text-main);
|
color: var(--color-foreground);
|
||||||
background-color: var(--color-app-bg);
|
background-color: var(--color-app-bg);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
|
|
@ -52,8 +100,19 @@ body,
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background:
|
transition: background-color 0.2s ease, color 0.2s ease;
|
||||||
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%);
|
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%);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue