@djangocfg/ui-nextjs 1.4.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +152 -0
- package/package.json +110 -0
- package/src/animations/AnimatedBackground.tsx +645 -0
- package/src/animations/index.ts +2 -0
- package/src/blocks/ArticleCard.tsx +94 -0
- package/src/blocks/ArticleList.tsx +95 -0
- package/src/blocks/CTASection.tsx +136 -0
- package/src/blocks/FeatureSection.tsx +104 -0
- package/src/blocks/Hero.tsx +102 -0
- package/src/blocks/NewsletterSection.tsx +119 -0
- package/src/blocks/StatsSection.tsx +103 -0
- package/src/blocks/SuperHero.tsx +328 -0
- package/src/blocks/TestimonialSection.tsx +122 -0
- package/src/blocks/index.ts +9 -0
- package/src/components/README.md +2018 -0
- package/src/components/breadcrumb-navigation.tsx +127 -0
- package/src/components/breadcrumb.tsx +132 -0
- package/src/components/button-download.tsx +275 -0
- package/src/components/dropdown-menu.tsx +219 -0
- package/src/components/index.ts +86 -0
- package/src/components/markdown/MarkdownMessage.tsx +338 -0
- package/src/components/markdown/index.ts +5 -0
- package/src/components/menubar.tsx +274 -0
- package/src/components/multi-select-pro/async.tsx +608 -0
- package/src/components/multi-select-pro/helpers.tsx +84 -0
- package/src/components/multi-select-pro/index.tsx +622 -0
- package/src/components/navigation-menu.tsx +153 -0
- package/src/components/pagination-static.tsx +348 -0
- package/src/components/pagination.tsx +138 -0
- package/src/components/phone-input.tsx +276 -0
- package/src/components/sidebar.tsx +866 -0
- package/src/components/sonner.tsx +31 -0
- package/src/components/ssr-pagination.tsx +237 -0
- package/src/hooks/index.ts +19 -0
- package/src/hooks/useCfgRouter.ts +153 -0
- package/src/hooks/useLocalStorage.ts +221 -0
- package/src/hooks/useQueryParams.ts +73 -0
- package/src/hooks/useSessionStorage.ts +188 -0
- package/src/hooks/useTheme.ts +57 -0
- package/src/index.ts +24 -0
- package/src/lib/index.ts +2 -0
- package/src/styles/index.css +2 -0
- package/src/theme/ForceTheme.tsx +115 -0
- package/src/theme/ThemeProvider.tsx +82 -0
- package/src/theme/ThemeToggle.tsx +52 -0
- package/src/theme/index.ts +3 -0
- package/src/tools/JsonForm/JsonSchemaForm.tsx +199 -0
- package/src/tools/JsonForm/examples/BotConfigExample.tsx +245 -0
- package/src/tools/JsonForm/examples/RealBotConfigExample.tsx +157 -0
- package/src/tools/JsonForm/index.ts +46 -0
- package/src/tools/JsonForm/templates/ArrayFieldItemTemplate.tsx +46 -0
- package/src/tools/JsonForm/templates/ArrayFieldTemplate.tsx +73 -0
- package/src/tools/JsonForm/templates/BaseInputTemplate.tsx +106 -0
- package/src/tools/JsonForm/templates/ErrorListTemplate.tsx +34 -0
- package/src/tools/JsonForm/templates/FieldTemplate.tsx +61 -0
- package/src/tools/JsonForm/templates/ObjectFieldTemplate.tsx +43 -0
- package/src/tools/JsonForm/templates/index.ts +12 -0
- package/src/tools/JsonForm/types.ts +83 -0
- package/src/tools/JsonForm/utils.ts +212 -0
- package/src/tools/JsonForm/widgets/CheckboxWidget.tsx +36 -0
- package/src/tools/JsonForm/widgets/NumberWidget.tsx +88 -0
- package/src/tools/JsonForm/widgets/SelectWidget.tsx +100 -0
- package/src/tools/JsonForm/widgets/SwitchWidget.tsx +34 -0
- package/src/tools/JsonForm/widgets/TextWidget.tsx +95 -0
- package/src/tools/JsonForm/widgets/index.ts +12 -0
- package/src/tools/JsonTree/index.tsx +252 -0
- package/src/tools/LottiePlayer/LottiePlayer.client.tsx +212 -0
- package/src/tools/LottiePlayer/index.tsx +54 -0
- package/src/tools/LottiePlayer/types.ts +108 -0
- package/src/tools/LottiePlayer/useLottie.ts +163 -0
- package/src/tools/Mermaid/Mermaid.client.tsx +341 -0
- package/src/tools/Mermaid/index.tsx +40 -0
- package/src/tools/OpenapiViewer/components/EndpointInfo.tsx +144 -0
- package/src/tools/OpenapiViewer/components/EndpointsLibrary.tsx +255 -0
- package/src/tools/OpenapiViewer/components/PlaygroundLayout.tsx +123 -0
- package/src/tools/OpenapiViewer/components/PlaygroundStepper.tsx +98 -0
- package/src/tools/OpenapiViewer/components/RequestBuilder.tsx +164 -0
- package/src/tools/OpenapiViewer/components/RequestParametersForm.tsx +253 -0
- package/src/tools/OpenapiViewer/components/ResponseViewer.tsx +169 -0
- package/src/tools/OpenapiViewer/components/VersionSelector.tsx +64 -0
- package/src/tools/OpenapiViewer/components/index.ts +14 -0
- package/src/tools/OpenapiViewer/constants.ts +39 -0
- package/src/tools/OpenapiViewer/context/PlaygroundContext.tsx +338 -0
- package/src/tools/OpenapiViewer/hooks/index.ts +8 -0
- package/src/tools/OpenapiViewer/hooks/useMobile.ts +10 -0
- package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +203 -0
- package/src/tools/OpenapiViewer/index.tsx +36 -0
- package/src/tools/OpenapiViewer/types.ts +152 -0
- package/src/tools/OpenapiViewer/utils/apiKeyManager.ts +149 -0
- package/src/tools/OpenapiViewer/utils/formatters.ts +71 -0
- package/src/tools/OpenapiViewer/utils/index.ts +9 -0
- package/src/tools/OpenapiViewer/utils/versionManager.ts +161 -0
- package/src/tools/PrettyCode/PrettyCode.client.tsx +217 -0
- package/src/tools/PrettyCode/index.tsx +43 -0
- package/src/tools/VideoPlayer/README.md +239 -0
- package/src/tools/VideoPlayer/VideoControls.tsx +138 -0
- package/src/tools/VideoPlayer/VideoPlayer.tsx +230 -0
- package/src/tools/VideoPlayer/index.ts +9 -0
- package/src/tools/VideoPlayer/types.ts +62 -0
- package/src/tools/index.ts +43 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useQueryParams Hook
|
|
3
|
+
*
|
|
4
|
+
* Safe hook to access URL query parameters without requiring Suspense boundary.
|
|
5
|
+
* Works on client-side only, returns empty URLSearchParams during SSR/prerendering.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* const params = useQueryParams();
|
|
10
|
+
* const flow = params.get('flow');
|
|
11
|
+
* const hasFlow = params.has('flow');
|
|
12
|
+
* const allTags = params.getAll('tags');
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use client';
|
|
17
|
+
|
|
18
|
+
import { useState, useEffect, useRef } from 'react';
|
|
19
|
+
import { usePathname } from 'next/navigation';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Hook to safely access URL query parameters without useSearchParams()
|
|
23
|
+
*
|
|
24
|
+
* This hook reads query parameters directly from window.location.search,
|
|
25
|
+
* avoiding the need for Suspense boundaries that useSearchParams() requires.
|
|
26
|
+
*
|
|
27
|
+
* Automatically updates when URL changes (navigation, back/forward, etc.)
|
|
28
|
+
* Uses pathname from Next.js to detect route changes and polls for query param changes.
|
|
29
|
+
*
|
|
30
|
+
* Returns a URLSearchParams object with get(), getAll(), has(), etc.
|
|
31
|
+
*
|
|
32
|
+
* @returns URLSearchParams object (empty during SSR)
|
|
33
|
+
*/
|
|
34
|
+
export function useQueryParams(): URLSearchParams {
|
|
35
|
+
const pathname = usePathname();
|
|
36
|
+
const [queryParams, setQueryParams] = useState<URLSearchParams>(() => {
|
|
37
|
+
if (typeof window === 'undefined') {
|
|
38
|
+
return new URLSearchParams();
|
|
39
|
+
}
|
|
40
|
+
return new URLSearchParams(window.location.search);
|
|
41
|
+
});
|
|
42
|
+
const lastSearchRef = useRef<string>('');
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (typeof window === 'undefined') return;
|
|
46
|
+
|
|
47
|
+
const updateQueryParams = () => {
|
|
48
|
+
const currentSearch = window.location.search;
|
|
49
|
+
if (currentSearch !== lastSearchRef.current) {
|
|
50
|
+
lastSearchRef.current = currentSearch;
|
|
51
|
+
setQueryParams(new URLSearchParams(currentSearch));
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Update when pathname changes (Next.js navigation)
|
|
56
|
+
updateQueryParams();
|
|
57
|
+
|
|
58
|
+
// Listen to popstate (back/forward navigation)
|
|
59
|
+
window.addEventListener('popstate', updateQueryParams);
|
|
60
|
+
|
|
61
|
+
// Poll for query param changes (for router.push with same pathname)
|
|
62
|
+
// This handles cases where Next.js router.push updates query params without changing pathname
|
|
63
|
+
const intervalId = setInterval(updateQueryParams, 100);
|
|
64
|
+
|
|
65
|
+
return () => {
|
|
66
|
+
window.removeEventListener('popstate', updateQueryParams);
|
|
67
|
+
clearInterval(intervalId);
|
|
68
|
+
};
|
|
69
|
+
}, [pathname]);
|
|
70
|
+
|
|
71
|
+
return queryParams;
|
|
72
|
+
}
|
|
73
|
+
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Simple sessionStorage hook with better error handling
|
|
7
|
+
* @param key - Storage key
|
|
8
|
+
* @param initialValue - Default value if key doesn't exist
|
|
9
|
+
* @returns [value, setValue, removeValue] - Current value, setter function, and remove function
|
|
10
|
+
*/
|
|
11
|
+
export function useSessionStorage<T>(key: string, initialValue: T) {
|
|
12
|
+
// Get initial value from sessionStorage or use provided initialValue
|
|
13
|
+
const [storedValue, setStoredValue] = useState<T>(() => {
|
|
14
|
+
if (typeof window === 'undefined') {
|
|
15
|
+
return initialValue;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const item = window.sessionStorage.getItem(key);
|
|
20
|
+
return item ? JSON.parse(item) : initialValue;
|
|
21
|
+
} catch (error) {
|
|
22
|
+
console.error(`Error reading sessionStorage key "${key}":`, error);
|
|
23
|
+
return initialValue;
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Check data size and limit
|
|
28
|
+
const checkDataSize = (data: any): boolean => {
|
|
29
|
+
try {
|
|
30
|
+
const jsonString = JSON.stringify(data);
|
|
31
|
+
const sizeInBytes = new Blob([jsonString]).size;
|
|
32
|
+
const sizeInKB = sizeInBytes / 1024;
|
|
33
|
+
|
|
34
|
+
// Limit to 1MB per item
|
|
35
|
+
if (sizeInKB > 1024) {
|
|
36
|
+
console.warn(`Data size (${sizeInKB.toFixed(2)}KB) exceeds 1MB limit for key "${key}"`);
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return true;
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.error(`Error checking data size for key "${key}":`, error);
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Clear old data when sessionStorage is full
|
|
48
|
+
const clearOldData = () => {
|
|
49
|
+
try {
|
|
50
|
+
const keys = Object.keys(sessionStorage).filter(key => key && typeof key === 'string');
|
|
51
|
+
// Remove oldest items if we have more than 50 items
|
|
52
|
+
if (keys.length > 50) {
|
|
53
|
+
const itemsToRemove = Math.ceil(keys.length * 0.2);
|
|
54
|
+
for (let i = 0; i < itemsToRemove; i++) {
|
|
55
|
+
try {
|
|
56
|
+
const key = keys[i];
|
|
57
|
+
if (key) {
|
|
58
|
+
sessionStorage.removeItem(key);
|
|
59
|
+
sessionStorage.removeItem(`${key}_timestamp`);
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
// Ignore errors when removing items
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error('Error clearing old sessionStorage data:', error);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Force clear all data if quota is exceeded
|
|
72
|
+
const forceClearAll = () => {
|
|
73
|
+
try {
|
|
74
|
+
const keys = Object.keys(sessionStorage);
|
|
75
|
+
for (const key of keys) {
|
|
76
|
+
try {
|
|
77
|
+
sessionStorage.removeItem(key);
|
|
78
|
+
} catch {
|
|
79
|
+
// Ignore errors when removing items
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} catch (error) {
|
|
83
|
+
console.error('Error force clearing sessionStorage:', error);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Update sessionStorage when value changes
|
|
88
|
+
const setValue = (value: T | ((val: T) => T)) => {
|
|
89
|
+
try {
|
|
90
|
+
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
|
91
|
+
|
|
92
|
+
// Check data size before attempting to save
|
|
93
|
+
if (!checkDataSize(valueToStore)) {
|
|
94
|
+
console.warn(`Data size too large for key "${key}", removing key`);
|
|
95
|
+
// Remove the key if data is too large
|
|
96
|
+
try {
|
|
97
|
+
window.sessionStorage.removeItem(key);
|
|
98
|
+
window.sessionStorage.removeItem(`${key}_timestamp`);
|
|
99
|
+
} catch {
|
|
100
|
+
// Ignore errors when removing
|
|
101
|
+
}
|
|
102
|
+
// Still update the state
|
|
103
|
+
setStoredValue(valueToStore);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
setStoredValue(valueToStore);
|
|
108
|
+
|
|
109
|
+
if (typeof window !== 'undefined') {
|
|
110
|
+
// Try to set the value
|
|
111
|
+
try {
|
|
112
|
+
window.sessionStorage.setItem(key, JSON.stringify(valueToStore));
|
|
113
|
+
// Add timestamp for cleanup
|
|
114
|
+
window.sessionStorage.setItem(`${key}_timestamp`, Date.now().toString());
|
|
115
|
+
} catch (storageError: any) {
|
|
116
|
+
// If quota exceeded, clear old data and try again
|
|
117
|
+
if (storageError.name === 'QuotaExceededError' ||
|
|
118
|
+
storageError.code === 22 ||
|
|
119
|
+
storageError.message?.includes('quota')) {
|
|
120
|
+
console.warn('sessionStorage quota exceeded, clearing old data...');
|
|
121
|
+
clearOldData();
|
|
122
|
+
|
|
123
|
+
// Try again after clearing
|
|
124
|
+
try {
|
|
125
|
+
window.sessionStorage.setItem(key, JSON.stringify(valueToStore));
|
|
126
|
+
window.sessionStorage.setItem(`${key}_timestamp`, Date.now().toString());
|
|
127
|
+
} catch (retryError) {
|
|
128
|
+
console.error(`Failed to set sessionStorage key "${key}" after clearing old data:`, retryError);
|
|
129
|
+
// If still fails, force clear all and try one more time
|
|
130
|
+
try {
|
|
131
|
+
forceClearAll();
|
|
132
|
+
window.sessionStorage.setItem(key, JSON.stringify(valueToStore));
|
|
133
|
+
window.sessionStorage.setItem(`${key}_timestamp`, Date.now().toString());
|
|
134
|
+
} catch (finalError) {
|
|
135
|
+
console.error(`Failed to set sessionStorage key "${key}" after force clearing:`, finalError);
|
|
136
|
+
// If still fails, just update the state without sessionStorage
|
|
137
|
+
setStoredValue(valueToStore);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
throw storageError;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
} catch (error) {
|
|
146
|
+
console.error(`Error setting sessionStorage key "${key}":`, error);
|
|
147
|
+
// Still update the state even if sessionStorage fails
|
|
148
|
+
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
|
149
|
+
setStoredValue(valueToStore);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// Remove value from sessionStorage
|
|
154
|
+
const removeValue = () => {
|
|
155
|
+
try {
|
|
156
|
+
setStoredValue(initialValue);
|
|
157
|
+
if (typeof window !== 'undefined') {
|
|
158
|
+
try {
|
|
159
|
+
window.sessionStorage.removeItem(key);
|
|
160
|
+
window.sessionStorage.removeItem(`${key}_timestamp`);
|
|
161
|
+
} catch (removeError: any) {
|
|
162
|
+
// If removal fails due to quota, try to clear some data first
|
|
163
|
+
if (removeError.name === 'QuotaExceededError' ||
|
|
164
|
+
removeError.code === 22 ||
|
|
165
|
+
removeError.message?.includes('quota')) {
|
|
166
|
+
console.warn('sessionStorage quota exceeded during removal, clearing old data...');
|
|
167
|
+
clearOldData();
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
window.sessionStorage.removeItem(key);
|
|
171
|
+
window.sessionStorage.removeItem(`${key}_timestamp`);
|
|
172
|
+
} catch (retryError) {
|
|
173
|
+
console.error(`Failed to remove sessionStorage key "${key}" after clearing:`, retryError);
|
|
174
|
+
// If still fails, force clear all
|
|
175
|
+
forceClearAll();
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
throw removeError;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
} catch (error) {
|
|
183
|
+
console.error(`Error removing sessionStorage key "${key}":`, error);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
return [storedValue, setValue, removeValue] as const;
|
|
188
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
export type Theme = 'light' | 'dark';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Hook to detect and track the current theme
|
|
9
|
+
* Supports both manual theme switching and system preference
|
|
10
|
+
*/
|
|
11
|
+
export const useTheme = (): Theme => {
|
|
12
|
+
const [theme, setTheme] = useState<Theme>('light');
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const checkTheme = (): Theme => {
|
|
16
|
+
// Check if dark class is applied to html element (manual theme)
|
|
17
|
+
if (document.documentElement.classList.contains('dark')) {
|
|
18
|
+
return 'dark';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Check system preference
|
|
22
|
+
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
23
|
+
return 'dark';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return 'light';
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Set initial theme
|
|
30
|
+
setTheme(checkTheme());
|
|
31
|
+
|
|
32
|
+
// Listen for manual theme changes (class changes on html element)
|
|
33
|
+
const observer = new MutationObserver(() => {
|
|
34
|
+
setTheme(checkTheme());
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
observer.observe(document.documentElement, {
|
|
38
|
+
attributes: true,
|
|
39
|
+
attributeFilter: ['class']
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Listen for system theme changes
|
|
43
|
+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
44
|
+
const handleMediaChange = () => {
|
|
45
|
+
setTheme(checkTheme());
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
mediaQuery.addEventListener('change', handleMediaChange);
|
|
49
|
+
|
|
50
|
+
return () => {
|
|
51
|
+
observer.disconnect();
|
|
52
|
+
mediaQuery.removeEventListener('change', handleMediaChange);
|
|
53
|
+
};
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
return theme;
|
|
57
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// @djangocfg/ui - Main Export File
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
// Re-export everything from components
|
|
6
|
+
export * from './components';
|
|
7
|
+
|
|
8
|
+
// Re-export hooks
|
|
9
|
+
export * from './hooks';
|
|
10
|
+
|
|
11
|
+
// Re-export blocks
|
|
12
|
+
export * from './blocks';
|
|
13
|
+
|
|
14
|
+
// Re-export animations
|
|
15
|
+
export * from './animations';
|
|
16
|
+
|
|
17
|
+
// Re-export lib utilities
|
|
18
|
+
export * from './lib';
|
|
19
|
+
|
|
20
|
+
// Re-export tools
|
|
21
|
+
export * from './tools';
|
|
22
|
+
|
|
23
|
+
// Re-export theme
|
|
24
|
+
export * from './theme';
|
package/src/lib/index.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ForceTheme - Force a specific theme for a section
|
|
3
|
+
*
|
|
4
|
+
* Wraps content to override the global theme setting.
|
|
5
|
+
* Works by adding both the theme class and inline CSS variables
|
|
6
|
+
* to ensure proper theme application regardless of parent context.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use client';
|
|
10
|
+
|
|
11
|
+
import React, { ReactNode } from 'react';
|
|
12
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
13
|
+
|
|
14
|
+
interface ForceThemeProps {
|
|
15
|
+
theme: 'light' | 'dark';
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Dark theme CSS variables
|
|
21
|
+
const darkThemeVars = {
|
|
22
|
+
// Base HSL values
|
|
23
|
+
'--background': '0 0% 4%',
|
|
24
|
+
'--foreground': '0 0% 98%',
|
|
25
|
+
'--card': '0 0% 8%',
|
|
26
|
+
'--card-foreground': '0 0% 98%',
|
|
27
|
+
'--popover': '0 0% 12%',
|
|
28
|
+
'--popover-foreground': '0 0% 98%',
|
|
29
|
+
'--primary': '217 91% 60%',
|
|
30
|
+
'--primary-foreground': '0 0% 100%',
|
|
31
|
+
'--secondary': '0 0% 98%',
|
|
32
|
+
'--secondary-foreground': '0 0% 9%',
|
|
33
|
+
'--muted': '0 0% 10%',
|
|
34
|
+
'--muted-foreground': '0 0% 60%',
|
|
35
|
+
'--accent': '0 0% 15%',
|
|
36
|
+
'--accent-foreground': '0 0% 98%',
|
|
37
|
+
'--destructive': '0 84% 60%',
|
|
38
|
+
'--destructive-foreground': '0 0% 98%',
|
|
39
|
+
'--border': '0 0% 15%',
|
|
40
|
+
'--input': '0 0% 15%',
|
|
41
|
+
'--ring': '217 91% 60%',
|
|
42
|
+
// Tailwind color tokens (used by bg-*, text-*, etc)
|
|
43
|
+
'--color-background': 'hsl(0 0% 4%)',
|
|
44
|
+
'--color-foreground': 'hsl(0 0% 98%)',
|
|
45
|
+
'--color-card': 'hsl(0 0% 8%)',
|
|
46
|
+
'--color-card-foreground': 'hsl(0 0% 98%)',
|
|
47
|
+
'--color-primary': 'hsl(217 91% 60%)',
|
|
48
|
+
'--color-primary-foreground': 'hsl(0 0% 100%)',
|
|
49
|
+
'--color-secondary': 'hsl(0 0% 98%)',
|
|
50
|
+
'--color-secondary-foreground': 'hsl(0 0% 9%)',
|
|
51
|
+
'--color-muted': 'hsl(0 0% 10%)',
|
|
52
|
+
'--color-muted-foreground': 'hsl(0 0% 60%)',
|
|
53
|
+
'--color-accent': 'hsl(0 0% 15%)',
|
|
54
|
+
'--color-accent-foreground': 'hsl(0 0% 98%)',
|
|
55
|
+
'--color-destructive': 'hsl(0 84% 60%)',
|
|
56
|
+
'--color-destructive-foreground': 'hsl(0 0% 98%)',
|
|
57
|
+
'--color-border': 'hsl(0 0% 15%)',
|
|
58
|
+
'--color-input': 'hsl(0 0% 15%)',
|
|
59
|
+
'--color-ring': 'hsl(217 91% 60%)',
|
|
60
|
+
} as React.CSSProperties;
|
|
61
|
+
|
|
62
|
+
// Light theme CSS variables
|
|
63
|
+
const lightThemeVars = {
|
|
64
|
+
// Base HSL values
|
|
65
|
+
'--background': '0 0% 96%',
|
|
66
|
+
'--foreground': '0 0% 9%',
|
|
67
|
+
'--card': '0 0% 100%',
|
|
68
|
+
'--card-foreground': '0 0% 9%',
|
|
69
|
+
'--popover': '0 0% 100%',
|
|
70
|
+
'--popover-foreground': '0 0% 9%',
|
|
71
|
+
'--primary': '217 91% 60%',
|
|
72
|
+
'--primary-foreground': '0 0% 100%',
|
|
73
|
+
'--secondary': '0 0% 9%',
|
|
74
|
+
'--secondary-foreground': '0 0% 98%',
|
|
75
|
+
'--muted': '0 0% 96%',
|
|
76
|
+
'--muted-foreground': '0 0% 40%',
|
|
77
|
+
'--accent': '0 0% 92%',
|
|
78
|
+
'--accent-foreground': '0 0% 9%',
|
|
79
|
+
'--destructive': '0 84% 60%',
|
|
80
|
+
'--destructive-foreground': '0 0% 98%',
|
|
81
|
+
'--border': '0 0% 90%',
|
|
82
|
+
'--input': '0 0% 90%',
|
|
83
|
+
'--ring': '217 91% 60%',
|
|
84
|
+
// Tailwind color tokens (used by bg-*, text-*, etc)
|
|
85
|
+
'--color-background': 'hsl(0 0% 96%)',
|
|
86
|
+
'--color-foreground': 'hsl(0 0% 9%)',
|
|
87
|
+
'--color-card': 'hsl(0 0% 100%)',
|
|
88
|
+
'--color-card-foreground': 'hsl(0 0% 9%)',
|
|
89
|
+
'--color-primary': 'hsl(217 91% 60%)',
|
|
90
|
+
'--color-primary-foreground': 'hsl(0 0% 100%)',
|
|
91
|
+
'--color-secondary': 'hsl(0 0% 9%)',
|
|
92
|
+
'--color-secondary-foreground': 'hsl(0 0% 98%)',
|
|
93
|
+
'--color-muted': 'hsl(0 0% 96%)',
|
|
94
|
+
'--color-muted-foreground': 'hsl(0 0% 40%)',
|
|
95
|
+
'--color-accent': 'hsl(0 0% 92%)',
|
|
96
|
+
'--color-accent-foreground': 'hsl(0 0% 9%)',
|
|
97
|
+
'--color-destructive': 'hsl(0 84% 60%)',
|
|
98
|
+
'--color-destructive-foreground': 'hsl(0 0% 98%)',
|
|
99
|
+
'--color-border': 'hsl(0 0% 90%)',
|
|
100
|
+
'--color-input': 'hsl(0 0% 90%)',
|
|
101
|
+
'--color-ring': 'hsl(217 91% 60%)',
|
|
102
|
+
} as React.CSSProperties;
|
|
103
|
+
|
|
104
|
+
export function ForceTheme({ theme, children, className }: ForceThemeProps) {
|
|
105
|
+
const themeVars = theme === 'dark' ? darkThemeVars : lightThemeVars;
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div
|
|
109
|
+
className={cn(theme, className)}
|
|
110
|
+
style={themeVars}
|
|
111
|
+
>
|
|
112
|
+
{children}
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ThemeProvider - Universal theme management
|
|
3
|
+
*
|
|
4
|
+
* Provides theme context for the entire application with localStorage persistence.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use client';
|
|
8
|
+
|
|
9
|
+
import React, { createContext, useContext, useEffect, ReactNode } from 'react';
|
|
10
|
+
import { useLocalStorage } from '../hooks/useLocalStorage';
|
|
11
|
+
|
|
12
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
13
|
+
// Types
|
|
14
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
type Theme = 'light' | 'dark';
|
|
17
|
+
|
|
18
|
+
interface ThemeContextValue {
|
|
19
|
+
theme: Theme;
|
|
20
|
+
setTheme: (theme: Theme) => void;
|
|
21
|
+
toggleTheme: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
25
|
+
// Create Context
|
|
26
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
|
29
|
+
|
|
30
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
31
|
+
// Provider Component
|
|
32
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
interface ThemeProviderProps {
|
|
35
|
+
children: ReactNode;
|
|
36
|
+
defaultTheme?: Theme;
|
|
37
|
+
storageKey?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function ThemeProvider({
|
|
41
|
+
children,
|
|
42
|
+
defaultTheme = 'light',
|
|
43
|
+
storageKey = 'theme'
|
|
44
|
+
}: ThemeProviderProps) {
|
|
45
|
+
const [theme, setTheme] = useLocalStorage<Theme>(storageKey, defaultTheme);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const root = window.document.documentElement;
|
|
49
|
+
root.classList.remove('light', 'dark');
|
|
50
|
+
root.classList.add(theme);
|
|
51
|
+
}, [theme]);
|
|
52
|
+
|
|
53
|
+
const toggleTheme = () => {
|
|
54
|
+
setTheme(theme === 'light' ? 'dark' : 'light');
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const value: ThemeContextValue = {
|
|
58
|
+
theme,
|
|
59
|
+
setTheme,
|
|
60
|
+
toggleTheme,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<ThemeContext.Provider value={value}>
|
|
65
|
+
{children}
|
|
66
|
+
</ThemeContext.Provider>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
71
|
+
// Custom Hook
|
|
72
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
export function useThemeContext(): ThemeContextValue {
|
|
75
|
+
const context = useContext(ThemeContext);
|
|
76
|
+
|
|
77
|
+
if (context === undefined) {
|
|
78
|
+
throw new Error('useThemeContext must be used within ThemeProvider');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return context;
|
|
82
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ThemeToggle - Theme switcher component
|
|
3
|
+
*
|
|
4
|
+
* Switches between light and dark themes by toggling the 'dark' class on the html element.
|
|
5
|
+
* Uses localStorage to persist the user's theme preference.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* import { ThemeToggle } from '@djangocfg/ui-nextjs';
|
|
10
|
+
*
|
|
11
|
+
* <ThemeToggle />
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use client';
|
|
16
|
+
|
|
17
|
+
import { useEffect, useState } from 'react';
|
|
18
|
+
import { Moon, Sun } from 'lucide-react';
|
|
19
|
+
import { Button } from '@djangocfg/ui-core/components';
|
|
20
|
+
import { useThemeContext } from './ThemeProvider';
|
|
21
|
+
|
|
22
|
+
export function ThemeToggle() {
|
|
23
|
+
const { theme, toggleTheme } = useThemeContext();
|
|
24
|
+
const [isMounted, setIsMounted] = useState(false);
|
|
25
|
+
|
|
26
|
+
// Prevent hydration mismatch by only rendering after mount
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
setIsMounted(true);
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
// Don't render anything during SSR
|
|
32
|
+
if (!isMounted) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Button
|
|
38
|
+
variant="ghost"
|
|
39
|
+
size="icon"
|
|
40
|
+
onClick={toggleTheme}
|
|
41
|
+
className="h-9 w-9"
|
|
42
|
+
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} theme`}
|
|
43
|
+
>
|
|
44
|
+
{theme === 'light' ? (
|
|
45
|
+
<Sun className="h-4 w-4" />
|
|
46
|
+
) : (
|
|
47
|
+
<Moon className="h-4 w-4" />
|
|
48
|
+
)}
|
|
49
|
+
<span className="sr-only">Toggle theme</span>
|
|
50
|
+
</Button>
|
|
51
|
+
);
|
|
52
|
+
}
|