Skip to content

Commit 7edbec8

Browse files
committed
ft: themes
1 parent 53041b9 commit 7edbec8

8 files changed

Lines changed: 344 additions & 22 deletions

File tree

src/App.tsx

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -102,24 +102,30 @@ function AppRoutes() {
102102
</Routes>);
103103

104104
}
105-
export function App() {
106-
const basename = import.meta.env.BASE_URL || '/';
105+
function AppContent() {
106+
const { user } = useAuth();
107107

108108
return (
109-
<ErrorBoundary>
110-
<ThemeProvider>
111-
<LanguageProvider>
112-
<AuthProvider>
113-
<AdminSettingsProvider>
114-
<RateLimitProvider>
115-
<BrowserRouter basename={basename}>
116-
<AppRoutes />
117-
</BrowserRouter>
118-
</RateLimitProvider>
119-
</AdminSettingsProvider>
120-
</AuthProvider>
121-
</LanguageProvider>
122-
</ThemeProvider>
123-
</ErrorBoundary>);
109+
<ThemeProvider username={user?.username}>
110+
<AdminSettingsProvider>
111+
<RateLimitProvider>
112+
<BrowserRouter basename={import.meta.env.BASE_URL || '/'}>
113+
<AppRoutes />
114+
</BrowserRouter>
115+
</RateLimitProvider>
116+
</AdminSettingsProvider>
117+
</ThemeProvider>
118+
);
119+
}
124120

121+
export function App() {
122+
return (
123+
<ErrorBoundary>
124+
<LanguageProvider>
125+
<AuthProvider>
126+
<AppContent />
127+
</AuthProvider>
128+
</LanguageProvider>
129+
</ErrorBoundary>
130+
);
125131
}

src/components/ColorPicker.tsx

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// Color Picker Component for User Accent Color Selection
2+
3+
import React, { useState, useEffect } from 'react';
4+
import { Palette, Check } from 'lucide-react';
5+
import { DEFAULT_ACCENT_COLORS, updateUserAccentColor } from '../lib/userSettings';
6+
import { useTheme } from '../lib/theme';
7+
import { safeLogError } from '../lib/utils';
8+
9+
interface ColorPickerProps {
10+
username: string;
11+
onColorChange?: (color: string) => void;
12+
}
13+
14+
export function ColorPicker({ username, onColorChange }: ColorPickerProps) {
15+
const { accentColor, setAccentColor } = useTheme();
16+
const [isSaving, setIsSaving] = useState(false);
17+
const [customColor, setCustomColor] = useState(accentColor);
18+
19+
const handleColorSelect = async (color: string) => {
20+
setIsSaving(true);
21+
try {
22+
await updateUserAccentColor(username, color);
23+
setAccentColor(color);
24+
onColorChange?.(color);
25+
} catch (error) {
26+
safeLogError('Error saving accent color:', error);
27+
alert('Failed to save color preference. Please try again.');
28+
} finally {
29+
setIsSaving(false);
30+
}
31+
};
32+
33+
const handleCustomColorChange = (color: string) => {
34+
setCustomColor(color);
35+
};
36+
37+
const handleCustomColorApply = () => {
38+
handleColorSelect(customColor);
39+
};
40+
41+
return (
42+
<div className="space-y-4">
43+
<div className="flex items-center gap-2">
44+
<Palette className="w-5 h-5 text-gray-600 dark:text-gray-400" />
45+
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
46+
Accent Color
47+
</h3>
48+
</div>
49+
50+
<div className="space-y-3">
51+
<p className="text-sm text-gray-600 dark:text-gray-400">
52+
Choose your accent color that will be applied throughout the interface.
53+
</p>
54+
55+
{/* Preset colors */}
56+
<div className="grid grid-cols-4 gap-3">
57+
{DEFAULT_ACCENT_COLORS.map((color) => (
58+
<button
59+
key={color}
60+
onClick={() => handleColorSelect(color)}
61+
disabled={isSaving}
62+
className={`relative w-12 h-12 rounded-lg border-2 transition-all hover:scale-110 ${
63+
accentColor === color
64+
? 'border-gray-900 dark:border-gray-100 shadow-lg'
65+
: 'border-gray-300 dark:border-gray-600'
66+
}`}
67+
style={{ backgroundColor: color }}
68+
title={color}
69+
>
70+
{accentColor === color && (
71+
<Check className="w-5 h-5 text-white absolute inset-0 m-auto drop-shadow-lg" />
72+
)}
73+
</button>
74+
))}
75+
</div>
76+
77+
{/* Custom color picker */}
78+
<div className="space-y-2">
79+
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
80+
Custom Color
81+
</label>
82+
<div className="flex items-center gap-3">
83+
<input
84+
type="color"
85+
value={customColor}
86+
onChange={(e) => handleCustomColorChange(e.target.value)}
87+
className="w-12 h-10 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700"
88+
/>
89+
<input
90+
type="text"
91+
value={customColor}
92+
onChange={(e) => handleCustomColorChange(e.target.value)}
93+
placeholder="rgb(59, 130, 246)"
94+
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
95+
/>
96+
<button
97+
onClick={handleCustomColorApply}
98+
disabled={isSaving || customColor === accentColor}
99+
className="px-4 py-2 btn-accent hover:opacity-90 disabled:opacity-50 text-white rounded-md transition-colors"
100+
>
101+
{isSaving ? 'Saving...' : 'Apply'}
102+
</button>
103+
</div>
104+
</div>
105+
106+
{/* Current color preview */}
107+
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
108+
<div
109+
className="w-8 h-8 rounded-full border-2 border-gray-300 dark:border-gray-600"
110+
style={{ backgroundColor: accentColor }}
111+
/>
112+
<div>
113+
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
114+
Current: {accentColor}
115+
</p>
116+
<p className="text-xs text-gray-600 dark:text-gray-400">
117+
This color is applied to buttons, links, and interactive elements
118+
</p>
119+
</div>
120+
</div>
121+
</div>
122+
</div>
123+
);
124+
}

