feat(hackathon): add dark mode support with theme toggle

- Add ThemeProvider with system/light/dark mode support and localStorage persistence
- Add ThemeToggle component for pages without sidebar (landing, login, signup)
- Enable class-based dark mode in Tailwind CSS v4 via @custom-variant
- Add dark mode classes to landing page, login, signup, and all dashboard pages
- Keep IMPHNEN logo blue in dark mode, invert Kolosal logo

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Maulana Sodiqin
2025-11-26 15:25:23 +07:00
co-authored by Claude
parent c9f5b9e4a5
commit 1839f4cab0
18 changed files with 594 additions and 342 deletions
@@ -70,15 +70,15 @@ export const CitySelect: FC<CitySelectProps> = ({
}}
onClick={handleInputClick}
placeholder={placeholder}
className={`w-full h-[42px] px-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
error ? 'border-red-500' : 'border-gray-300'
className={`w-full h-[42px] px-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-neutral-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-neutral-500 ${
error ? 'border-red-500' : 'border-gray-300 dark:border-neutral-600'
}`}
/>
{value && !isOpen && (
<button
type="button"
onClick={handleClearSelection}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-neutral-500 hover:text-gray-600 dark:hover:text-neutral-300"
>
</button>
@@ -88,15 +88,15 @@ export const CitySelect: FC<CitySelectProps> = ({
{error && <p className="text-sm text-red-500 mt-1">{error}</p>}
{isOpen && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-60 overflow-y-auto">
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-neutral-800 border border-gray-300 dark:border-neutral-600 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{filteredCities.length > 0 ? (
<ul className="py-1">
{filteredCities.map((city) => (
<li
key={city}
onClick={() => handleSelectCity(city)}
className={`px-3 py-2 cursor-pointer hover:bg-blue-50 ${
value === city ? 'bg-blue-100 text-blue-700' : ''
className={`px-3 py-2 cursor-pointer hover:bg-blue-50 dark:hover:bg-blue-900/30 ${
value === city ? 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-400' : 'text-gray-900 dark:text-neutral-200'
}`}
>
{city}
@@ -104,7 +104,7 @@ export const CitySelect: FC<CitySelectProps> = ({
))}
</ul>
) : (
<div className="px-3 py-2 text-gray-500 text-sm">
<div className="px-3 py-2 text-gray-500 dark:text-neutral-400 text-sm">
No cities found
</div>
)}
+59 -16
View File
@@ -1,8 +1,9 @@
import { FC, useState, useEffect } from 'react';
import { FC, useEffect } from 'react';
import { Link, useLocation } from 'react-router';
import { useMyTeams, useAuthStore, supabase } from '@imphnen-frontend-service/service';
import { useNavigate } from 'react-router';
import { toast } from 'sonner';
import { useTheme } from './theme-provider';
interface NavItem {
name: string;
@@ -21,6 +22,7 @@ export const Sidebar: FC<SidebarProps> = ({ isOpen = true, onClose }) => {
const navigate = useNavigate();
const { session, clearSession } = useAuthStore();
const { data: teamsData } = useMyTeams();
const { theme, setTheme, resolvedTheme } = useTheme();
const user = session?.user;
const myTeams = teamsData?.data || [];
@@ -91,17 +93,51 @@ export const Sidebar: FC<SidebarProps> = ({ isOpen = true, onClose }) => {
},
];
const cycleTheme = () => {
if (theme === 'light') setTheme('dark');
else if (theme === 'dark') setTheme('system');
else setTheme('light');
};
const getThemeIcon = () => {
if (theme === 'system') {
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
);
}
if (resolvedTheme === 'dark') {
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
);
}
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
);
};
const getThemeLabel = () => {
if (theme === 'system') return 'System';
if (theme === 'dark') return 'Dark';
return 'Light';
};
const sidebarContent = (
<div className="w-64 bg-white border-r min-h-screen flex flex-col">
<div className="w-64 bg-white dark:bg-neutral-900 border-r dark:border-neutral-700 min-h-screen flex flex-col">
{/* Logo / Brand with Close Button */}
<div className="p-6 border-b flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-900">🏆 Hackathon</h1>
<div className="p-6 border-b dark:border-neutral-700 flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Hackathon</h1>
{onClose && (
<button
onClick={onClose}
className="lg:hidden p-2 rounded-lg hover:bg-gray-100 transition-colors"
className="lg:hidden p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors"
>
<svg className="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="w-5 h-5 text-gray-500 dark:text-neutral-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
@@ -109,7 +145,7 @@ export const Sidebar: FC<SidebarProps> = ({ isOpen = true, onClose }) => {
</div>
{/* User Info */}
<div className="p-4 border-b">
<div className="p-4 border-b dark:border-neutral-700">
<div className="flex items-center space-x-3">
{user?.avatar ? (
<img
@@ -118,15 +154,15 @@ export const Sidebar: FC<SidebarProps> = ({ isOpen = true, onClose }) => {
className="w-10 h-10 rounded-full object-cover"
/>
) : (
<div className="w-10 h-10 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-500 text-lg">👤</span>
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-neutral-700 flex items-center justify-center">
<span className="text-gray-500 dark:text-neutral-400 text-lg">👤</span>
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{user?.fullname || user?.email?.split('@')[0] || 'User'}
</p>
<p className="text-xs text-gray-500 truncate">{user?.email}</p>
<p className="text-xs text-gray-500 dark:text-neutral-400 truncate">{user?.email}</p>
</div>
</div>
</div>
@@ -144,8 +180,8 @@ export const Sidebar: FC<SidebarProps> = ({ isOpen = true, onClose }) => {
to={item.path}
className={`flex items-center space-x-3 px-4 py-3 rounded-lg transition-colors ${
isActive
? 'bg-blue-50 text-blue-700 font-medium'
: 'text-gray-700 hover:bg-gray-100'
? 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 font-medium'
: 'text-gray-700 dark:text-neutral-300 hover:bg-gray-100 dark:hover:bg-neutral-800'
}`}
>
{item.icon}
@@ -157,11 +193,18 @@ export const Sidebar: FC<SidebarProps> = ({ isOpen = true, onClose }) => {
</ul>
</nav>
{/* Logout Button */}
<div className="p-4 border-t">
{/* Theme Toggle & Logout */}
<div className="p-4 border-t dark:border-neutral-700 space-y-2">
<button
onClick={cycleTheme}
className="flex items-center space-x-3 px-4 py-3 w-full rounded-lg text-gray-700 dark:text-neutral-300 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors"
>
{getThemeIcon()}
<span>{getThemeLabel()}</span>
</button>
<button
onClick={handleLogout}
className="flex items-center space-x-3 px-4 py-3 w-full rounded-lg text-red-600 hover:bg-red-50 transition-colors"
className="flex items-center space-x-3 px-4 py-3 w-full rounded-lg text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
@@ -0,0 +1,100 @@
import { createContext, useContext, useEffect, useState, FC, ReactNode } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
resolvedTheme: 'light' | 'dark';
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
const STORAGE_KEY = 'hackathon-theme';
function getSystemTheme(): 'light' | 'dark' {
if (typeof window !== 'undefined') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return 'light';
}
interface ThemeProviderProps {
children: ReactNode;
defaultTheme?: Theme;
}
export const ThemeProvider: FC<ThemeProviderProps> = ({ children, defaultTheme = 'system' }) => {
const [theme, setThemeState] = useState<Theme>(() => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null;
return stored || defaultTheme;
}
return defaultTheme;
});
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(() => {
if (theme === 'system') {
return getSystemTheme();
}
return theme;
});
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme);
localStorage.setItem(STORAGE_KEY, newTheme);
};
useEffect(() => {
const root = document.documentElement;
// Calculate the resolved theme
const resolved = theme === 'system' ? getSystemTheme() : theme;
setResolvedTheme(resolved);
// Apply dark class to root
if (resolved === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
}, [theme]);
// Listen for system theme changes
useEffect(() => {
if (theme !== 'system') return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
const newTheme = e.matches ? 'dark' : 'light';
setResolvedTheme(newTheme);
const root = document.documentElement;
if (newTheme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
{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;
}
export default ThemeProvider;
@@ -0,0 +1,63 @@
import { FC } from 'react';
import { useTheme } from './theme-provider';
interface ThemeToggleProps {
className?: string;
}
// Sun icon for light mode
const SunIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
);
// Moon icon for dark mode
const MoonIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
);
// System icon (monitor)
const SystemIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
);
export const ThemeToggle: FC<ThemeToggleProps> = ({ className = '' }) => {
const { theme, setTheme, resolvedTheme } = useTheme();
const cycleTheme = () => {
if (theme === 'light') setTheme('dark');
else if (theme === 'dark') setTheme('system');
else setTheme('light');
};
const getThemeLabel = () => {
if (theme === 'system') return 'System';
return theme === 'dark' ? 'Dark' : 'Light';
};
const renderIcon = () => {
if (theme === 'system') {
return <SystemIcon />;
}
return resolvedTheme === 'dark' ? <MoonIcon /> : <SunIcon />;
};
return (
<button
type="button"
onClick={cycleTheme}
className={`p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors cursor-pointer text-gray-600 dark:text-neutral-400 ${className}`}
title={`Theme: ${getThemeLabel()}`}
aria-label={`Current theme: ${getThemeLabel()}. Click to change.`}
>
{renderIcon()}
</button>
);
};
export default ThemeToggle;