@djangocfg/ui-nextjs 2.1.4 → 2.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-nextjs",
3
- "version": "2.1.4",
3
+ "version": "2.1.5",
4
4
  "description": "Next.js UI component library with Radix UI primitives, Tailwind CSS styling, charts, and form components",
5
5
  "keywords": [
6
6
  "ui-components",
@@ -58,10 +58,11 @@
58
58
  "check": "tsc --noEmit"
59
59
  },
60
60
  "peerDependencies": {
61
- "@djangocfg/api": "^2.1.4",
62
- "@djangocfg/ui-core": "^1.0.4",
61
+ "@djangocfg/api": "^2.1.5",
62
+ "@djangocfg/ui-core": "^2.1.5",
63
63
  "@types/react": "^19.1.0",
64
64
  "@types/react-dom": "^19.1.0",
65
+ "consola": "^3.4.2",
65
66
  "lucide-react": "^0.545.0",
66
67
  "moment": "^2.30.1",
67
68
  "next": "^15.5.7",
@@ -69,13 +70,9 @@
69
70
  "react-dom": "^19.1.0",
70
71
  "react-hook-form": "7.65.0",
71
72
  "tailwindcss": "^4.1.14",
72
- "zod": "^4.1.13",
73
- "consola": "^3.4.2"
73
+ "zod": "^4.1.13"
74
74
  },
75
75
  "dependencies": {
76
- "class-variance-authority": "^0.7.1",
77
- "libphonenumber-js": "^1.12.24",
78
- "sonner": "2.0.7",
79
76
  "@radix-ui/react-dropdown-menu": "^2.1.16",
80
77
  "@radix-ui/react-menubar": "^1.1.16",
81
78
  "@radix-ui/react-navigation-menu": "^1.2.14",
@@ -83,26 +80,30 @@
83
80
  "@rjsf/core": "^6.1.2",
84
81
  "@rjsf/utils": "^6.1.2",
85
82
  "@rjsf/validator-ajv8": "^6.1.2",
83
+ "@vidstack/react": "next",
86
84
  "@web3icons/react": "^4.0.26",
87
85
  "chart.js": "^4.5.0",
86
+ "class-variance-authority": "^0.7.1",
88
87
  "cytoscape": "^3.33.1",
89
88
  "cytoscape-cose-bilkent": "^4.1.0",
89
+ "libphonenumber-js": "^1.12.24",
90
+ "media-icons": "next",
90
91
  "mermaid": "^11.12.0",
91
92
  "next-themes": "^0.4.6",
92
93
  "prism-react-renderer": "^2.4.1",
93
94
  "react-chartjs-2": "^5.3.0",
95
+ "react-hotkeys-hook": "^5.2.1",
94
96
  "react-json-tree": "^0.20.0",
95
97
  "react-lottie-player": "^2.1.0",
96
98
  "react-markdown": "10.1.0",
97
- "remark-gfm": "4.0.1",
98
99
  "react-sticky-box": "^2.0.5",
99
100
  "recharts": "2.15.4",
100
- "@vidstack/react": "next",
101
- "media-icons": "next",
101
+ "remark-gfm": "4.0.1",
102
+ "sonner": "2.0.7",
102
103
  "vidstack": "next"
103
104
  },