src/index.css

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,50 @@
77

88
/* END TAILWIND IMPORTS — ALL OTHER CSS MUST GO BELOW THIS LINE */
99

10+
/* Accent color CSS variables */
11+
:root {
12+
--accent-r: 59;
13+
--accent-g: 130;
14+
--accent-b: 246;
15+
--accent-color: rgb(var(--accent-r), var(--accent-g), var(--accent-b));
16+
}
17+
18+
/* Apply accent color to interactive elements */
19+
.accent-bg {
20+
background-color: rgb(var(--accent-r), var(--accent-g), var(--accent-b));
21+
}
22+
23+
.accent-text {
24+
color: rgb(var(--accent-r), var(--accent-g), var(--accent-b));
25+
}
26+
27+
.accent-border {
28+
border-color: rgb(var(--accent-r), var(--accent-g), var(--accent-b));
29+
}
30+
31+
.accent-hover:hover {
32+
background-color: rgb(var(--accent-r), var(--accent-g), var(--accent-b));
33+
}
34+
35+
/* Button styles with accent color */
36+
.btn-accent {
37+
background-color: rgb(var(--accent-r), var(--accent-g), var(--accent-b));
38+
color: white;
39+
}
40+
41+
.btn-accent:hover {
42+
background-color: rgb(calc(var(--accent-r) - 20), calc(var(--accent-g) - 20), calc(var(--accent-b) - 20));
43+
}
44+
45+
/* Link styles with accent color */
46+
.link-accent {
47+
color: rgb(var(--accent-r), var(--accent-g), var(--accent-b));
48+
}
49+
50+
.link-accent:hover {
51+
color: rgb(calc(var(--accent-r) - 30), calc(var(--accent-g) - 30), calc(var(--accent-b) - 30));
52+
}
53+
1054
/* Mobile viewport and input focus fixes */
1155
@supports (-webkit-touch-callout: none) {
1256
/* iOS Safari specific fixes */

src/lib/auth.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export interface User {
1010
joinedAt: string;
1111
lang: string;
1212
theme: string;
13+
accentColor?: string; // RGB color string like "rgb(59, 130, 246)"
1314
}
1415

1516
interface AuthContextType {

src/lib/i18n.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ const translations = {
2929
offlineTopicError: 'Topic not available offline - please check your connection',
3030
offlineForumError: 'Forum not available offline - please check your connection',
3131
offlinePostError: 'Cannot create posts while offline. Please check your internet connection.',
32+
accentColor: 'Accent Color',
33+
accentColorDescription: 'Choose your accent color that will be applied throughout the interface.',
34+
customColor: 'Custom Color',
35+
apply: 'Apply',
36+
currentColor: 'Current',
37+
colorAppliedTo: 'This color is applied to buttons, links, and interactive elements',
3238
retry: 'Retry',
3339
by: 'by',
3440
joined: 'Joined',
@@ -110,7 +116,17 @@ const translations = {
110116
maintenanceMessage: 'Сайт находится в режиме обслуживания. Повторите попытку позже.',
111117
signupsDisabled: 'Регистрация временно отключена. Пожалуйста, войдите в существующий аккаунт.',
112118
adminSettingsLoading: 'Загрузка настроек администратора...',
113-
maintenanceBanner: 'Сайт находится в режиме обслуживания. Некоторые функции могут быть ограничены.'
119+
maintenanceBanner: 'Сайт находится в режиме обслуживания. Некоторые функции могут быть ограничены.',
120+
offlineError: 'Режим оффлайн - просмотр кешированных данных',
121+
offlineTopicError: 'Тема недоступна в оффлайне - проверьте подключение к интернету',
122+
offlineForumError: 'Форум недоступен в оффлайне - проверьте подключение к интернету',
123+
offlinePostError: 'Невозможно создавать сообщения в оффлайне. Проверьте подключение к интернету.',
124+
accentColor: 'Акцентный цвет',
125+
accentColorDescription: 'Выберите акцентный цвет, который будет применяться во всем интерфейсе.',
126+
customColor: 'Пользовательский цвет',
127+
apply: 'Применить',
128+
currentColor: 'Текущий',
129+
colorAppliedTo: 'Этот цвет применяется к кнопкам, ссылкам и интерактивным элементам'
114130
}
115131
};
116132

src/lib/theme.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,43 @@
11
import React, { createContext, useContext, useState, useEffect } from 'react';
22
import { config } from '../config/app';
3+
import { getUserAccentColor, DEFAULT_ACCENT_COLOR } from './userSettings';
34

45
type Theme = 'light' | 'dark';
56

67
interface ThemeContextType {
78
theme: Theme;
89
setTheme: (theme: Theme) => void;
10+
accentColor: string;
11+
setAccentColor: (color: string) => void;
912
}
1013

1114
const ThemeContext = createContext<ThemeContextType | null>(null);
1215

13-
export function ThemeProvider({ children }: {children: React.ReactNode;}) {
16+
export function ThemeProvider({ children, username }: { children: React.ReactNode; username?: string }) {
1417
const [theme, setTheme] = useState<Theme>(() => {
1518
const saved = localStorage.getItem('theme');
1619
return saved as Theme || config.defaultTheme as Theme;
1720
});
1821

22+
const [accentColor, setAccentColorState] = useState<string>(DEFAULT_ACCENT_COLOR);
23+
24+
// Load user's accent color on mount
25+
useEffect(() => {
26+
if (username) {
27+
getUserAccentColor(username).then(color => {
28+
setAccentColorState(color);
29+
}).catch(() => {
30+
// Keep default color on error
31+
});
32+
}
33+
}, [username]);
34+
35+
const setAccentColor = (color: string) => {
36+
setAccentColorState(color);
37+
// Apply color immediately to CSS variables
38+
applyAccentColor(color);
39+
};
40+
1941
useEffect(() => {
2042
localStorage.setItem('theme', theme);
2143
if (theme === 'dark') {
@@ -25,13 +47,30 @@ export function ThemeProvider({ children }: {children: React.ReactNode;}) {
2547
}
2648
}, [theme]);
2749

50+
// Apply accent color to CSS variables
51+
useEffect(() => {
52+
applyAccentColor(accentColor);
53+
}, [accentColor]);
54+
2855
return React.createElement(
2956
ThemeContext.Provider,
30-
{ value: { theme, setTheme } },
57+
{ value: { theme, setTheme, accentColor, setAccentColor } },
3158
children
3259
);
3360
}
3461

62+
function applyAccentColor(color: string) {
63+
// Extract RGB values from rgb(r, g, b) format
64+
const rgbMatch = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
65+
if (rgbMatch) {
66+
const [, r, g, b] = rgbMatch;
67+
document.documentElement.style.setProperty('--accent-r', r);
68+
document.documentElement.style.setProperty('--accent-g', g);
69+
document.documentElement.style.setProperty('--accent-b', b);
70+
document.documentElement.style.setProperty('--accent-color', color);
71+
}
72+
}
73+
3574
export const useTheme = () => {
3675
const context = useContext(ThemeContext);
3776
if (!context) throw new Error('useTheme must be used within ThemeProvider');

0 commit comments

Comments
 (0)