Skip to content

Commit ec20e03

Browse files
authored
Add dark mode support across UI (#33)
* Add dark mode support across UI Introduces DarkModeProvider and DarkModeToggle components for theme management. Updates all major UI components and pages to support dark mode styling using Tailwind CSS dark variants, improving accessibility and user experience for users preferring dark themes. * Improve dark mode initialization and modal UI (#32) Adds a script to layout.tsx to set dark mode before hydration, preventing UI flicker. Refactors DarkModeProvider to initialize theme and dark state after mount. Updates ScriptDetailModal for improved readability, consistent styling, and better handling of script status, install methods, and notes.
1 parent b77554a commit ec20e03

File tree

13 files changed

+678
-296
lines changed

13 files changed

+678
-296
lines changed
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
'use client';
2+
3+
import { createContext, useContext, useEffect, useState } from 'react';
4+
5+
type Theme = 'light' | 'dark' | 'system';
6+
7+
interface DarkModeContextType {
8+
theme: Theme;
9+
setTheme: (theme: Theme) => void;
10+
isDark: boolean;
11+
}
12+
13+
const DarkModeContext = createContext<DarkModeContextType | undefined>(undefined);
14+
15+
export function DarkModeProvider({ children }: { children: React.ReactNode }) {
16+
const [theme, setThemeState] = useState<Theme>('system');
17+
const [isDark, setIsDark] = useState(false);
18+
const [mounted, setMounted] = useState(false);
19+
20+
// Initialize theme from localStorage after mount
21+
useEffect(() => {
22+
setMounted(true);
23+
const stored = localStorage.getItem('theme') as Theme;
24+
if (stored && ['light', 'dark', 'system'].includes(stored)) {
25+
setThemeState(stored);
26+
}
27+
28+
// Set initial isDark state based on current DOM state
29+
const currentlyDark = document.documentElement.classList.contains('dark');
30+
setIsDark(currentlyDark);
31+
}, []);
32+
33+
// Update dark mode state and DOM when theme changes
34+
useEffect(() => {
35+
if (!mounted) return;
36+
37+
const updateDarkMode = () => {
38+
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
39+
const shouldBeDark = theme === 'dark' || (theme === 'system' && systemDark);
40+
41+
setIsDark(shouldBeDark);
42+
43+
// Apply to document
44+
if (shouldBeDark) {
45+
document.documentElement.classList.add('dark');
46+
} else {
47+
document.documentElement.classList.remove('dark');
48+
}
49+
};
50+
51+
updateDarkMode();
52+
53+
// Listen for system theme changes
54+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
55+
const handleChange = () => {
56+
if (theme === 'system') {
57+
updateDarkMode();
58+
}
59+
};
60+
61+
mediaQuery.addEventListener('change', handleChange);
62+
return () => mediaQuery.removeEventListener('change', handleChange);
63+
}, [theme, mounted]);
64+
65+
const setTheme = (newTheme: Theme) => {
66+
setThemeState(newTheme);
67+
localStorage.setItem('theme', newTheme);
68+
};
69+
70+
return (
71+
<DarkModeContext.Provider value={{ theme, setTheme, isDark }}>
72+
{children}
73+
</DarkModeContext.Provider>
74+
);
75+
}
76+
77+
export function useDarkMode() {
78+
const context = useContext(DarkModeContext);
79+
if (context === undefined) {
80+
throw new Error('useDarkMode must be used within a DarkModeProvider');
81+
}
82+
return context;
83+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
'use client';
2+
3+
import { useDarkMode } from './DarkModeProvider';
4+
5+
export function DarkModeToggle() {
6+
const { theme, setTheme, isDark } = useDarkMode();
7+
8+
const toggleTheme = () => {
9+
if (theme === 'light') {
10+
setTheme('dark');
11+
} else if (theme === 'dark') {
12+
setTheme('system');
13+
} else {
14+
setTheme('light');
15+
}
16+
};
17+
18+
const getIcon = () => {
19+
if (theme === 'light') {
20+
return (
21+
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
22+
<path fillRule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clipRule="evenodd" />
23+
</svg>
24+
);
25+
} else if (theme === 'dark') {
26+
return (
27+
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
28+
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
29+
</svg>
30+
);
31+
} else {
32+
// System theme icon
33+
return (
34+
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
35+
<path fillRule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2.22l.123.489.804.804A1 1 0 0113 18H7a1 1 0 01-.707-1.707l.804-.804L7.22 15H5a2 2 0 01-2-2V5zm5.771 7l.159-1.591A3.001 3.001 0 0112 8a3 3 0 01-3.229 2.409L8.771 12z" clipRule="evenodd" />
36+
</svg>
37+
);
38+
}
39+
};
40+
41+
const getLabel = () => {
42+
if (theme === 'light') return 'Light mode';
43+
if (theme === 'dark') return 'Dark mode';
44+
return 'System theme';
45+
};
46+
47+
return (
48+
<button
49+
onClick={toggleTheme}
50+
className={`
51+
flex items-center justify-center
52+
w-10 h-10 rounded-lg
53+
transition-all duration-200
54+
hover:scale-105 active:scale-95
55+
${isDark
56+
? 'bg-gray-800 text-yellow-400 hover:bg-gray-700 border border-gray-600'
57+
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 border border-gray-300'
58+
}
59+
`}
60+
title={getLabel()}
61+
aria-label={getLabel()}
62+
>
63+
{getIcon()}
64+
</button>
65+
);
66+
}

src/app/_components/InstalledScriptsTab.tsx

Lines changed: 28 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ export function InstalledScriptsTab() {
119119
case 'in_progress':
120120
return `${baseClasses} bg-yellow-100 text-yellow-800`;
121121
default:
122-
return `${baseClasses} bg-gray-100 text-gray-800`;
122+
return `${baseClasses} bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200`;
123123
}
124124
};
125125

@@ -131,14 +131,14 @@ export function InstalledScriptsTab() {
131131
case 'ssh':
132132
return `${baseClasses} bg-purple-100 text-purple-800`;
133133
default:
134-
return `${baseClasses} bg-gray-100 text-gray-800`;
134+
return `${baseClasses} bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200`;
135135
}
136136
};
137137

138138
if (isLoading) {
139139
return (
140140
<div className="flex items-center justify-center h-64">
141-
<div className="text-gray-500">Loading installed scripts...</div>
141+
<div className="text-gray-500 dark:text-gray-400">Loading installed scripts...</div>
142142
</div>
143143
);
144144
}
@@ -160,8 +160,8 @@ export function InstalledScriptsTab() {
160160
)}
161161