104
105
  "devDependencies": {
105
- "@djangocfg/typescript-config": "^2.1.4",
106
+ "@djangocfg/typescript-config": "^2.1.5",
106
107
  "@types/node": "^24.7.2",
107
108
  "eslint": "^9.37.0",
108
109
  "tailwindcss-animate": "1.0.7",
@@ -5,10 +5,8 @@ import ReactMarkdown from 'react-markdown';
5
5
  import remarkGfm from 'remark-gfm';
6
6
  import Mermaid from '../../tools/Mermaid';
7
7
  import PrettyCode from '../../tools/PrettyCode';
8
- import { Button } from '@djangocfg/ui-core/components';
9
- import { useCopy } from '@djangocfg/ui-core/hooks';
10
- import { useTheme } from '../../hooks/useTheme';
11
- import { Copy } from 'lucide-react';
8
+ import { CopyButton } from '@djangocfg/ui-core/components';
9
+ import { useResolvedTheme } from '../../hooks/useResolvedTheme';
12
10
  import type { Components } from 'react-markdown';
13
11
 
14
12
  // Helper function to extract text content from React children
@@ -52,32 +50,24 @@ interface CodeBlockProps {
52
50
  }
53
51
 
54
52
  const CodeBlock: React.FC<CodeBlockProps> = ({ code, language, isUser }) => {
55
- const { copyToClipboard } = useCopy();
56
- const theme = useTheme();
57
-
58
- const handleCopy = () => {
59
- copyToClipboard(code, "Code copied to clipboard!");
60
- };
53
+ const theme = useResolvedTheme();
61
54
 
62
55
  return (
63
56
  <div className="relative group my-3">
64
57
  {/* Copy button */}
65
- <Button
58
+ <CopyButton
59
+ value={code}
66
60
  variant="ghost"
67
- size="sm"
68
- onClick={handleCopy}
69
61
  className={`
70
62
  absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity
71
- h-8 w-8 p-0
63
+ h-8 w-8
72
64
  ${isUser
73
65
  ? 'hover:bg-white/20 text-white'
74
66
  : 'hover:bg-muted-foreground/20 text-muted-foreground hover:text-foreground'
75
67
  }
76
68
  `}
77
69
  title="Copy code"
78
- >
79
- <Copy className="h-4 w-4" />
80
- </Button>
70
+ />
81
71
 
82
72
  {/* Code content */}
83
73
  <PrettyCode
@@ -195,24 +185,19 @@ const createMarkdownComponents = (isUser: boolean = false, isCompact: boolean =
195
185
  console.warn('CodeBlock failed, using fallback:', error);
196
186
  return (
197
187
  <div className="relative group my-3">
198
- <Button
188
+ <CopyButton
189
+ value={codeContent}
199
190
  variant="ghost"
200
- size="sm"
201
- onClick={() => {
202
- navigator.clipboard.writeText(codeContent);
203
- }}
204
191
  className={`
205
192
  absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity
206
- h-8 w-8 p-0
193
+ h-8 w-8
207
194
  ${isUser
208
195
  ? 'hover:bg-white/20 text-white'
209
196
  : 'hover:bg-muted-foreground/20 text-muted-foreground hover:text-foreground'
210
197
  }
211
198
  `}
212
199
  title="Copy code"
213
- >
214
- <Copy className="h-4 w-4" />
215
- </Button>
200
+ />
216
201
  <pre className={`
217
202
  p-3 rounded text-xs font-mono overflow-x-auto
218
203
  ${isUser
@@ -440,7 +440,9 @@ const SidebarFooter = React.forwardRef<
440
440
  })
441
441
  SidebarFooter.displayName = "SidebarFooter"
442
442
 
443
- const SidebarSeparator = React.forwardRef<
443
+ const SidebarSeparator: React.ForwardRefExoticComponent<
444
+ React.ComponentProps<typeof Separator> & React.RefAttributes<React.ElementRef<typeof Separator>>
445
+ > = React.forwardRef<
444
446
  React.ElementRef<typeof Separator>,
445
447
  React.ComponentProps<typeof Separator>
446
448
  >(({ className, ...props }, ref) => {
@@ -2,7 +2,6 @@
2
2
 
3
3
  import React from 'react';
4
4
  import { usePathname } from 'next/navigation';
5
- import Link from 'next/link';
6
5
  import { cn } from '@djangocfg/ui-core/lib';
7
6
  import { useIsMobile } from '@djangocfg/ui-core/hooks';
8
7
  import { useQueryParams } from '../hooks';
@@ -29,7 +28,6 @@ interface SSRPaginationProps {
29
28
  baseUrl?: string;
30
29
  pathname?: string;
31
30
  preserveQuery?: boolean;
32
- prefetch?: boolean;
33
31
  }
34
32
 
35
33
  export const SSRPagination: React.FC<SSRPaginationProps> = ({
@@ -45,7 +43,6 @@ export const SSRPagination: React.FC<SSRPaginationProps> = ({
45
43
  baseUrl,
46
44
  pathname: propPathname,
47
45
  preserveQuery = true,
48
- prefetch = true,
49
46
  }) => {
50
47
  const queryParams = useQueryParams();
51
48
  const pathname = usePathname();
@@ -186,17 +183,10 @@ export const SSRPagination: React.FC<SSRPaginationProps> = ({
186
183
  <PaginationContent>
187
184
  {/* Previous Button */}
188
185
  <PaginationItem>
189
- {actualHasPreviousPage ? (
190
- <Link href={getPageUrl(actualCurrentPage - 1)} prefetch={prefetch} passHref legacyBehavior>
191
- <PaginationPrevious />
192
- </Link>
193
- ) : (
194
- <PaginationPrevious
195
- href="#"
196
- className="pointer-events-none opacity-50"
197
- onClick={(e) => e.preventDefault()}
198
- />
199
- )}
186
+ <PaginationPrevious
187
+ href={actualHasPreviousPage ? getPageUrl(actualCurrentPage - 1) : undefined}
188
+ className={!actualHasPreviousPage ? "pointer-events-none opacity-50" : undefined}
189
+ />
200
190
  </PaginationItem>
201
191
 
202
192
  {/* Page Numbers */}
@@ -205,28 +195,22 @@ export const SSRPagination: React.FC<SSRPaginationProps> = ({
205
195
  {page === 'ellipsis' ? (
206
196
  <PaginationEllipsis />
207
197
  ) : (
208
- <Link href={getPageUrl(page)} prefetch={prefetch} passHref legacyBehavior>
209
- <PaginationLink isActive={page === actualCurrentPage}>
210
- {page}
211
- </PaginationLink>
212
- </Link>
198
+ <PaginationLink
199
+ href={getPageUrl(page)}
200
+ isActive={page === actualCurrentPage}
201
+ >
202
+ {page}
203
+ </PaginationLink>
213
204
  )}
214
205
  </PaginationItem>
215
206
  ))}
216
207
 
217
208
  {/* Next Button */}
218
209
  <PaginationItem>
219
- {hasNextPage ? (
220
- <Link href={getPageUrl(actualCurrentPage + 1)} prefetch={prefetch} passHref legacyBehavior>
221
- <PaginationNext />
222
- </Link>
223
- ) : (
224
- <PaginationNext
225
- href="#"
226
- className="pointer-events-none opacity-50"
227
- onClick={(e) => e.preventDefault()}
228
- />
229
- )}
210
+ <PaginationNext
211
+ href={hasNextPage ? getPageUrl(actualCurrentPage + 1) : undefined}
212
+ className={!hasNextPage ? "pointer-events-none opacity-50" : undefined}
213
+ />
230
214
  </PaginationItem>
231
215
  </PaginationContent>
232
216
  </Pagination>
@@ -11,9 +11,14 @@ export * from '@djangocfg/ui-core/hooks';
11
11
  export { useLocalStorage } from './useLocalStorage';
12
12
  export { useSessionStorage } from './useSessionStorage';
13
13
 
14
- // Theme hook (uses next-themes)
15
- export { useTheme } from './useTheme';
14
+ // Theme hook (standalone, no provider required)
15
+ export { useResolvedTheme } from './useResolvedTheme';
16
+ export type { ResolvedTheme } from './useResolvedTheme';
16
17
 
17
18
  // Next.js router hooks
18
19
  export { useQueryParams } from './useQueryParams';
19
20
  export { useCfgRouter } from './useCfgRouter';
21
+
22
+ // Keyboard shortcuts
23
+ export { useHotkey, useHotkeysContext, HotkeysProvider, isHotkeyPressed } from './useHotkey';
24
+ export type { UseHotkeyOptions, HotkeyCallback, Keys } from './useHotkey';
@@ -0,0 +1,103 @@
1
+ 'use client';
2
+
3
+ import type { RefObject } from 'react';
4
+ import { useHotkeys, Options as HotkeysOptions } from 'react-hotkeys-hook';
5
+ import type { HotkeyCallback, Keys } from 'react-hotkeys-hook';
6
+
7
+ /**
8
+ * Options for the useHotkey hook
9
+ */
10
+ export interface UseHotkeyOptions extends Omit<HotkeysOptions, 'enabled'> {
11
+ /** Whether the hotkey is enabled (default: true) */
12
+ enabled?: boolean;
13
+ /** Scope for the hotkey - useful for context-specific shortcuts */
14
+ scope?: string;
15
+ /** Only trigger when focus is within a specific element */
16
+ scopes?: string[];
17
+ /** Prevent default browser behavior */
18
+ preventDefault?: boolean;
19
+ /** Enable in input fields and textareas */
20
+ enableOnFormTags?: boolean | readonly ('input' | 'textarea' | 'select')[];
21
+ /** Enable when contentEditable element is focused */
22
+ enableOnContentEditable?: boolean;
23
+ /** Split key for multiple hotkey combinations (default: ',') */
24
+ splitKey?: string;
25
+ /** Key up/down events */
26
+ keyup?: boolean;
27
+ keydown?: boolean;
28
+ /** Description for the hotkey (useful for help dialogs) */
29
+ description?: string;
30
+ }
31
+
32
+ /**
33
+ * Simple wrapper hook for react-hotkeys-hook
34
+ *
35
+ * @example
36
+ * // Single key
37
+ * useHotkey('escape', () => closeModal());
38
+ *
39
+ * @example
40
+ * // Key combination
41
+ * useHotkey('ctrl+s', (e) => {
42
+ * e.preventDefault();
43
+ * saveDocument();
44
+ * });
45
+ *
46
+ * @example
47
+ * // Multiple keys (any of them will trigger)
48
+ * useHotkey(['ArrowLeft', '['], () => goToPrevious());
49
+ * useHotkey(['ArrowRight', ']'], () => goToNext());
50
+ *
51
+ * @example
52
+ * // With options
53
+ * useHotkey('/', () => focusSearch(), {
54
+ * preventDefault: true,
55
+ * enableOnFormTags: false,
56
+ * description: 'Focus search input'
57
+ * });
58
+ *
59
+ * @example
60
+ * // Scoped hotkeys
61
+ * useHotkey('delete', () => deleteItem(), { scopes: ['list-view'] });
62
+ *
63
+ * @param keys - Hotkey or array of hotkeys (e.g., 'ctrl+s', 'ArrowLeft', ['[', 'ArrowLeft'])
64
+ * @param callback - Function to call when hotkey is pressed
65
+ * @param options - Configuration options
66
+ * @returns Ref to attach to element for scoped hotkeys
67
+ */
68
+ export function useHotkey<T extends HTMLElement = HTMLElement>(
69
+ keys: Keys,
70
+ callback: HotkeyCallback,
71
+ options: UseHotkeyOptions = {}
72
+ ): RefObject<T | null> {
73
+ const {
74
+ enabled = true,
75
+ preventDefault = false,
76
+ enableOnFormTags = false,
77
+ enableOnContentEditable = false,
78
+ description: _description,
79
+ ...restOptions
80
+ } = options;
81
+
82
+ return useHotkeys<T>(
83
+ keys,
84
+ (event, handler) => {
85
+ if (preventDefault) {
86
+ event.preventDefault();
87
+ }
88
+ callback(event, handler);
89
+ },
90
+ {
91
+ enabled,
92
+ enableOnFormTags,
93
+ enableOnContentEditable,
94
+ ...restOptions,
95
+ }
96
+ );
97
+ }
98
+
99
+ // Re-export useful utilities from react-hotkeys-hook
100
+ export { useHotkeysContext, HotkeysProvider, isHotkeyPressed } from 'react-hotkeys-hook';
101
+
102
+ // Re-export types
103
+ export type { HotkeyCallback, Keys } from 'react-hotkeys-hook';
@@ -2,56 +2,67 @@
2
2
 
3
3
  import { useEffect, useState } from 'react';
4
4
 
5
- export type Theme = 'light' | 'dark';
5
+ export type ResolvedTheme = 'light' | 'dark';
6
6
 
7
7
  /**
8
- * Hook to detect and track the current theme
9
- * Supports both manual theme switching and system preference
8
+ * Hook to detect the current resolved theme (light or dark)
9
+ *
10
+ * Standalone hook - doesn't require ThemeProvider.
11
+ * Detects theme from:
12
+ * 1. 'dark' class on html element
13
+ * 2. System preference (prefers-color-scheme)
14
+ *
15
+ * For full theme control (setTheme, toggleTheme), use useThemeContext instead.
16
+ *
17
+ * @example
18
+ * ```tsx
19
+ * const theme = useResolvedTheme(); // 'light' | 'dark'
20
+ * ```
10
21
  */
11
- export const useTheme = (): Theme => {
12
- const [theme, setTheme] = useState<Theme>('light');
13
-
22
+ export const useResolvedTheme = (): ResolvedTheme => {
23
+ const [theme, setTheme] = useState<ResolvedTheme>('light');
24
+
14
25
  useEffect(() => {
15
- const checkTheme = (): Theme => {
16
- // Check if dark class is applied to html element (manual theme)
26
+ const checkTheme = (): ResolvedTheme => {
27
+ // Check if dark class is applied to html element
17
28
  if (document.documentElement.classList.contains('dark')) {
18
29
  return 'dark';
19
30
  }
20
-
31
+
21
32
  // Check system preference
22
33
  if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
23
34
  return 'dark';
24
35
  }
25
-
36
+
26
37
  return 'light';
27
38
  };
28
-
39
+
29
40
  // Set initial theme
30
41
  setTheme(checkTheme());
31
-
32
- // Listen for manual theme changes (class changes on html element)
42
+
43
+ // Listen for class changes on html element
33
44
  const observer = new MutationObserver(() => {
34
45
  setTheme(checkTheme());
35
46
  });
36
-
47
+
37
48
  observer.observe(document.documentElement, {
38
49
  attributes: true,
39
50
  attributeFilter: ['class']
40
51
  });
41
-
52
+
42
53
  // Listen for system theme changes
43
54
  const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
44
55
  const handleMediaChange = () => {
45
56
  setTheme(checkTheme());
46
57
  };
47
-
58
+
48
59
  mediaQuery.addEventListener('change', handleMediaChange);
49
-
60
+
50
61
  return () => {
51
62
  observer.disconnect();
52
63
  mediaQuery.removeEventListener('change', handleMediaChange);
53
64
  };
54
65
  }, []);
55
-
66
+
56
67
  return theme;
57
68
  };
@@ -1,82 +1,76 @@
1
1
  /**
2
2
  * ThemeProvider - Universal theme management
3
3
  *
4
- * Provides theme context for the entire application with localStorage persistence.
4
+ * Re-exports next-themes ThemeProvider with sensible defaults.
5
+ * Supports light, dark, and system themes with localStorage persistence.
6
+ *
7
+ * @example
8
+ * ```tsx
9
+ * // In app/layout.tsx
10
+ * import { ThemeProvider } from '@djangocfg/ui-nextjs';
11
+ *
12
+ * <ThemeProvider
13
+ * attribute="class"
14
+ * defaultTheme="system"
15
+ * enableSystem
16
+ * >
17
+ * {children}
18
+ * </ThemeProvider>
19
+ * ```
5
20
  */
6
21
 
7
22
  'use client';
8
23
 
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
- // ─────────────────────────────────────────────────────────────────────────
24
+ import { ThemeProvider as NextThemesProvider, useTheme as useNextTheme } from 'next-themes';
25
+ import type { ThemeProviderProps as NextThemesProviderProps } from 'next-themes';
27
26
 
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
- }
27
+ // Re-export types
28
+ export type Theme = 'light' | 'dark' | 'system';
29
+ export type ThemeProviderProps = NextThemesProviderProps;
39
30
 
31
+ /**
32
+ * ThemeProvider wraps next-themes with sensible defaults
33
+ */
40
34
  export function ThemeProvider({
41
35
  children,
42
- defaultTheme = 'light',
43
- storageKey = 'theme'
36
+ attribute = 'class',
37
+ defaultTheme = 'system',
38
+ enableSystem = true,
39
+ disableTransitionOnChange = true,
40
+ ...props
44
41
  }: ThemeProviderProps) {
45
- const [theme, setTheme] = useLocalStorage<Theme>(storageKey, defaultTheme);
42
+ return (
43
+ <NextThemesProvider
44
+ attribute={attribute}
45
+ defaultTheme={defaultTheme}
46
+ enableSystem={enableSystem}
47
+ disableTransitionOnChange={disableTransitionOnChange}
48
+ {...props}
49
+ >
50
+ {children}
51
+ </NextThemesProvider>
52
+ );
53
+ }
46
54
 
47
- useEffect(() => {
48
- const root = window.document.documentElement;
49
- root.classList.remove('light', 'dark');
50
- root.classList.add(theme);
51
- }, [theme]);
55
+ /**
56
+ * Hook to access theme context
57
+ * Returns theme, setTheme, resolvedTheme, systemTheme, and themes
58
+ */
59
+ export function useThemeContext() {
60
+ const { theme, setTheme, resolvedTheme, systemTheme, themes } = useNextTheme();
52
61
 
53
62
  const toggleTheme = () => {
54
- setTheme(theme === 'light' ? 'dark' : 'light');
63
+ // Toggle between light and dark (ignore system)
64
+ const currentResolved = resolvedTheme || 'light';
65
+ setTheme(currentResolved === 'light' ? 'dark' : 'light');
55
66
  };
56
67
 
57
- const value: ThemeContextValue = {
58
- theme,
68
+ return {
69
+ theme: theme as Theme | undefined,
59
70
  setTheme,
71
+ resolvedTheme: resolvedTheme as 'light' | 'dark' | undefined,
72
+ systemTheme: systemTheme as 'light' | 'dark' | undefined,
73
+ themes,
60
74
  toggleTheme,
61
75
  };
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
76
  }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * ThemeToggle - Theme switcher component
3
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.
4
+ * Switches between light and dark themes.
5
+ * Must be used within ThemeProvider.
6
6
  *
7
7
  * @example
8
8
  * ```tsx
@@ -20,17 +20,21 @@ import { Button } from '@djangocfg/ui-core/components';
20
20
  import { useThemeContext } from './ThemeProvider';
21
21
 
22
22
  export function ThemeToggle() {
23
- const { theme, toggleTheme } = useThemeContext();
24
- const [isMounted, setIsMounted] = useState(false);
23
+ const { resolvedTheme, toggleTheme } = useThemeContext();
24
+ const [mounted, setMounted] = useState(false);
25
25
 
26
- // Prevent hydration mismatch by only rendering after mount
26
+ // Prevent hydration mismatch
27
27
  useEffect(() => {
28
- setIsMounted(true);
28
+ setMounted(true);
29
29
  }, []);
30
30
 
31
- // Don't render anything during SSR
32
- if (!isMounted) {
33
- return null;
31
+ if (!mounted) {
32
+ return (
33
+ <Button variant="ghost" size="icon" className="h-9 w-9" disabled>
34
+ <Sun className="h-4 w-4" />
35
+ <span className="sr-only">Toggle theme</span>
36
+ </Button>
37
+ );
34
38
  }
35
39
 
36
40
  return (
@@ -39,9 +43,9 @@ export function ThemeToggle() {
39
43
  size="icon"
40
44
  onClick={toggleTheme}
41
45
  className="h-9 w-9"
42
- title={`Switch to ${theme === 'light' ? 'dark' : 'light'} theme`}
46
+ title={`Switch to ${resolvedTheme === 'light' ? 'dark' : 'light'} theme`}
43
47
  >
44
- {theme === 'light' ? (
48
+ {resolvedTheme === 'light' ? (
45
49
  <Sun className="h-4 w-4" />
46
50
  ) : (
47
51
  <Moon className="h-4 w-4" />
@@ -1,3 +1,4 @@
1
1
  export { ThemeProvider, useThemeContext } from './ThemeProvider';
2
+ export type { Theme, ThemeProviderProps } from './ThemeProvider';
2
3
  export { ThemeToggle } from './ThemeToggle';
3
- export { ForceTheme } from './ForceTheme';
4
+ export { ForceTheme } from './ForceTheme';
@@ -2,9 +2,8 @@
2
2
 
3
3
  import React, { useState } from 'react';
4
4
  import { CommonExternalProps, JSONTree } from 'react-json-tree';
5
- import { ChevronDown, ChevronUp, Copy, Download } from 'lucide-react';
6
- import { Button } from '@djangocfg/ui-core/components';
7
- import { useCopy } from '@djangocfg/ui-core/hooks';
5
+ import { ChevronDown, ChevronUp, Download } from 'lucide-react';
6
+ import { Button, CopyButton } from '@djangocfg/ui-core/components';
8
7
 
9
8
  export type { Language } from 'prism-react-renderer';
10
9
 
@@ -43,12 +42,6 @@ const JsonTreeComponent = ({ title, data, config = {}, jsonTreeProps = {} }: Jso
43
42
  // State for expand/collapse all
44
43
  const [expandAll, setExpandAll] = useState<boolean | null>(null);
45
44
  const [renderKey, setRenderKey] = useState(0);
46
-
47
- // Copy hook
48
- const { copyToClipboard } = useCopy({
49
- successMessage: "JSON copied to clipboard",
50
- errorMessage: "Failed to copy JSON"
51
- });
52
45
 
53
46
  // Default configuration
54
47
  const {
@@ -142,14 +135,11 @@ const JsonTreeComponent = ({ title, data, config = {}, jsonTreeProps = {} }: Jso
142
135
  return false;
143
136
  };
144
137
 
145
- // Action handlers
146
- const handleCopy = () => {
147
- const jsonString = JSON.stringify(data, null, 2);
148
- copyToClipboard(jsonString);
149
- };
138
+ // JSON string for copy/download
139
+ const jsonString = JSON.stringify(data, null, 2);
150
140
 
141
+ // Action handlers
151
142
  const handleDownload = () => {
152
- const jsonString = JSON.stringify(data, null, 2);
153
143
  const blob = new Blob([jsonString], { type: 'application/json' });
154
144
  const url = URL.createObjectURL(blob);
155
145
  const a = document.createElement('a');
@@ -202,15 +192,15 @@ const JsonTreeComponent = ({ title, data, config = {}, jsonTreeProps = {} }: Jso
202
192
  {/* Action Buttons */}
203
193
  {showActionButtons && (
204
194
  <>
205
- <Button
195
+ <CopyButton
196
+ value={jsonString}
206
197
  variant="outline"
207
198
  size="sm"
208
- onClick={handleCopy}
209
199
  className="h-8 px-2"
200
+ iconClassName="h-3 w-3"
210
201
  >
211
- <Copy className="h-3 w-3" />
212
- <span className="ml-1 text-xs hidden sm:inline">Copy</span>
213
- </Button>
202
+ Copy
203
+ </CopyButton>
214
204
  <Button
215
205
  variant="outline"
216
206
  size="sm"
@@ -3,7 +3,7 @@
3
3
  import mermaid from 'mermaid';
4
4
  import React, { useEffect, useRef, useState } from 'react';
5
5
  import { createPortal } from 'react-dom';
6
- import { useTheme } from '../../hooks/useTheme';
6
+ import { useResolvedTheme } from '../../hooks/useResolvedTheme';
7
7
 
8
8
  interface MermaidProps {
9
9
  chart: string;
@@ -48,7 +48,7 @@ const Mermaid: React.FC<MermaidProps> = ({ chart, className = '' }) => {
48
48
  const [isFullscreen, setIsFullscreen] = useState(false);
49
49
  const [svgContent, setSvgContent] = useState<string>('');
50
50
  const [isVertical, setIsVertical] = useState(false);
51
- const theme = useTheme();
51
+ const theme = useResolvedTheme();
52
52
 
53
53
  useEffect(() => {
54
54
  // Get CSS variables for semantic colors
@@ -1,15 +1,29 @@
1
1
  'use client';
2
2
 
3
- import React from 'react';
4
- import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Collapsible, CollapsibleContent, CollapsibleTrigger } from '@djangocfg/ui-core/components';
5
- import { ChevronDown, Code, Database, FileText, AlertCircle, Copy } from 'lucide-react';
3
+ import React, { useMemo } from 'react';
4
+ import { Badge, Card, CardContent, CardHeader, CardTitle, Collapsible, CollapsibleContent, CollapsibleTrigger, CopyButton } from '@djangocfg/ui-core/components';
5
+ import { ChevronDown, Code, Database, FileText, AlertCircle } from 'lucide-react';
6
6
  import { usePlaygroundContext } from '../context/PlaygroundContext';
7
7
  import { getMethodColor, getStatusColor } from '../utils';
8
8
 
9
9
  export const EndpointInfo: React.FC = () => {
10
- const { state, copyToClipboard } = usePlaygroundContext();
10
+ const { state } = usePlaygroundContext();
11
11
  const { selectedEndpoint } = state;
12
12
 
13
+ // Memoize endpoint JSON for copy
14
+ const endpointJson = useMemo(() => {
15
+ if (!selectedEndpoint) return '';
16
+ return JSON.stringify({
17
+ name: selectedEndpoint.name,
18
+ method: selectedEndpoint.method,
19
+ path: selectedEndpoint.path,
20
+ description: selectedEndpoint.description,
21
+ parameters: selectedEndpoint.parameters,
22
+ requestBody: selectedEndpoint.requestBody,
23
+ responses: selectedEndpoint.responses
24
+ }, null, 2);
25
+ }, [selectedEndpoint]);
26
+
13
27
  if (!selectedEndpoint) {
14
28
  return null;
15
29
  }
@@ -22,28 +36,13 @@ export const EndpointInfo: React.FC = () => {
22
36
  ));
23
37
  };
24
38
 
25
- const handleCopyEndpoint = () => {
26
- const endpointDetails = {
27
- name: selectedEndpoint.name,
28
- method: selectedEndpoint.method,
29
- path: selectedEndpoint.path,
30
- description: selectedEndpoint.description,
31
- parameters: selectedEndpoint.parameters,
32
- requestBody: selectedEndpoint.requestBody,
33
- responses: selectedEndpoint.responses
34
- };
35
-
36
- copyToClipboard(JSON.stringify(endpointDetails, null, 2));
37
- };
38
-
39
39
  return (
40
40
  <div className="space-y-4">
41
41
  <div className="flex items-center justify-between">
42
42
  <h2 className="text-lg font-semibold text-foreground">Selected Endpoint</h2>
43
- <Button variant="outline" size="sm" onClick={handleCopyEndpoint}>
44
- <Copy className="h-4 w-4" />
43
+ <CopyButton value={endpointJson} variant="outline" size="sm">
45
44
  Copy
46
- </Button>
45
+ </CopyButton>
47
46
  </div>
48
47
 
49
48
  <Card>
@@ -1,10 +1,10 @@
1
1
  'use client';
2
2
 
3
3
  import React, { useCallback } from 'react';
4
- import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Textarea, Badge, Skeleton } from '@djangocfg/ui-core/components';
5
- import { Play, Copy, RotateCcw, Loader2, Key, Settings, Send } from 'lucide-react';
4
+ import { Button, Card, CardContent, CardHeader, CardTitle, Input, Textarea, CopyButton } from '@djangocfg/ui-core/components';
5
+ import { Loader2, Key, Send } from 'lucide-react';
6
6
  import { usePlaygroundContext } from '../context/PlaygroundContext';
7
- import { findApiKeyById, logApiKeyUsage } from '../utils';
7
+ import { findApiKeyById } from '../utils';
8
8
  import { isValidJson, parseRequestHeaders } from '../utils';
9
9
  import { RequestParametersForm } from './RequestParametersForm';
10
10
  import { EndpointInfo } from './EndpointInfo';
@@ -14,16 +14,8 @@ export const RequestBuilder: React.FC = () => {
14
14
  const {
15
15
  state,
16
16
  apiKeys,
17
- apiKeysLoading,
18
- setRequestUrl,
19
- setRequestMethod,
20
- setRequestHeaders,
21
17
  setRequestBody,
22
- setResponse,
23
- setLoading,
24
- setSelectedApiKey,
25
18
  setManualApiToken,
26
- copyToClipboard,
27
19
  sendRequest
28
20
  } = usePlaygroundContext();
29
21
 
@@ -143,15 +135,13 @@ export const RequestBuilder: React.FC = () => {
143
135
  <CardHeader>
144
136
  <div className="flex items-center justify-between">
145
137
  <CardTitle className="text-sm text-foreground">cURL Command</CardTitle>
146
- <Button
138
+ <CopyButton
139
+ value={curlCommand}
147
140
  variant="outline"
148
141
  size="sm"
149
- onClick={() => copyToClipboard(curlCommand)}
150
- disabled={!curlCommand}
151
142
  >
152
- <Copy className="h-4 w-4" />
153
- <span className="hidden sm:inline">Copy cURL</span>
154
- </Button>
143
+ Copy cURL
144
+ </CopyButton>
155
145
  </div>
156
146
  </CardHeader>
157
147
  <CardContent>
@@ -1,16 +1,35 @@
1
1
  'use client';
2
2
 
3
- import React from 'react';
4
- import { Badge, Button, Card, CardContent, CardHeader, CardTitle } from '@djangocfg/ui-core/components';
5
- import { Copy, XCircle, Download } from 'lucide-react';
3
+ import React, { useMemo, useCallback } from 'react';
4
+ import { Badge, Button, Card, CardContent, CardHeader, CardTitle, CopyButton } from '@djangocfg/ui-core/components';
5
+ import { XCircle, Download } from 'lucide-react';
6
6
  import { usePlaygroundContext } from '../context/PlaygroundContext';
7
7
  import { getStatusColor } from '../utils';
8
8
  import JsonTree from '../../JsonTree';
9
9
 
10
10
  export const ResponseViewer: React.FC = () => {
11
- const { state, copyToClipboard } = usePlaygroundContext();
11
+ const { state } = usePlaygroundContext();
12
12
  const { response } = state;
13
13
 
14
+ // Memoize response text for copy/download
15
+ const responseText = useMemo(() => {
16
+ if (!response?.data) return '';
17
+ return typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2);
18
+ }, [response?.data]);
19
+
20
+ const handleDownloadResponse = useCallback(() => {
21
+ if (!responseText) return;
22
+ const blob = new Blob([responseText], { type: 'application/json' });
23
+ const url = URL.createObjectURL(blob);
24
+ const a = document.createElement('a');
25
+ a.href = url;
26
+ a.download = 'response.json';
27
+ document.body.appendChild(a);
28
+ a.click();
29
+ document.body.removeChild(a);
30
+ URL.revokeObjectURL(url);
31
+ }, [responseText]);
32
+
14
33
  if (!response) {
15
34
  return (
16
35
  <div className="space-y-4">
@@ -26,33 +45,14 @@ export const ResponseViewer: React.FC = () => {
26
45
  );
27
46
  }
28
47
 
29
- const handleCopyResponse = () => {
30
- const responseText = typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2);
31
- copyToClipboard(responseText);
32
- };
33
-
34
- const handleDownloadResponse = () => {
35
- const responseText = typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2);
36
- const blob = new Blob([responseText], { type: 'application/json' });
37
- const url = URL.createObjectURL(blob);
38
- const a = document.createElement('a');
39
- a.href = url;
40
- a.download = 'response.json';
41
- document.body.appendChild(a);
42
- a.click();
43
- document.body.removeChild(a);
44
- URL.revokeObjectURL(url);
45
- };
46
-
47
48
  return (
48
49
  <div className="space-y-4">
49
50
  <div className="flex items-center justify-between">
50
51
  <h2 className="text-lg font-semibold text-foreground">Response</h2>
51
52
  <div className="flex items-center space-x-2">
52
- <Button variant="outline" size="sm" onClick={handleCopyResponse}>
53
- <Copy className="h-4 w-4" />
54
- <span className="hidden sm:inline">Copy</span>
55
- </Button>
53
+ <CopyButton value={responseText} variant="outline" size="sm">
54
+ Copy
55
+ </CopyButton>
56
56
  <Button variant="outline" size="sm" onClick={handleDownloadResponse}>
57
57
  <Download className="h-4 w-4" />
58
58
  <span className="hidden sm:inline">Download</span>
@@ -5,7 +5,6 @@ import consola from 'consola';
5
5
  import { type ApiEndpoint, type ApiResponse, type PlaygroundContextType, type PlaygroundState, type PlaygroundStep, type PlaygroundConfig } from '../types';
6
6
  import { getDefaultVersion } from '../utils/versionManager';
7
7
  import { parseRequestHeaders, substituteUrlParameters } from '../utils';
8
- import { useCopy } from '@djangocfg/ui-core/hooks';
9
8
 
10
9
  const createInitialState = (): PlaygroundState => ({
11
10
  // Step management
@@ -59,11 +58,6 @@ export const PlaygroundProvider: React.FC<PlaygroundProviderProps> = ({ children
59
58
  const apiKeys = React.useMemo(() => [], []);
60
59
  const isLoadingApiKeys = false;
61
60
 
62
- const { copyToClipboard } = useCopy({
63
- successMessage: "cURL command copied to clipboard",
64
- errorMessage: "Failed to copy cURL command"
65
- });
66
-
67
61
  const updateState = (updates: Partial<PlaygroundState>) => {
68
62
  setState((prev) => ({ ...prev, ...updates }));
69
63
  };
@@ -330,7 +324,6 @@ export const PlaygroundProvider: React.FC<PlaygroundProviderProps> = ({ children
330
324
 
331
325
  // Actions
332
326
  clearAll,
333
- copyToClipboard,
334
327
  sendRequest,
335
328
  };
336
329
 
@@ -135,7 +135,6 @@ export interface PlaygroundContextType {
135
135
 
136
136
  // Actions
137
137
  clearAll: () => void;
138
- copyToClipboard: (text: string) => void;
139
138
  sendRequest: () => Promise<void>;
140
139
  }
141
140
 
@@ -1,10 +1,9 @@
1
1
  'use client';
2
2
 
3
3
  import { Highlight, Language, themes } from 'prism-react-renderer';
4
- import React, { useState } from 'react';
5
- import { Copy, Check } from 'lucide-react';
6
- import { useCopy } from '@djangocfg/ui-core/hooks';
7
- import { useTheme } from '../../hooks/useTheme';
4
+ import React from 'react';
5
+ import { CopyButton } from '@djangocfg/ui-core/components';
6
+ import { useResolvedTheme } from '../../hooks/useResolvedTheme';
8
7
 
9
8
  interface PrettyCodeProps {
10
9
  data: string | object;
@@ -16,12 +15,7 @@ interface PrettyCodeProps {
16
15
  }
17
16
 
18
17
  const PrettyCode = ({ data, language, className, mode, inline = false, customBg }: PrettyCodeProps) => {
19
- const detectedTheme = useTheme();
20
- const [copied, setCopied] = useState(false);
21
- const { copyToClipboard } = useCopy({
22
- successMessage: "Code copied to clipboard",
23
- errorMessage: "Failed to copy code"
24
- });
18
+ const detectedTheme = useResolvedTheme();
25
19
 
26
20
  // Use provided mode or fall back to detected theme
27
21
  const currentTheme = mode || detectedTheme;
@@ -32,15 +26,6 @@ const PrettyCode = ({ data, language, className, mode, inline = false, customBg
32
26
 
33
27
  // Convert form object to JSON string with proper formatting
34
28
  const contentJson = typeof data === 'string' ? data : JSON.stringify(data || {}, null, 2);
35
-
36
- // Handle copy
37
- const handleCopy = async () => {
38
- const success = await copyToClipboard(contentJson);
39
- if (success) {
40
- setCopied(true);
41
- setTimeout(() => setCopied(false), 2000);
42
- }
43
- };
44
29
 
45
30
  // Handle empty content
46
31
  if (!contentJson || contentJson.trim() === '') {
@@ -173,13 +158,13 @@ const PrettyCode = ({ data, language, className, mode, inline = false, customBg
173
158
  <span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-background/80 text-muted-foreground border border-border/50 backdrop-blur-sm">
174
159
  {displayLanguage}
175
160
  </span>
176
- <button
177
- onClick={handleCopy}
178
- className="inline-flex items-center justify-center p-1.5 rounded text-muted-foreground hover:text-foreground bg-background/80 border border-border/50 backdrop-blur-sm transition-colors"
161
+ <CopyButton
162
+ value={contentJson}
163
+ variant="ghost"
164
+ className="h-7 w-7 bg-background/80 border border-border/50 backdrop-blur-sm"
165
+ iconClassName="h-3.5 w-3.5"
179
166
  title="Copy code"
180
- >
181
- {copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
182
- </button>
167
+ />
183
168
  </div>
184
169
 
185
170
  <div className="h-full overflow-auto">
@@ -194,20 +194,20 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
194
194
  <MediaProvider />
195
195
 
196
196
  {/* Poster with proper aspect ratio handling */}
197
- {source.poster && (
197
+ {posterUrl && (
198
198
  <Poster
199
199
  className="vds-poster"
200
- src={source.poster}
200
+ src={posterUrl}
201
201
  alt={source.title || 'Video poster'}
202
202
  style={{ objectFit: 'cover' }}
203
203
  />
204
204
  )}
205
-
205
+
206
206
  {/* Use Vidstack's built-in default layout */}
207
207
  {controls && (
208
208
  <DefaultVideoLayout
209
209
  icons={defaultLayoutIcons}
210
- thumbnails={source.poster}
210
+ thumbnails={posterUrl}
211
211
  />
212
212
  )}
213
213
  </MediaPlayer>