-
-
Notifications
You must be signed in to change notification settings - Fork 16
Expand file tree
/
Copy paththemeUtils.ts
More file actions
609 lines (556 loc) · 17.8 KB
/
themeUtils.ts
File metadata and controls
609 lines (556 loc) · 17.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
export type Theme = {
id: string;
name: string;
colors: {
primary: string;
secondary: string;
accent: string;
};
dotGradient: string;
};
export const themes: Theme[] = [
{
id: "default",
name: "Default",
colors: {
primary: "#0D5C63", // ipython-blue
secondary: "#008B95", // ipython-cyan
accent: "#059669", // ipython-green
},
dotGradient: "linear-gradient(to bottom right, #0D5C63, #008B95, #059669)",
},
{
id: "ipython-default",
name: "Teal",
colors: {
primary: "#0D5C63", // ipython-blue
secondary: "#008B95", // ipython-cyan
accent: "#059669", // ipython-green
},
dotGradient: "linear-gradient(to bottom right, #0D5C63, #008B95, #059669)",
},
{
id: "rainbow",
name: "Rainbow Pride",
colors: {
primary: "#E40303", // Red
secondary: "#FF8C00", // Orange
accent: "#FFED00", // Yellow
},
dotGradient:
"linear-gradient(to bottom right, #E40303 0%, #E40303 16.67%, #FF8C00 16.67%, #FF8C00 33.33%, #FFED00 33.33%, #FFED00 50%, #008026 50%, #008026 66.67%, #004CFF 66.67%, #004CFF 83.33%, #732982 83.33%, #732982 100%)",
},
{
id: "gay",
name: "Gay Pride",
colors: {
primary: "#078D70", // Green
secondary: "#26CEAA", // Light green/teal
accent: "#98E8C1", // Pale green/mint
},
dotGradient:
"linear-gradient(to bottom right, #078D70 0%, #078D70 14.28%, #26CEAA 14.28%, #26CEAA 28.56%, #98E8C1 28.56%, #98E8C1 42.84%, #FFFFFF 42.84%, #FFFFFF 57.12%, #7BADE2 57.12%, #7BADE2 71.4%, #5049CC 71.4%, #5049CC 85.68%, #3D1A78 85.68%, #3D1A78 100%)",
},
{
id: "lesbian",
name: "Lesbian Pride",
colors: {
primary: "#D52D00", // Dark orange/red
secondary: "#EF7627", // Orange
accent: "#FF9A56", // Light orange/peach
},
dotGradient:
"linear-gradient(to bottom right, #D52D00 0%, #D52D00 14.28%, #EF7627 14.28%, #EF7627 28.56%, #FF9A56 28.56%, #FF9A56 42.84%, #FFFFFF 42.84%, #FFFFFF 57.12%, #D162A4 57.12%, #D162A4 71.4%, #B55690 71.4%, #B55690 85.68%, #A30262 85.68%, #A30262 100%)",
},
{
id: "trans",
name: "Trans Pride",
colors: {
primary: "#5BCEFA", // Light blue
secondary: "#F5A9B8", // Pink
accent: "#FFFFFF", // White
},
dotGradient:
"linear-gradient(to bottom right, #5BCEFA, #F5A9B8, #FFFFFF, #F5A9B8, #5BCEFA)",
},
{
id: "purple",
name: "Purple",
colors: {
primary: "#7C3AED", // Purple
secondary: "#A78BFA", // Light purple
accent: "#C4B5FD", // Lighter purple
},
dotGradient: "linear-gradient(to bottom right, #7C3AED, #A78BFA, #C4B5FD)",
},
{
id: "pink",
name: "Pink",
colors: {
primary: "#EC4899", // Pink
secondary: "#F472B6", // Light pink
accent: "#FBCFE8", // Lighter pink
},
dotGradient: "linear-gradient(to bottom right, #EC4899, #F472B6, #FBCFE8)",
},
{
id: "orange",
name: "Orange",
colors: {
primary: "#F97316", // Orange
secondary: "#FB923C", // Light orange
accent: "#FDBA74", // Lighter orange
},
dotGradient: "linear-gradient(to bottom right, #F97316, #FB923C, #FDBA74)",
},
{
id: "indigo",
name: "Indigo",
colors: {
primary: "#4F46E5", // Indigo
secondary: "#6366F1", // Light indigo
accent: "#818CF8", // Lighter indigo
},
dotGradient: "linear-gradient(to bottom right, #4F46E5, #6366F1, #818CF8)",
},
{
id: "emerald",
name: "Emerald",
colors: {
primary: "#10B981", // Emerald
secondary: "#34D399", // Light emerald
accent: "#6EE7B7", // Lighter emerald
},
dotGradient: "linear-gradient(to bottom right, #10B981, #34D399, #6EE7B7)",
},
{
id: "winter",
name: "Winter",
colors: {
primary: "#0EA5E9", // Sky blue
secondary: "#38BDF8", // Light sky blue
accent: "#BAE6FD", // Lighter sky blue
},
dotGradient: "linear-gradient(to bottom right, #0EA5E9, #38BDF8, #BAE6FD)",
},
{
id: "christmas",
name: "Christmas",
colors: {
primary: "#c8102e", // Deep Christmas Red
secondary: "#006b3c", // Forest Green
accent: "#ffffff", // White/Snow
},
dotGradient:
"linear-gradient(to bottom right, #c8102e 0%, #c8102e 40%, #006b3c 40%, #006b3c 80%, #ffffff 80%, #ffffff 100%)",
},
{
id: "ocean",
name: "Ocean",
colors: {
primary: "#0a2540", // Deep ocean blue
secondary: "#006994", // Ocean blue
accent: "#00d4ff", // Bright cyan
},
dotGradient:
"linear-gradient(to bottom right, #0a2540 0%, #0a2540 40%, #006994 40%, #006994 70%, #00d4ff 70%, #00d4ff 100%)",
},
{
id: "velvet",
name: "Red Velvet",
colors: {
primary: "#6b0f2a", // Deep velvet red
secondary: "#8b1538", // Rich carmine
accent: "#a91d3d", // Warm velvet
},
dotGradient:
"linear-gradient(to bottom right, #6b0f2a 0%, #6b0f2a 40%, #8b1538 40%, #8b1538 70%, #a91d3d 70%, #a91d3d 100%)",
},
{
id: "sun",
name: "Sun",
colors: {
primary: "#f59e0b", // Bright amber/yellow
secondary: "#fbbf24", // Golden yellow
accent: "#fcd34d", // Light yellow
},
dotGradient:
"linear-gradient(to bottom right, #f59e0b 0%, #f59e0b 40%, #fbbf24 40%, #fbbf24 70%, #fcd34d 70%, #fcd34d 100%)",
},
{
id: "random",
name: "Random",
colors: {
primary: "#9333ea", // Purple
secondary: "#ec4899", // Pink
accent: "#f59e0b", // Amber
},
dotGradient:
"linear-gradient(to bottom right, #9333ea, #ec4899, #f59e0b, #10b981, #3b82f6)",
},
];
/**
* Get all available theme IDs excluding 'random'
*/
export function getAvailableThemes(): string[] {
return themes.filter((t) => t.id !== "random").map((t) => t.id);
}
/**
* Get a random theme ID (excluding 'random')
*/
export function getRandomTheme(): string {
const availableThemes = getAvailableThemes();
return availableThemes[Math.floor(Math.random() * availableThemes.length)];
}
/**
* Theme storage format
*/
interface ThemeStorage {
themeId: string;
date: string; // YYYY-MM-DD format
}
/**
* Apply a theme to the document
* If themeId is 'random', it will pick a random theme
* @param themeId - The theme ID to apply
* @param storePreference - Whether to store the preference in localStorage (default: true)
*/
export function applyTheme(
themeId: string,
storePreference: boolean = true
): void {
let actualThemeId = themeId;
// If random is selected, pick a random theme
if (themeId === "random") {
actualThemeId = getRandomTheme();
}
console.log("Applying theme", themeId);
const theme = themes.find((t) => t.id === actualThemeId) || themes[0];
const root = document.documentElement;
// Set CSS custom properties
root.style.setProperty("--theme-primary", theme.colors.primary);
root.style.setProperty("--theme-secondary", theme.colors.secondary);
root.style.setProperty("--theme-accent", theme.colors.accent);
// Update Tailwind colors via data attribute (use actual theme, not 'random')
root.setAttribute("data-color-theme", actualThemeId);
// Store preference with date (store 'random' if that's what was selected)
// Don't store if theme is 'default' (non-persistent default theme)
if (storePreference && themeId !== "default") {
const today = new Date();
const dateString = `${today.getFullYear()}-${String(
today.getMonth() + 1
).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
const storage: ThemeStorage = {
themeId: themeId,
date: dateString,
};
localStorage.setItem("colorTheme", JSON.stringify(storage));
} else if (themeId === "default" && typeof localStorage !== "undefined") {
// Remove any stored theme when switching to default
localStorage.removeItem("colorTheme");
}
}
/**
* Get the current theme from localStorage or data attribute
*/
export function getCurrentTheme(): string {
if (typeof document === "undefined") return "default";
return (
document.documentElement.getAttribute("data-color-theme") ||
localStorage.getItem("colorTheme") ||
"default"
);
}
/**
* Check if we should auto-apply winter theme
* Returns true if current month is December or January,
* and the last theme change was NOT in December or January
*/
function shouldAutoApplyWinterTheme(): boolean {
if (typeof localStorage === "undefined") {
return true;
}
const now = new Date();
const currentMonth = now.getMonth(); // 0 = January, 11 = December
const isDecOrJan = currentMonth === 11 || currentMonth === 0;
if (!isDecOrJan) {
console.info("Nothing specific using default theme");
return false;
}
const stored = localStorage.getItem("colorTheme");
if (!stored) return true; // No stored theme, apply winter
try {
const parsed = JSON.parse(stored);
let storedDate: Date | null = null;
if (typeof parsed === "object" && parsed.date) {
// Parse YYYY-MM-DD format
storedDate = new Date(parsed.date + "T00:00:00");
} else if (typeof parsed === "object" && parsed.timestamp) {
// Old format with timestamp
storedDate = new Date(parsed.timestamp);
}
if (storedDate) {
const storedMonth = storedDate.getMonth();
const storedIsDecOrJan = storedMonth === 11 || storedMonth === 0;
// If stored date is NOT in Dec/Jan, auto-apply winter
return !storedIsDecOrJan;
}
} catch {
console.log("Something wrong in local storage");
// If parsing fails, assume old format - apply winter
return true;
}
return false;
}
/**
* Get the stored theme preference (what user selected, not the actual applied theme)
*/
export function getStoredTheme(): string {
if (typeof localStorage === "undefined") return "default";
const stored = localStorage.getItem("colorTheme");
if (!stored) return "default";
// Handle both old format (string) and new format (object with timestamp)
try {
const parsed = JSON.parse(stored);
if (typeof parsed === "object" && parsed.themeId) {
return parsed.themeId;
}
} catch {
// If parsing fails, assume it's the old string format
return stored;
}
return "default";
}
/**
* Get the stored theme preference with date
* @returns Object with themeId and date, or null if not found
*/
export function getStoredThemeWithDate(): ThemeStorage | null {
if (typeof localStorage === "undefined") return null;
const stored = localStorage.getItem("colorTheme");
if (!stored) return null;
try {
const parsed = JSON.parse(stored);
if (typeof parsed === "object" && parsed.themeId) {
// Handle both old format (with timestamp) and new format (with date)
if (parsed.date) {
return parsed as ThemeStorage;
} else if (parsed.timestamp) {
// Convert old timestamp to date string
const date = new Date(parsed.timestamp);
const dateString = `${date.getFullYear()}-${String(
date.getMonth() + 1
).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
return {
themeId: parsed.themeId,
date: dateString,
};
}
}
// Handle old format (just a string)
if (typeof parsed === "string") {
const today = new Date();
const dateString = `${today.getFullYear()}-${String(
today.getMonth() + 1
).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
return {
themeId: parsed,
date: dateString, // Use current date as fallback
};
}
} catch {
// If parsing fails, assume it's the old string format
const today = new Date();
const dateString = `${today.getFullYear()}-${String(
today.getMonth() + 1
).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
return {
themeId: stored,
date: dateString, // Use current date as fallback
};
}
return null;
}
// Theme change callbacks
type ThemeChangeCallback = (themeId: string) => void;
const themeChangeCallbacks = new Set<ThemeChangeCallback>();
/**
* Subscribe to theme changes
* @param callback - Function to call when theme changes
* @returns Unsubscribe function
*/
export function subscribeToThemeChanges(
callback: ThemeChangeCallback
): () => void {
themeChangeCallbacks.add(callback);
// Immediately call with current theme
callback(getStoredTheme());
return () => {
themeChangeCallbacks.delete(callback);
};
}
/**
* Notify all subscribers of theme change
*/
function notifyThemeChange(themeId: string): void {
themeChangeCallbacks.forEach((callback) => callback(themeId));
}
// Theme watcher state
let themeWatcherInitialized = false;
let themeWatcherCleanup: (() => void) | null = null;
let randomRotationCleanup: (() => void) | null = null;
/**
* Initialize theme watcher - watches for changes from localStorage, data attributes, etc.
* Should be called once when the app starts. Safe to call multiple times.
*/
export function initializeThemeWatcher(): () => void {
if (themeWatcherInitialized || typeof document === "undefined") {
// Return existing cleanup or no-op
return themeWatcherCleanup || (() => {});
}
themeWatcherInitialized = true;
const checkTheme = () => {
// Check if we should auto-apply winter theme first
if (shouldAutoApplyWinterTheme()) {
const currentApplied = getCurrentTheme();
// Only apply winter if it's not already applied
if (currentApplied !== "winter") {
applyTheme("winter", false); // Apply winter but don't store it
notifyThemeChange(getStoredTheme()); // Notify with stored theme, not 'winter'
}
return; // Don't check stored theme when winter is auto-applied
}
const storedTheme = getStoredTheme();
const currentApplied = getCurrentTheme();
// If stored theme is 'random', handle rotation
if (storedTheme === "random") {
// Random rotation is handled separately
return;
}
// If stored theme doesn't match applied theme, apply it
if (storedTheme !== currentApplied && storedTheme !== "random") {
applyTheme(storedTheme, false); // Don't store again, just apply
}
};
// Watch for data attribute changes
const observer = new MutationObserver(checkTheme);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-color-theme"],
});
// Watch for localStorage changes (from other tabs/windows)
const handleStorageChange = (e: StorageEvent) => {
if (e.key === "colorTheme") {
let newTheme = "default";
if (e.newValue) {
try {
const parsed = JSON.parse(e.newValue);
if (typeof parsed === "object" && parsed.themeId) {
newTheme = parsed.themeId;
} else if (typeof parsed === "string") {
newTheme = parsed;
}
} catch {
// If parsing fails, assume it's the old string format
newTheme = e.newValue;
}
}
applyTheme(newTheme, false); // Don't store, it's already stored
notifyThemeChange(newTheme);
// Handle random theme rotation
if (newTheme === "random") {
startRandomThemeRotation();
} else {
stopRandomThemeRotation();
}
}
};
window.addEventListener("storage", handleStorageChange);
// Poll for changes (fallback)
const interval = setInterval(checkTheme, 10000);
// Check for theme URL parameter and apply it
const urlParams = new URLSearchParams(window.location.search);
const themeParam = urlParams.get("theme");
let themeParamApplied = false;
if (themeParam) {
// Validate theme exists
const themeExists = themes.some((t) => t.id === themeParam);
if (themeExists) {
changeTheme(themeParam);
themeParamApplied = true;
}
// Remove theme parameter from URL without page reload
urlParams.delete("theme");
const newUrl =
urlParams.toString() === ""
? window.location.pathname
: `${window.location.pathname}?${urlParams.toString()}`;
window.history.replaceState({}, "", newUrl);
}
// Apply initial theme (only if no valid theme parameter was provided)
if (!themeParamApplied) {
// Check if we should auto-apply winter theme
if (shouldAutoApplyWinterTheme()) {
console.log("will apply winter");
applyTheme("winter", false); // Apply winter but don't store it
notifyThemeChange("winter"); // Notify with the stored theme, not 'winter'
} else {
const initialTheme = getStoredTheme();
applyTheme(initialTheme);
if (initialTheme === "random") {
startRandomThemeRotation();
}
}
}
// Cleanup function
themeWatcherCleanup = () => {
observer.disconnect();
window.removeEventListener("storage", handleStorageChange);
clearInterval(interval);
stopRandomThemeRotation();
themeWatcherInitialized = false;
themeWatcherCleanup = null;
};
return themeWatcherCleanup;
}
/**
* Start random theme rotation
*/
function startRandomThemeRotation(): void {
if (randomRotationCleanup) return; // Already running
// Apply random theme immediately
applyTheme("random", false);
notifyThemeChange("random");
// Change theme every minute (60000ms)
const randomInterval = setInterval(() => {
applyTheme("random", false);
notifyThemeChange("random");
}, 60000);
randomRotationCleanup = () => {
clearInterval(randomInterval);
randomRotationCleanup = null;
};
}
/**
* Stop random theme rotation
*/
function stopRandomThemeRotation(): void {
if (randomRotationCleanup) {
randomRotationCleanup();
randomRotationCleanup = null;
}
}
/**
* Change theme (public API for components)
* This is the main way components should change themes
*/
export function changeTheme(themeId: string): void {
applyTheme(themeId, true); // Store preference
notifyThemeChange(themeId);
// Handle random theme rotation
if (themeId === "random") {
startRandomThemeRotation();
} else {
stopRandomThemeRotation();
}
}