162162
{/* Header with Stats */}
163-
<div className="bg-white rounded-lg shadow p-6">
164-
<h2 className="text-2xl font-bold text-gray-900 mb-4">Installed Scripts</h2>
163+
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
164+
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-4">Installed Scripts</h2>
165165

166166
{stats && (
167167
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
@@ -192,14 +192,14 @@ export function InstalledScriptsTab() {
192192
placeholder="Search scripts, container IDs, or servers..."
193193
value={searchTerm}
194194
onChange={(e) => setSearchTerm(e.target.value)}
195-
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
195+
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
196196
/>
197197
</div>
198198

199199
<select
200200
value={statusFilter}
201201
onChange={(e) => setStatusFilter(e.target.value as 'all' | 'success' | 'failed' | 'in_progress')}
202-
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
202+
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
203203
>
204204
<option value="all">All Status</option>
205205
<option value="success">Success</option>
@@ -210,7 +210,7 @@ export function InstalledScriptsTab() {
210210
<select
211211
value={serverFilter}
212212
onChange={(e) => setServerFilter(e.target.value)}
213-
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
213+
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
214214
>
215215
<option value="all">All Servers</option>
216216
<option value="local">Local</option>
@@ -222,60 +222,57 @@ export function InstalledScriptsTab() {
222222
</div>
223223

224224
{/* Scripts Table */}
225-
<div className="bg-white rounded-lg shadow overflow-hidden">
225+
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
226226
{filteredScripts.length === 0 ? (
227-
<div className="text-center py-8 text-gray-500">
227+
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
228228
{scripts.length === 0 ? 'No installed scripts found.' : 'No scripts match your filters.'}
229229
</div>
230230
) : (
231231
<div className="overflow-x-auto">
232-
<table className="min-w-full divide-y divide-gray-200">
233-
<thead className="bg-gray-50">
232+
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
233+
<thead className="bg-gray-50 dark:bg-gray-700">
234234
<tr>
235-
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
235+
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
236236
Script Name
237237
</th>
238-
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
238+
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
239239
Container ID
240240
</th>
241-
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
241+
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
242242
Server
243243
</th>
244-
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
245-
Mode
246-
</th>
247-
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
244+
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
248245
Status
249246
</th>
250-
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
251-
Date
247+
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
248+
Installation Date
252249
</th>
253-
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
250+
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
254251
Actions
255252
</th>
256253
</tr>
257254
</thead>
258-
<tbody className="bg-white divide-y divide-gray-200">
255+
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
259256
{filteredScripts.map((script) => (
260257
<tr key={script.id} className="hover:bg-gray-50">
261258
<td className="px-6 py-4 whitespace-nowrap">
262-
<div className="text-sm font-medium text-gray-900">{script.script_name}</div>
263-
<div className="text-sm text-gray-500">{script.script_path}</div>
259+
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{script.script_name}</div>
260+
<div className="text-sm text-gray-500 dark:text-gray-400">{script.script_path}</div>
264261
</td>
265262
<td className="px-6 py-4 whitespace-nowrap">
266263
{script.container_id ? (
267-
<span className="text-sm font-mono text-gray-900">{String(script.container_id)}</span>
264+
<span className="text-sm font-mono text-gray-900 dark:text-gray-100">{String(script.container_id)}</span>
268265
) : (
269-
<span className="text-sm text-gray-400">-</span>
266+
<span className="text-sm text-gray-400 dark:text-gray-500">-</span>
270267
)}
271268
</td>
272269
<td className="px-6 py-4 whitespace-nowrap">
273270
{script.execution_mode === 'local' ? (
274-
<span className="text-sm text-gray-900">Local</span>
271+
<span className="text-sm text-gray-900 dark:text-gray-100">Local</span>
275272
) : (
276273
<div>
277-
<div className="text-sm font-medium text-gray-900">{script.server_name}</div>
278-
<div className="text-sm text-gray-500">{script.server_ip}</div>
274+
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{script.server_name}</div>
275+
<div className="text-sm text-gray-500 dark:text-gray-400">{script.server_ip}</div>
279276
</div>
280277
)}
281278
</td>
@@ -289,7 +286,7 @@ export function InstalledScriptsTab() {
289286
{String(script.status).replace('_', ' ').toUpperCase()}
290287
</span>
291288
</td>
292-
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
289+
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
293290
{formatDate(String(script.installation_date))}
294291
</td>
295292
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">

src/app/_components/ResyncButton.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ export function ResyncButton() {
4444
disabled={isResyncing}
4545
className={`flex items-center space-x-2 px-4 py-2 rounded-lg font-medium transition-colors ${
4646
isResyncing
47-
? 'bg-gray-400 text-white cursor-not-allowed'
48-
: 'bg-blue-600 text-white hover:bg-blue-700'
47+
? 'bg-gray-400 dark:bg-gray-600 text-white cursor-not-allowed'
48+
: 'bg-blue-600 dark:bg-blue-700 text-white hover:bg-blue-700 dark:hover:bg-blue-600'
4949
}`}
5050
>
5151
{isResyncing ? (
@@ -64,16 +64,16 @@ export function ResyncButton() {
6464
</button>
6565

6666
{lastSync && (
67-
<div className="text-sm text-gray-500">
67+
<div className="text-sm text-gray-500 dark:text-gray-400">
6868
Last sync: {lastSync.toLocaleTimeString()}
6969
</div>
7070
)}
7171

7272
{syncMessage && (
7373
<div className={`text-sm px-3 py-1 rounded-lg ${
7474
syncMessage.includes('Error') || syncMessage.includes('Failed')
75-
? 'bg-red-100 text-red-700'
76-
: 'bg-green-100 text-green-700'
75+
? 'bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300'
76+
: 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300'
7777
}`}>
7878
{syncMessage}
7979
</div>

0 commit comments

Comments
 (0)