@djangocfg/layouts 2.0.8 → 2.0.9
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 +5 -5
- package/src/auth/context/AuthContext.tsx +11 -6
- package/src/auth/hooks/useAuthGuard.ts +2 -2
- package/src/auth/hooks/useAutoAuth.ts +2 -2
- package/src/auth/hooks/useGithubAuth.ts +4 -3
- package/src/components/RedirectPage/RedirectPage.tsx +2 -2
- package/src/snippets/AuthDialog/AuthDialog.tsx +2 -2
- package/src/snippets/McpChat/components/AIChatWidget.tsx +3 -39
- package/src/snippets/McpChat/components/ChatMessages.tsx +2 -2
- package/src/snippets/McpChat/components/ChatPanel.tsx +84 -110
- package/src/snippets/McpChat/components/ChatSidebar.tsx +66 -60
- package/src/snippets/McpChat/components/ChatWidget.tsx +4 -37
- package/src/snippets/McpChat/components/MessageBubble.tsx +5 -5
- package/src/snippets/McpChat/components/index.ts +0 -2
- package/src/snippets/McpChat/config.ts +42 -0
- package/src/snippets/McpChat/context/ChatContext.tsx +5 -7
- package/src/snippets/McpChat/hooks/useChatLayout.ts +134 -23
- package/src/snippets/McpChat/index.ts +0 -1
- package/src/snippets/index.ts +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/layouts",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.9",
|
|
4
4
|
"description": "Simple, straightforward layout components for Next.js - import and use with props",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"layouts",
|
|
@@ -92,9 +92,9 @@
|
|
|
92
92
|
"check": "tsc --noEmit"
|
|
93
93
|
},
|
|
94
94
|
"peerDependencies": {
|
|
95
|
-
"@djangocfg/api": "^1.4.
|
|
96
|
-
"@djangocfg/centrifugo": "^1.4.
|
|
97
|
-
"@djangocfg/ui": "^1.4.
|
|
95
|
+
"@djangocfg/api": "^1.4.39",
|
|
96
|
+
"@djangocfg/centrifugo": "^1.4.39",
|
|
97
|
+
"@djangocfg/ui": "^1.4.39",
|
|
98
98
|
"@hookform/resolvers": "^5.2.0",
|
|
99
99
|
"consola": "^3.4.2",
|
|
100
100
|
"lucide-react": "^0.545.0",
|
|
@@ -114,7 +114,7 @@
|
|
|
114
114
|
"uuid": "^11.1.0"
|
|
115
115
|
},
|
|
116
116
|
"devDependencies": {
|
|
117
|
-
"@djangocfg/typescript-config": "^1.4.
|
|
117
|
+
"@djangocfg/typescript-config": "^1.4.39",
|
|
118
118
|
"@types/node": "^24.7.2",
|
|
119
119
|
"@types/react": "19.2.2",
|
|
120
120
|
"@types/react-dom": "19.2.1",
|
|
@@ -8,7 +8,7 @@ import React, {
|
|
|
8
8
|
|
|
9
9
|
import { api, Enums } from '@djangocfg/api';
|
|
10
10
|
import { useAccountsContext, AccountsProvider } from './AccountsContext';
|
|
11
|
-
import { useLocalStorage, useQueryParams,
|
|
11
|
+
import { useLocalStorage, useQueryParams, useCfgRouter } from '@djangocfg/ui/hooks';
|
|
12
12
|
import { getCachedProfile, clearProfileCache } from '../hooks/useProfileCache';
|
|
13
13
|
|
|
14
14
|
import { authLogger } from '../../utils/logger';
|
|
@@ -50,7 +50,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
50
50
|
});
|
|
51
51
|
|
|
52
52
|
const [initialized, setInitialized] = useState(false);
|
|
53
|
-
const router =
|
|
53
|
+
const router = useCfgRouter();
|
|
54
54
|
const pathname = usePathname();
|
|
55
55
|
const queryParams = useQueryParams();
|
|
56
56
|
|
|
@@ -362,13 +362,14 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
362
362
|
}
|
|
363
363
|
|
|
364
364
|
// Handle redirect logic here
|
|
365
|
+
// Use hardPush for full page reload - ensures all React contexts reinitialize
|
|
365
366
|
const defaultCallback = config?.routes?.defaultCallback || defaultRoutes.defaultCallback;
|
|
366
367
|
|
|
367
368
|
if (redirectUrl && redirectUrl !== defaultCallback) {
|
|
368
369
|
clearRedirectUrl();
|
|
369
|
-
router.
|
|
370
|
+
router.hardPush(redirectUrl);
|
|
370
371
|
} else {
|
|
371
|
-
router.
|
|
372
|
+
router.hardPush(defaultCallback);
|
|
372
373
|
}
|
|
373
374
|
|
|
374
375
|
return {
|
|
@@ -460,7 +461,11 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
460
461
|
accounts.logout(); // Clear tokens and profile
|
|
461
462
|
setInitialized(true);
|
|
462
463
|
setIsLoading(false);
|
|
463
|
-
|
|
464
|
+
|
|
465
|
+
// Use hardReplace for full page reload + replace history
|
|
466
|
+
// This ensures contexts reinitialize AND back button won't return to protected page
|
|
467
|
+
const authCallbackUrl = config?.routes?.defaultAuthCallback || defaultRoutes.defaultAuthCallback;
|
|
468
|
+
router.hardReplace(authCallbackUrl);
|
|
464
469
|
};
|
|
465
470
|
|
|
466
471
|
// Use config.onConfirm if provided, otherwise use a simple confirm
|
|
@@ -482,7 +487,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
482
487
|
performLogout();
|
|
483
488
|
}
|
|
484
489
|
}
|
|
485
|
-
}, [accounts,
|
|
490
|
+
}, [accounts, config?.routes?.defaultAuthCallback, router]);
|
|
486
491
|
|
|
487
492
|
// Redirect URL methods
|
|
488
493
|
const getSavedRedirectUrl = useCallback((): string | null => {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { useEffect } from 'react';
|
|
4
4
|
|
|
5
5
|
import { useAuth } from '../context';
|
|
6
|
-
import {
|
|
6
|
+
import { useCfgRouter } from '@djangocfg/ui/hooks';
|
|
7
7
|
|
|
8
8
|
interface UseAuthGuardOptions {
|
|
9
9
|
redirectTo?: string;
|
|
@@ -13,7 +13,7 @@ interface UseAuthGuardOptions {
|
|
|
13
13
|
export const useAuthGuard = (options: UseAuthGuardOptions = {}) => {
|
|
14
14
|
const { redirectTo = '/auth', requireAuth = true } = options;
|
|
15
15
|
const { isAuthenticated, isLoading } = useAuth();
|
|
16
|
-
const router =
|
|
16
|
+
const router = useCfgRouter();
|
|
17
17
|
|
|
18
18
|
useEffect(() => {
|
|
19
19
|
if (!isLoading && requireAuth && !isAuthenticated) {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect } from 'react';
|
|
4
4
|
import { usePathname } from 'next/navigation';
|
|
5
|
-
import { useQueryParams,
|
|
5
|
+
import { useQueryParams, useCfgRouter } from '@djangocfg/ui/hooks';
|
|
6
6
|
import { authLogger } from '../../utils/logger';
|
|
7
7
|
|
|
8
8
|
export interface UseAutoAuthOptions {
|
|
@@ -18,7 +18,7 @@ export const useAutoAuth = (options: UseAutoAuthOptions = {}) => {
|
|
|
18
18
|
const { onOTPDetected, cleanupUrl = true } = options;
|
|
19
19
|
const queryParams = useQueryParams();
|
|
20
20
|
const pathname = usePathname();
|
|
21
|
-
const router =
|
|
21
|
+
const router = useCfgRouter();
|
|
22
22
|
|
|
23
23
|
const isReady = !!pathname && !!queryParams.get('otp');
|
|
24
24
|
const hasOTP = !!(queryParams.get('otp'));
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useCallback, useState } from 'react';
|
|
4
|
-
import { useRouter } from 'next/navigation';
|
|
5
4
|
|
|
6
5
|
import { api } from '@djangocfg/api';
|
|
6
|
+
import { useCfgRouter } from '@djangocfg/ui/hooks';
|
|
7
7
|
import { authLogger } from '../../utils/logger';
|
|
8
8
|
import { Analytics, AnalyticsEvent, AnalyticsCategory } from '../../snippets/Analytics';
|
|
9
9
|
|
|
@@ -42,7 +42,7 @@ export interface UseGithubAuthReturn {
|
|
|
42
42
|
*/
|
|
43
43
|
export const useGithubAuth = (options: UseGithubAuthOptions = {}): UseGithubAuthReturn => {
|
|
44
44
|
const { sourceUrl, onSuccess, onError, redirectUrl } = options;
|
|
45
|
-
const router =
|
|
45
|
+
const router = useCfgRouter();
|
|
46
46
|
|
|
47
47
|
const [isLoading, setIsLoading] = useState(false);
|
|
48
48
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -155,8 +155,9 @@ export const useGithubAuth = (options: UseGithubAuthOptions = {}): UseGithubAuth
|
|
|
155
155
|
onSuccess?.(response.user, response.is_new_user || false);
|
|
156
156
|
|
|
157
157
|
// Redirect to dashboard or specified URL
|
|
158
|
+
// Use hardPush for full page reload - ensures all React contexts reinitialize
|
|
158
159
|
const finalRedirectUrl = redirectUrl || '/dashboard';
|
|
159
|
-
router.
|
|
160
|
+
router.hardPush(finalRedirectUrl);
|
|
160
161
|
|
|
161
162
|
} catch (err) {
|
|
162
163
|
const errorMessage = err instanceof Error ? err.message : 'GitHub authentication failed';
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect } from 'react';
|
|
4
4
|
import { useAuth } from '../../auth';
|
|
5
|
-
import {
|
|
5
|
+
import { useCfgRouter } from '@djangocfg/ui/hooks';
|
|
6
6
|
import { Preloader } from '@djangocfg/ui/components';
|
|
7
7
|
|
|
8
8
|
export interface RedirectPageProps {
|
|
@@ -49,7 +49,7 @@ export function RedirectPage({
|
|
|
49
49
|
loadingText = 'Loading...',
|
|
50
50
|
}: RedirectPageProps) {
|
|
51
51
|
const { isAuthenticated } = useAuth();
|
|
52
|
-
const router =
|
|
52
|
+
const router = useCfgRouter();
|
|
53
53
|
|
|
54
54
|
useEffect(() => {
|
|
55
55
|
if (!isAuthenticated) {
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
DialogHeader,
|
|
11
11
|
DialogTitle,
|
|
12
12
|
} from '@djangocfg/ui/components';
|
|
13
|
-
import { useEventListener,
|
|
13
|
+
import { useEventListener, useCfgRouter } from '@djangocfg/ui/hooks';
|
|
14
14
|
|
|
15
15
|
// Re-export events for backwards compatibility
|
|
16
16
|
export const DIALOG_EVENTS = {
|
|
@@ -31,7 +31,7 @@ export const AuthDialog: React.FC<AuthDialogProps> = ({
|
|
|
31
31
|
}) => {
|
|
32
32
|
const [open, setOpen] = useState(false);
|
|
33
33
|
const [message, setMessage] = useState<string>('Please sign in to continue');
|
|
34
|
-
const router =
|
|
34
|
+
const router = useCfgRouter();
|
|
35
35
|
|
|
36
36
|
// Listen for open auth dialog event
|
|
37
37
|
useEventListener(DIALOG_EVENTS.OPEN_AUTH_DIALOG, (payload: any) => {
|
|
@@ -66,20 +66,7 @@ export interface AIChatWidgetProps extends ChatWidgetConfig {
|
|
|
66
66
|
* Internal AI chat widget that uses context
|
|
67
67
|
*/
|
|
68
68
|
const AIChatWidgetInternal = React.memo<{ className?: string }>(({ className }) => {
|
|
69
|
-
const {
|
|
70
|
-
messages,
|
|
71
|
-
isLoading,
|
|
72
|
-
isMinimized,
|
|
73
|
-
config,
|
|
74
|
-
displayMode,
|
|
75
|
-
isMobile,
|
|
76
|
-
sendMessage,
|
|
77
|
-
openChat,
|
|
78
|
-
closeChat,
|
|
79
|
-
toggleMinimize,
|
|
80
|
-
setDisplayMode,
|
|
81
|
-
stopStreaming,
|
|
82
|
-
} = useAIChatContext();
|
|
69
|
+
const { config, displayMode, openChat } = useAIChatContext();
|
|
83
70
|
|
|
84
71
|
// Use layout hook for consistent positioning
|
|
85
72
|
const { getFabStyles, getFloatingStyles } = useChatLayout();
|
|
@@ -173,17 +160,7 @@ const AIChatWidgetInternal = React.memo<{ className?: string }>(({ className })
|
|
|
173
160
|
if (displayMode === 'sidebar') {
|
|
174
161
|
return (
|
|
175
162
|
<Portal>
|
|
176
|
-
<ChatSidebar
|
|
177
|
-
messages={messages}
|
|
178
|
-
isLoading={isLoading}
|
|
179
|
-
onSendMessage={sendMessage}
|
|
180
|
-
onClose={closeChat}
|
|
181
|
-
onModeChange={setDisplayMode}
|
|
182
|
-
onStopStreaming={stopStreaming}
|
|
183
|
-
title={config.title}
|
|
184
|
-
placeholder={config.placeholder}
|
|
185
|
-
greeting={config.greeting}
|
|
186
|
-
/>
|
|
163
|
+
<ChatSidebar />
|
|
187
164
|
</Portal>
|
|
188
165
|
);
|
|
189
166
|
}
|
|
@@ -192,20 +169,7 @@ const AIChatWidgetInternal = React.memo<{ className?: string }>(({ className })
|
|
|
192
169
|
return (
|
|
193
170
|
<Portal>
|
|
194
171
|
<div style={floatingStyles} className={className || ''}>
|
|
195
|
-
<ChatPanel
|
|
196
|
-
messages={messages}
|
|
197
|
-
isLoading={isLoading}
|
|
198
|
-
onSendMessage={sendMessage}
|
|
199
|
-
onClose={closeChat}
|
|
200
|
-
onMinimize={toggleMinimize}
|
|
201
|
-
onModeChange={setDisplayMode}
|
|
202
|
-
onStopStreaming={stopStreaming}
|
|
203
|
-
isMinimized={isMinimized}
|
|
204
|
-
isMobile={isMobile}
|
|
205
|
-
title={config.title}
|
|
206
|
-
placeholder={config.placeholder}
|
|
207
|
-
greeting={config.greeting}
|
|
208
|
-
/>
|
|
172
|
+
<ChatPanel />
|
|
209
173
|
</div>
|
|
210
174
|
</Portal>
|
|
211
175
|
);
|
|
@@ -93,8 +93,8 @@ export const ChatMessages = forwardRef<ChatMessagesHandle, ChatMessagesProps>(
|
|
|
93
93
|
const padding = largeGreetingIcon ? 'py-12' : 'py-8';
|
|
94
94
|
|
|
95
95
|
return (
|
|
96
|
-
<ScrollArea ref={scrollAreaRef} className="h-full">
|
|
97
|
-
<div className={`${isCompact ? 'p-3' : 'p-4'} space-y-4`}>
|
|
96
|
+
<ScrollArea ref={scrollAreaRef} className="h-full w-full">
|
|
97
|
+
<div className={`${isCompact ? 'p-3' : 'p-4'} space-y-4 max-w-full overflow-x-hidden`}>
|
|
98
98
|
{/* Greeting */}
|
|
99
99
|
{messages.length === 0 && greeting && (
|
|
100
100
|
<div className={`text-center ${padding}`}>
|
|
@@ -2,125 +2,99 @@
|
|
|
2
2
|
|
|
3
3
|
import React from 'react';
|
|
4
4
|
import { Card, CardHeader, CardContent, CardFooter, Button } from '@djangocfg/ui';
|
|
5
|
-
import { X,
|
|
6
|
-
import type { ChatDisplayMode, AIChatMessage } from '../types';
|
|
5
|
+
import { X, PanelRight, Bot, RotateCcw } from 'lucide-react';
|
|
7
6
|
import { ChatMessages } from './ChatMessages';
|
|
8
7
|
import { AIMessageInput } from './MessageInput';
|
|
8
|
+
import { useAIChatContext } from '../context/AIChatContext';
|
|
9
9
|
|
|
10
|
-
export
|
|
11
|
-
|
|
12
|
-
isLoading: boolean;
|
|
13
|
-
onSendMessage: (content: string) => void;
|
|
14
|
-
onClose?: () => void;
|
|
15
|
-
onMinimize?: () => void;
|
|
16
|
-
onModeChange?: (mode: ChatDisplayMode) => void;
|
|
17
|
-
onStopStreaming?: () => void;
|
|
18
|
-
isMinimized?: boolean;
|
|
19
|
-
isMobile?: boolean;
|
|
20
|
-
title?: string;
|
|
21
|
-
placeholder?: string;
|
|
22
|
-
greeting?: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export const ChatPanel = React.memo<ChatPanelProps>(
|
|
26
|
-
({
|
|
10
|
+
export const ChatPanel = React.memo(() => {
|
|
11
|
+
const {
|
|
27
12
|
messages,
|
|
28
13
|
isLoading,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
placeholder,
|
|
38
|
-
greeting,
|
|
39
|
-
}) => {
|
|
40
|
-
if (isMinimized) {
|
|
41
|
-
return (
|
|
42
|
-
<Button
|
|
43
|
-
onClick={onMinimize}
|
|
44
|
-
className="rounded-full shadow-lg"
|
|
45
|
-
style={{ width: '56px', height: '56px' }}
|
|
46
|
-
>
|
|
47
|
-
<Bot className="h-6 w-6" />
|
|
48
|
-
</Button>
|
|
49
|
-
);
|
|
50
|
-
}
|
|
14
|
+
config,
|
|
15
|
+
isMobile,
|
|
16
|
+
sendMessage,
|
|
17
|
+
closeChat,
|
|
18
|
+
setDisplayMode,
|
|
19
|
+
stopStreaming,
|
|
20
|
+
clearMessages,
|
|
21
|
+
} = useAIChatContext();
|
|
51
22
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
</div>
|
|
70
|
-
<div>
|
|
71
|
-
<h3 className="font-semibold text-sm">{title}</h3>
|
|
72
|
-
<p className="text-xs text-muted-foreground">AI-powered documentation assistant</p>
|
|
73
|
-
</div>
|
|
23
|
+
return (
|
|
24
|
+
<Card
|
|
25
|
+
className="flex flex-col shadow-2xl border-border/50"
|
|
26
|
+
style={{
|
|
27
|
+
width: '380px',
|
|
28
|
+
height: '520px',
|
|
29
|
+
maxHeight: 'calc(100vh - 100px)',
|
|
30
|
+
}}
|
|
31
|
+
>
|
|
32
|
+
{/* Header */}
|
|
33
|
+
<CardHeader className="flex flex-row items-center justify-between p-3 border-b">
|
|
34
|
+
<div className="flex items-center gap-2">
|
|
35
|
+
<div
|
|
36
|
+
className="rounded-full bg-primary/10 flex items-center justify-center"
|
|
37
|
+
style={{ width: '32px', height: '32px' }}
|
|
38
|
+
>
|
|
39
|
+
<Bot className="h-4 w-4 text-primary" />
|
|
74
40
|
</div>
|
|
75
|
-
<div
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
<Button
|
|
79
|
-
variant="ghost"
|
|
80
|
-
size="icon"
|
|
81
|
-
className="h-8 w-8"
|
|
82
|
-
onClick={() => onModeChange('sidebar')}
|
|
83
|
-
title="Switch to sidebar mode"
|
|
84
|
-
>
|
|
85
|
-
<PanelRight className="h-4 w-4" />
|
|
86
|
-
</Button>
|
|
87
|
-
)}
|
|
88
|
-
{onMinimize && (
|
|
89
|
-
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onMinimize} title="Minimize">
|
|
90
|
-
<Minimize2 className="h-4 w-4" />
|
|
91
|
-
</Button>
|
|
92
|
-
)}
|
|
93
|
-
{onClose && (
|
|
94
|
-
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose} title="Close">
|
|
95
|
-
<X className="h-4 w-4" />
|
|
96
|
-
</Button>
|
|
97
|
-
)}
|
|
41
|
+
<div>
|
|
42
|
+
<h3 className="font-semibold text-sm">{config.title || 'DjangoCFG AI'}</h3>
|
|
43
|
+
<p className="text-xs text-muted-foreground">documentation assistant</p>
|
|
98
44
|
</div>
|
|
99
|
-
</
|
|
45
|
+
</div>
|
|
46
|
+
<div className="flex gap-1">
|
|
47
|
+
{messages.length > 0 && (
|
|
48
|
+
<Button
|
|
49
|
+
variant="ghost"
|
|
50
|
+
size="icon"
|
|
51
|
+
className="h-8 w-8"
|
|
52
|
+
onClick={clearMessages}
|
|
53
|
+
title="New chat"
|
|
54
|
+
>
|
|
55
|
+
<RotateCcw className="h-4 w-4" />
|
|
56
|
+
</Button>
|
|
57
|
+
)}
|
|
58
|
+
{/* Sidebar mode button - only on desktop */}
|
|
59
|
+
{!isMobile && (
|
|
60
|
+
<Button
|
|
61
|
+
variant="ghost"
|
|
62
|
+
size="icon"
|
|
63
|
+
className="h-8 w-8"
|
|
64
|
+
onClick={() => setDisplayMode('sidebar')}
|
|
65
|
+
title="Switch to sidebar mode"
|
|
66
|
+
>
|
|
67
|
+
<PanelRight className="h-4 w-4" />
|
|
68
|
+
</Button>
|
|
69
|
+
)}
|
|
70
|
+
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={closeChat} title="Close">
|
|
71
|
+
<X className="h-4 w-4" />
|
|
72
|
+
</Button>
|
|
73
|
+
</div>
|
|
74
|
+
</CardHeader>
|
|
100
75
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
76
|
+
{/* Messages */}
|
|
77
|
+
<CardContent className="flex-1 p-0 overflow-hidden">
|
|
78
|
+
<ChatMessages
|
|
79
|
+
messages={messages}
|
|
80
|
+
isLoading={isLoading}
|
|
81
|
+
greeting={config.greeting}
|
|
82
|
+
onStopStreaming={stopStreaming}
|
|
83
|
+
isCompact
|
|
84
|
+
greetingIcon="bot"
|
|
85
|
+
/>
|
|
86
|
+
</CardContent>
|
|
112
87
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
);
|
|
88
|
+
{/* Input */}
|
|
89
|
+
<CardFooter className="p-3 border-t">
|
|
90
|
+
<AIMessageInput
|
|
91
|
+
onSend={sendMessage}
|
|
92
|
+
isLoading={isLoading}
|
|
93
|
+
placeholder={config.placeholder}
|
|
94
|
+
/>
|
|
95
|
+
</CardFooter>
|
|
96
|
+
</Card>
|
|
97
|
+
);
|
|
98
|
+
});
|
|
125
99
|
|
|
126
100
|
ChatPanel.displayName = 'ChatPanel';
|
|
@@ -2,60 +2,59 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { useEffect } from 'react';
|
|
4
4
|
import { Button } from '@djangocfg/ui';
|
|
5
|
-
import { X, PanelRightClose, Bot } from 'lucide-react';
|
|
5
|
+
import { X, PanelRightClose, Bot, GripVertical, RotateCcw } from 'lucide-react';
|
|
6
6
|
import { ChatMessages } from './ChatMessages';
|
|
7
7
|
import { AIMessageInput } from './MessageInput';
|
|
8
8
|
import { useChatLayout } from '../hooks/useChatLayout';
|
|
9
|
-
import
|
|
9
|
+
import { useAIChatContext } from '../context/AIChatContext';
|
|
10
10
|
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
export interface ChatSidebarProps {
|
|
14
|
-
messages: AIChatMessage[];
|
|
15
|
-
isLoading: boolean;
|
|
16
|
-
onSendMessage: (content: string) => void;
|
|
17
|
-
onClose?: () => void;
|
|
18
|
-
onModeChange?: (mode: ChatDisplayMode) => void;
|
|
19
|
-
onStopStreaming?: () => void;
|
|
20
|
-
title?: string;
|
|
21
|
-
placeholder?: string;
|
|
22
|
-
greeting?: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export const ChatSidebar = React.memo<ChatSidebarProps>(
|
|
26
|
-
({
|
|
11
|
+
export const ChatSidebar = React.memo(() => {
|
|
12
|
+
const {
|
|
27
13
|
messages,
|
|
28
14
|
isLoading,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}) => {
|
|
37
|
-
// Use the layout hook for content pushing
|
|
38
|
-
const { applyLayout, getSidebarStyles } = useChatLayout({
|
|
39
|
-
sidebarWidth: SIDEBAR_WIDTH,
|
|
40
|
-
});
|
|
15
|
+
config,
|
|
16
|
+
sendMessage,
|
|
17
|
+
closeChat,
|
|
18
|
+
setDisplayMode,
|
|
19
|
+
stopStreaming,
|
|
20
|
+
clearMessages,
|
|
21
|
+
} = useAIChatContext();
|
|
41
22
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
useEffect(() => {
|
|
45
|
-
applyLayout('sidebar');
|
|
46
|
-
return () => {
|
|
47
|
-
applyLayout('closed');
|
|
48
|
-
};
|
|
49
|
-
}, []);
|
|
23
|
+
// Use the layout hook for content pushing and resizing
|
|
24
|
+
const { applyLayout, getSidebarStyles, startResize, isResizing } = useChatLayout();
|
|
50
25
|
|
|
51
|
-
|
|
26
|
+
// Apply sidebar layout on mount, reset on unmount
|
|
27
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
applyLayout('sidebar');
|
|
30
|
+
return () => {
|
|
31
|
+
applyLayout('closed');
|
|
32
|
+
};
|
|
33
|
+
}, []);
|
|
52
34
|
|
|
53
|
-
|
|
35
|
+
const sidebarStyles = getSidebarStyles();
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
className="flex bg-background"
|
|
40
|
+
style={sidebarStyles}
|
|
41
|
+
data-chat-sidebar-panel
|
|
42
|
+
>
|
|
43
|
+
{/* Resize handle */}
|
|
54
44
|
<div
|
|
55
|
-
className=
|
|
56
|
-
|
|
57
|
-
|
|
45
|
+
className={`
|
|
46
|
+
flex items-center justify-center w-3 cursor-ew-resize
|
|
47
|
+
border-l border-border transition-colors select-none flex-shrink-0
|
|
48
|
+
${isResizing ? 'bg-primary/20' : 'bg-muted/30 hover:bg-muted/50'}
|
|
49
|
+
`}
|
|
50
|
+
onMouseDown={startResize}
|
|
51
|
+
title="Drag to resize"
|
|
58
52
|
>
|
|
53
|
+
<GripVertical className={`h-4 w-4 ${isResizing ? 'text-primary' : 'text-muted-foreground/50'}`} />
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
{/* Main sidebar content */}
|
|
57
|
+
<div className="flex flex-col flex-1 min-w-0">
|
|
59
58
|
{/* Header - uses CSS variable for navbar height consistency */}
|
|
60
59
|
<div
|
|
61
60
|
className="flex items-center justify-between px-4 border-b border-border bg-muted/30"
|
|
@@ -69,27 +68,34 @@ export const ChatSidebar = React.memo<ChatSidebarProps>(
|
|
|
69
68
|
<Bot className="h-4 w-4 text-primary" />
|
|
70
69
|
</div>
|
|
71
70
|
<div>
|
|
72
|
-
<h3 className="font-semibold text-sm">{title}</h3>
|
|
73
|
-
<p className="text-xs text-muted-foreground">
|
|
71
|
+
<h3 className="font-semibold text-sm">{config.title || 'DjangoCFG AI'}</h3>
|
|
72
|
+
<p className="text-xs text-muted-foreground">documentation assistant</p>
|
|
74
73
|
</div>
|
|
75
74
|
</div>
|
|
76
75
|
<div className="flex gap-1">
|
|
77
|
-
{
|
|
76
|
+
{messages.length > 0 && (
|
|
78
77
|
<Button
|
|
79
78
|
variant="ghost"
|
|
80
79
|
size="icon"
|
|
81
80
|
className="h-8 w-8"
|
|
82
|
-
onClick={
|
|
83
|
-
title="
|
|
81
|
+
onClick={clearMessages}
|
|
82
|
+
title="New chat"
|
|
84
83
|
>
|
|
85
|
-
<
|
|
86
|
-
</Button>
|
|
87
|
-
)}
|
|
88
|
-
{onClose && (
|
|
89
|
-
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose} title="Close chat">
|
|
90
|
-
<X className="h-4 w-4" />
|
|
84
|
+
<RotateCcw className="h-4 w-4" />
|
|
91
85
|
</Button>
|
|
92
86
|
)}
|
|
87
|
+
<Button
|
|
88
|
+
variant="ghost"
|
|
89
|
+
size="icon"
|
|
90
|
+
className="h-8 w-8"
|
|
91
|
+
onClick={() => setDisplayMode('floating')}
|
|
92
|
+
title="Switch to floating mode"
|
|
93
|
+
>
|
|
94
|
+
<PanelRightClose className="h-4 w-4" />
|
|
95
|
+
</Button>
|
|
96
|
+
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={closeChat} title="Close chat">
|
|
97
|
+
<X className="h-4 w-4" />
|
|
98
|
+
</Button>
|
|
93
99
|
</div>
|
|
94
100
|
</div>
|
|
95
101
|
|
|
@@ -98,8 +104,8 @@ export const ChatSidebar = React.memo<ChatSidebarProps>(
|
|
|
98
104
|
<ChatMessages
|
|
99
105
|
messages={messages}
|
|
100
106
|
isLoading={isLoading}
|
|
101
|
-
greeting={greeting}
|
|
102
|
-
onStopStreaming={
|
|
107
|
+
greeting={config.greeting}
|
|
108
|
+
onStopStreaming={stopStreaming}
|
|
103
109
|
isCompact={false}
|
|
104
110
|
largeGreetingIcon
|
|
105
111
|
greetingIcon="message"
|
|
@@ -109,11 +115,11 @@ export const ChatSidebar = React.memo<ChatSidebarProps>(
|
|
|
109
115
|
|
|
110
116
|
{/* Input */}
|
|
111
117
|
<div className="p-4 border-t border-border bg-muted/30">
|
|
112
|
-
<AIMessageInput onSend={
|
|
118
|
+
<AIMessageInput onSend={sendMessage} isLoading={isLoading} placeholder={config.placeholder} />
|
|
113
119
|
</div>
|
|
114
120
|
</div>
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
);
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
});
|
|
118
124
|
|
|
119
125
|
ChatSidebar.displayName = 'ChatSidebar';
|
|
@@ -18,19 +18,7 @@ export interface ChatWidgetProps extends ChatWidgetConfig {
|
|
|
18
18
|
* Internal chat widget that uses context
|
|
19
19
|
*/
|
|
20
20
|
const ChatWidgetInternal: React.FC<{ className?: string }> = ({ className }) => {
|
|
21
|
-
const {
|
|
22
|
-
messages,
|
|
23
|
-
isLoading,
|
|
24
|
-
isMinimized,
|
|
25
|
-
config,
|
|
26
|
-
displayMode,
|
|
27
|
-
isMobile,
|
|
28
|
-
sendMessage,
|
|
29
|
-
openChat,
|
|
30
|
-
closeChat,
|
|
31
|
-
toggleMinimize,
|
|
32
|
-
setDisplayMode,
|
|
33
|
-
} = useChatContext();
|
|
21
|
+
const { config, displayMode, openChat } = useChatContext();
|
|
34
22
|
|
|
35
23
|
// Use layout hook for consistent positioning
|
|
36
24
|
const { getFabStyles, getFloatingStyles } = useChatLayout();
|
|
@@ -60,16 +48,7 @@ const ChatWidgetInternal: React.FC<{ className?: string }> = ({ className }) =>
|
|
|
60
48
|
if (displayMode === 'sidebar') {
|
|
61
49
|
return (
|
|
62
50
|
<Portal>
|
|
63
|
-
<ChatSidebar
|
|
64
|
-
messages={messages}
|
|
65
|
-
isLoading={isLoading}
|
|
66
|
-
onSendMessage={sendMessage}
|
|
67
|
-
onClose={closeChat}
|
|
68
|
-
onModeChange={setDisplayMode}
|
|
69
|
-
title={config.title}
|
|
70
|
-
placeholder={config.placeholder}
|
|
71
|
-
greeting={config.greeting}
|
|
72
|
-
/>
|
|
51
|
+
<ChatSidebar />
|
|
73
52
|
</Portal>
|
|
74
53
|
);
|
|
75
54
|
}
|
|
@@ -78,19 +57,7 @@ const ChatWidgetInternal: React.FC<{ className?: string }> = ({ className }) =>
|
|
|
78
57
|
return (
|
|
79
58
|
<Portal>
|
|
80
59
|
<div style={floatingStyles} className={className || ''}>
|
|
81
|
-
<ChatPanel
|
|
82
|
-
messages={messages}
|
|
83
|
-
isLoading={isLoading}
|
|
84
|
-
onSendMessage={sendMessage}
|
|
85
|
-
onClose={closeChat}
|
|
86
|
-
onMinimize={toggleMinimize}
|
|
87
|
-
onModeChange={setDisplayMode}
|
|
88
|
-
isMinimized={isMinimized}
|
|
89
|
-
isMobile={isMobile}
|
|
90
|
-
title={config.title}
|
|
91
|
-
placeholder={config.placeholder}
|
|
92
|
-
greeting={config.greeting}
|
|
93
|
-
/>
|
|
60
|
+
<ChatPanel />
|
|
94
61
|
</div>
|
|
95
62
|
</Portal>
|
|
96
63
|
);
|
|
@@ -105,7 +72,7 @@ const ChatWidgetInternal: React.FC<{ className?: string }> = ({ className }) =>
|
|
|
105
72
|
*/
|
|
106
73
|
export const ChatWidget: React.FC<ChatWidgetProps> = ({
|
|
107
74
|
apiEndpoint = '/api/chat',
|
|
108
|
-
title = 'DjangoCFG
|
|
75
|
+
title = 'DjangoCFG AI',
|
|
109
76
|
placeholder = 'Ask about DjangoCFG...',
|
|
110
77
|
greeting = "Hi! I'm your DjangoCFG documentation assistant. Ask me anything about configuration, features, or how to use the library.",
|
|
111
78
|
position = 'bottom-right',
|
|
@@ -24,7 +24,7 @@ export const MessageBubble = React.memo<MessageBubbleProps>(
|
|
|
24
24
|
|
|
25
25
|
return (
|
|
26
26
|
<div
|
|
27
|
-
className={`flex gap-3 animate-in fade-in slide-in-from-bottom-2 duration-300 ${
|
|
27
|
+
className={`flex gap-3 animate-in fade-in slide-in-from-bottom-2 duration-300 max-w-full overflow-hidden ${
|
|
28
28
|
isUser ? 'flex-row-reverse' : ''
|
|
29
29
|
}`}
|
|
30
30
|
>
|
|
@@ -52,7 +52,7 @@ export const MessageBubble = React.memo<MessageBubbleProps>(
|
|
|
52
52
|
{/* Header */}
|
|
53
53
|
<div className={`flex items-baseline gap-2 mb-1 ${isUser ? 'justify-end' : ''}`}>
|
|
54
54
|
<span className={`font-medium ${isCompact ? 'text-xs' : 'text-sm'}`}>
|
|
55
|
-
{isUser ? 'You' : 'DjangoCFG
|
|
55
|
+
{isUser ? 'You' : 'DjangoCFG AI'}
|
|
56
56
|
</span>
|
|
57
57
|
<span className="text-xs text-muted-foreground">
|
|
58
58
|
{formatTime(message.timestamp)}
|
|
@@ -69,12 +69,12 @@ export const MessageBubble = React.memo<MessageBubbleProps>(
|
|
|
69
69
|
>
|
|
70
70
|
<CardContent className={isCompact ? 'p-2' : 'p-3'}>
|
|
71
71
|
{/* Message Text */}
|
|
72
|
-
<div className={`${isCompact ? 'text-xs' : 'text-sm'}`}>
|
|
72
|
+
<div className={`${isCompact ? 'text-xs' : 'text-sm'} overflow-hidden`} style={{ overflowWrap: 'anywhere', wordBreak: 'break-word' }}>
|
|
73
73
|
{isUser ? (
|
|
74
|
-
<span className="whitespace-pre-wrap">{message.content}</span>
|
|
74
|
+
<span className="whitespace-pre-wrap" style={{ overflowWrap: 'anywhere' }}>{message.content}</span>
|
|
75
75
|
) : message.isStreaming ? (
|
|
76
76
|
// During streaming - show plain text to avoid parsing errors
|
|
77
|
-
<span className="whitespace-pre-wrap">
|
|
77
|
+
<span className="whitespace-pre-wrap" style={{ overflowWrap: 'anywhere' }}>
|
|
78
78
|
{message.content}
|
|
79
79
|
<Loader2 className="inline-block ml-1 h-3 w-3 animate-spin" />
|
|
80
80
|
</span>
|
|
@@ -7,7 +7,6 @@ export { AIChatWidget } from './AIChatWidget';
|
|
|
7
7
|
export type { AIChatWidgetProps } from './AIChatWidget';
|
|
8
8
|
|
|
9
9
|
export { ChatPanel } from './ChatPanel';
|
|
10
|
-
export type { ChatPanelProps } from './ChatPanel';
|
|
11
10
|
|
|
12
11
|
export { ChatMessages } from './ChatMessages';
|
|
13
12
|
export type { ChatMessagesProps, ChatMessagesHandle } from './ChatMessages';
|
|
@@ -19,4 +18,3 @@ export { AIMessageInput } from './MessageInput';
|
|
|
19
18
|
export type { AIMessageInputProps } from './MessageInput';
|
|
20
19
|
|
|
21
20
|
export { ChatSidebar } from './ChatSidebar';
|
|
22
|
-
export type { ChatSidebarProps } from './ChatSidebar';
|
|
@@ -33,3 +33,45 @@ export const mcpEndpoints = {
|
|
|
33
33
|
// Export defaults for backwards compatibility
|
|
34
34
|
export const DEFAULT_CHAT_API_ENDPOINT = PROD_HOST + '/api/chat';
|
|
35
35
|
export const DEFAULT_MCP_BASE_URL = PROD_HOST;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Chat sidebar layout configuration
|
|
39
|
+
*/
|
|
40
|
+
export const sidebarConfig = {
|
|
41
|
+
/** Minimum sidebar width in pixels */
|
|
42
|
+
minWidth: 320,
|
|
43
|
+
/** Maximum sidebar width in pixels */
|
|
44
|
+
maxWidth: 600,
|
|
45
|
+
/** Default sidebar width in pixels */
|
|
46
|
+
defaultWidth: 400,
|
|
47
|
+
/** Z-index for chat elements */
|
|
48
|
+
zIndex: 300,
|
|
49
|
+
/** Animation duration in milliseconds */
|
|
50
|
+
animationDuration: 200,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Chat FAB (Floating Action Button) configuration
|
|
55
|
+
*/
|
|
56
|
+
export const fabConfig = {
|
|
57
|
+
/** Bottom offset in pixels */
|
|
58
|
+
bottom: 24,
|
|
59
|
+
/** Right offset in pixels */
|
|
60
|
+
right: 24,
|
|
61
|
+
/** Size of FAB button in pixels */
|
|
62
|
+
size: 56,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* LocalStorage keys for chat state persistence
|
|
67
|
+
*/
|
|
68
|
+
export const storageKeys = {
|
|
69
|
+
/** Display mode (closed, floating, sidebar) */
|
|
70
|
+
mode: 'djangocfg-chat-mode',
|
|
71
|
+
/** User ID for conversation tracking */
|
|
72
|
+
userId: 'djangocfg-chat-user-id',
|
|
73
|
+
/** Chat messages history */
|
|
74
|
+
messages: 'djangocfg-chat-messages',
|
|
75
|
+
/** Sidebar width */
|
|
76
|
+
sidebarWidth: 'djangocfg-chat-sidebar-width',
|
|
77
|
+
};
|
|
@@ -4,10 +4,8 @@ import React, { createContext, useContext, useState, useCallback, useMemo, useEf
|
|
|
4
4
|
import { useLocalStorage, useIsMobile } from '@djangocfg/ui/hooks';
|
|
5
5
|
import { v4 as uuidv4 } from 'uuid';
|
|
6
6
|
import type { AIChatMessage, ChatApiResponse, AIChatSource, ChatWidgetConfig, ChatDisplayMode } from '../types';
|
|
7
|
+
import { storageKeys } from '../config';
|
|
7
8
|
|
|
8
|
-
const STORAGE_KEY_MODE = 'djangocfg-chat-mode';
|
|
9
|
-
const STORAGE_KEY_USER_ID = 'djangocfg-chat-user-id';
|
|
10
|
-
const STORAGE_KEY_MESSAGES = 'djangocfg-chat-messages';
|
|
11
9
|
const MAX_STORED_MESSAGES = 50;
|
|
12
10
|
|
|
13
11
|
function generateMessageId(): string {
|
|
@@ -102,13 +100,13 @@ export function ChatProvider({
|
|
|
102
100
|
const isHydratedRef = useRef(false);
|
|
103
101
|
|
|
104
102
|
// Display mode with localStorage persistence
|
|
105
|
-
const [storedMode, setStoredMode] = useLocalStorage<ChatDisplayMode>(
|
|
103
|
+
const [storedMode, setStoredMode] = useLocalStorage<ChatDisplayMode>(storageKeys.mode, 'closed');
|
|
106
104
|
|
|
107
105
|
// User ID with localStorage persistence
|
|
108
|
-
const [userId, setUserId] = useLocalStorage<string>(
|
|
106
|
+
const [userId, setUserId] = useLocalStorage<string>(storageKeys.userId, '');
|
|
109
107
|
|
|
110
108
|
// Messages storage (serialized)
|
|
111
|
-
const [storedMessages, setStoredMessages] = useLocalStorage<SerializedMessage[]>(
|
|
109
|
+
const [storedMessages, setStoredMessages] = useLocalStorage<SerializedMessage[]>(storageKeys.messages, []);
|
|
112
110
|
|
|
113
111
|
const isMobile = useIsMobile();
|
|
114
112
|
|
|
@@ -163,7 +161,7 @@ export function ChatProvider({
|
|
|
163
161
|
const config: ChatWidgetConfig = useMemo(
|
|
164
162
|
() => ({
|
|
165
163
|
apiEndpoint,
|
|
166
|
-
title: 'DjangoCFG
|
|
164
|
+
title: 'DjangoCFG AI',
|
|
167
165
|
placeholder: 'Ask about DjangoCFG...',
|
|
168
166
|
greeting:
|
|
169
167
|
"Hi! I'm your DjangoCFG documentation assistant. Ask me anything about configuration, features, or how to use the library.",
|
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useEffect, useCallback, useRef } from 'react';
|
|
3
|
+
import { useEffect, useCallback, useRef, useState } from 'react';
|
|
4
|
+
import { useLocalStorage } from '@djangocfg/ui/hooks';
|
|
4
5
|
import type { ChatDisplayMode } from '../types';
|
|
6
|
+
import { sidebarConfig, fabConfig, storageKeys } from '../config';
|
|
7
|
+
|
|
8
|
+
// Re-export for convenience
|
|
9
|
+
export const MIN_SIDEBAR_WIDTH = sidebarConfig.minWidth;
|
|
10
|
+
export const MAX_SIDEBAR_WIDTH = sidebarConfig.maxWidth;
|
|
11
|
+
export const DEFAULT_SIDEBAR_WIDTH = sidebarConfig.defaultWidth;
|
|
5
12
|
|
|
6
13
|
/**
|
|
7
14
|
* Configuration for chat layout management
|
|
8
15
|
*/
|
|
9
16
|
export interface ChatLayoutConfig {
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
/** Z-index for chat elements */
|
|
13
|
-
zIndex?: number;
|
|
17
|
+
/** Initial width of sidebar in pixels */
|
|
18
|
+
initialWidth?: number;
|
|
14
19
|
/** Animation duration in ms */
|
|
15
20
|
animationDuration?: number;
|
|
16
21
|
/** Element to push (defaults to body) */
|
|
@@ -21,10 +26,18 @@ export interface ChatLayoutConfig {
|
|
|
21
26
|
* Return type for useChatLayout hook
|
|
22
27
|
*/
|
|
23
28
|
export interface UseChatLayoutReturn {
|
|
29
|
+
/** Current sidebar width */
|
|
30
|
+
sidebarWidth: number;
|
|
24
31
|
/** Apply layout changes for mode */
|
|
25
32
|
applyLayout: (mode: ChatDisplayMode) => void;
|
|
26
33
|
/** Reset layout to default */
|
|
27
34
|
resetLayout: () => void;
|
|
35
|
+
/** Update sidebar width (for resize) */
|
|
36
|
+
updateWidth: (width: number) => void;
|
|
37
|
+
/** Start resize operation */
|
|
38
|
+
startResize: (e: React.MouseEvent) => void;
|
|
39
|
+
/** Whether currently resizing */
|
|
40
|
+
isResizing: boolean;
|
|
28
41
|
/** Get CSS for sidebar container */
|
|
29
42
|
getSidebarStyles: () => React.CSSProperties;
|
|
30
43
|
/** Get CSS for floating container */
|
|
@@ -34,9 +47,8 @@ export interface UseChatLayoutReturn {
|
|
|
34
47
|
}
|
|
35
48
|
|
|
36
49
|
const DEFAULT_CONFIG: Required<ChatLayoutConfig> = {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
animationDuration: 200,
|
|
50
|
+
initialWidth: sidebarConfig.defaultWidth,
|
|
51
|
+
animationDuration: sidebarConfig.animationDuration,
|
|
40
52
|
pushTarget: 'body',
|
|
41
53
|
};
|
|
42
54
|
|
|
@@ -69,7 +81,22 @@ interface FixedElementOriginalStyles {
|
|
|
69
81
|
*/
|
|
70
82
|
export function useChatLayout(config?: ChatLayoutConfig): UseChatLayoutReturn {
|
|
71
83
|
const mergedConfig = { ...DEFAULT_CONFIG, ...config };
|
|
72
|
-
const {
|
|
84
|
+
const { initialWidth, animationDuration, pushTarget } = mergedConfig;
|
|
85
|
+
|
|
86
|
+
// Sidebar width with localStorage persistence
|
|
87
|
+
const [storedWidth, setStoredWidth] = useLocalStorage<number>(storageKeys.sidebarWidth, initialWidth);
|
|
88
|
+
|
|
89
|
+
// Clamp stored width to valid range
|
|
90
|
+
const sidebarWidth = Math.max(MIN_SIDEBAR_WIDTH, Math.min(MAX_SIDEBAR_WIDTH, storedWidth));
|
|
91
|
+
const sidebarWidthRef = useRef(sidebarWidth);
|
|
92
|
+
|
|
93
|
+
// Resizing state (runtime only, not persisted)
|
|
94
|
+
const [isResizing, setIsResizing] = useState(false);
|
|
95
|
+
|
|
96
|
+
// Keep ref in sync
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
sidebarWidthRef.current = sidebarWidth;
|
|
99
|
+
}, [sidebarWidth]);
|
|
73
100
|
|
|
74
101
|
// Store original styles for cleanup
|
|
75
102
|
const originalStylesRef = useRef<{
|
|
@@ -157,6 +184,7 @@ export function useChatLayout(config?: ChatLayoutConfig): UseChatLayoutReturn {
|
|
|
157
184
|
*/
|
|
158
185
|
const adjustFixedElements = useCallback(
|
|
159
186
|
(open: boolean) => {
|
|
187
|
+
const currentWidth = sidebarWidthRef.current;
|
|
160
188
|
if (open) {
|
|
161
189
|
// Save and adjust fixed elements
|
|
162
190
|
const fixedElements = getFixedElements();
|
|
@@ -168,7 +196,7 @@ export function useChatLayout(config?: ChatLayoutConfig): UseChatLayoutReturn {
|
|
|
168
196
|
|
|
169
197
|
fixedElements.forEach((el) => {
|
|
170
198
|
el.style.transition = `right ${animationDuration}ms ease`;
|
|
171
|
-
el.style.right = `${
|
|
199
|
+
el.style.right = `${currentWidth}px`;
|
|
172
200
|
});
|
|
173
201
|
} else {
|
|
174
202
|
// Restore fixed elements
|
|
@@ -185,7 +213,7 @@ export function useChatLayout(config?: ChatLayoutConfig): UseChatLayoutReturn {
|
|
|
185
213
|
fixedElementsRef.current = [];
|
|
186
214
|
}
|
|
187
215
|
},
|
|
188
|
-
[getFixedElements,
|
|
216
|
+
[getFixedElements, animationDuration]
|
|
189
217
|
);
|
|
190
218
|
|
|
191
219
|
/**
|
|
@@ -195,11 +223,13 @@ export function useChatLayout(config?: ChatLayoutConfig): UseChatLayoutReturn {
|
|
|
195
223
|
const target = getTargetElement();
|
|
196
224
|
if (!target) return;
|
|
197
225
|
|
|
226
|
+
const currentWidth = sidebarWidthRef.current;
|
|
227
|
+
|
|
198
228
|
saveOriginalStyles(target);
|
|
199
229
|
|
|
200
230
|
// Add smooth transition
|
|
201
231
|
target.style.transition = `margin-right ${animationDuration}ms ease`;
|
|
202
|
-
target.style.marginRight = `${
|
|
232
|
+
target.style.marginRight = `${currentWidth}px`;
|
|
203
233
|
target.style.overflowX = 'hidden';
|
|
204
234
|
target.setAttribute('data-chat-sidebar', 'open');
|
|
205
235
|
|
|
@@ -207,7 +237,7 @@ export function useChatLayout(config?: ChatLayoutConfig): UseChatLayoutReturn {
|
|
|
207
237
|
adjustFixedElements(true);
|
|
208
238
|
|
|
209
239
|
currentModeRef.current = 'sidebar';
|
|
210
|
-
}, [getTargetElement, saveOriginalStyles,
|
|
240
|
+
}, [getTargetElement, saveOriginalStyles, animationDuration, adjustFixedElements]);
|
|
211
241
|
|
|
212
242
|
/**
|
|
213
243
|
* Apply floating/closed mode layout (reset sidebar push)
|
|
@@ -268,6 +298,83 @@ export function useChatLayout(config?: ChatLayoutConfig): UseChatLayoutReturn {
|
|
|
268
298
|
currentModeRef.current = 'closed';
|
|
269
299
|
}, [getTargetElement, restoreOriginalStyles]);
|
|
270
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Update width during resize (no animation)
|
|
303
|
+
*/
|
|
304
|
+
const updateWidthImmediate = useCallback(
|
|
305
|
+
(newWidth: number) => {
|
|
306
|
+
const clampedWidth = Math.max(MIN_SIDEBAR_WIDTH, Math.min(MAX_SIDEBAR_WIDTH, newWidth));
|
|
307
|
+
|
|
308
|
+
// Update body margin
|
|
309
|
+
const target = getTargetElement();
|
|
310
|
+
if (target && currentModeRef.current === 'sidebar') {
|
|
311
|
+
target.style.transition = 'none';
|
|
312
|
+
target.style.marginRight = `${clampedWidth}px`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Update fixed elements
|
|
316
|
+
fixedElementsRef.current.forEach(({ element }) => {
|
|
317
|
+
element.style.transition = 'none';
|
|
318
|
+
element.style.right = `${clampedWidth}px`;
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
return clampedWidth;
|
|
322
|
+
},
|
|
323
|
+
[getTargetElement]
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Update sidebar width (for resize)
|
|
328
|
+
*/
|
|
329
|
+
const updateWidth = useCallback(
|
|
330
|
+
(newWidth: number) => {
|
|
331
|
+
const clampedWidth = updateWidthImmediate(newWidth);
|
|
332
|
+
setStoredWidth(clampedWidth);
|
|
333
|
+
},
|
|
334
|
+
[updateWidthImmediate, setStoredWidth]
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Start resize operation
|
|
339
|
+
*/
|
|
340
|
+
const startResize = useCallback(
|
|
341
|
+
(e: React.MouseEvent) => {
|
|
342
|
+
e.preventDefault();
|
|
343
|
+
setIsResizing(true);
|
|
344
|
+
|
|
345
|
+
const startX = e.clientX;
|
|
346
|
+
const startWidth = sidebarWidthRef.current;
|
|
347
|
+
|
|
348
|
+
const handleMouseMove = (moveEvent: MouseEvent) => {
|
|
349
|
+
// Calculate new width (dragging left increases width)
|
|
350
|
+
const deltaX = startX - moveEvent.clientX;
|
|
351
|
+
const newWidth = startWidth + deltaX;
|
|
352
|
+
const clampedWidth = Math.max(MIN_SIDEBAR_WIDTH, Math.min(MAX_SIDEBAR_WIDTH, newWidth));
|
|
353
|
+
|
|
354
|
+
// Update DOM immediately for smooth feel
|
|
355
|
+
updateWidthImmediate(clampedWidth);
|
|
356
|
+
sidebarWidthRef.current = clampedWidth;
|
|
357
|
+
|
|
358
|
+
// Also update React state so sidebar visually resizes
|
|
359
|
+
setStoredWidth(clampedWidth);
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const handleMouseUp = () => {
|
|
363
|
+
setIsResizing(false);
|
|
364
|
+
document.removeEventListener('mousemove', handleMouseMove);
|
|
365
|
+
document.removeEventListener('mouseup', handleMouseUp);
|
|
366
|
+
document.body.style.cursor = '';
|
|
367
|
+
document.body.style.userSelect = '';
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
document.addEventListener('mousemove', handleMouseMove);
|
|
371
|
+
document.addEventListener('mouseup', handleMouseUp);
|
|
372
|
+
document.body.style.cursor = 'ew-resize';
|
|
373
|
+
document.body.style.userSelect = 'none';
|
|
374
|
+
},
|
|
375
|
+
[updateWidthImmediate, setStoredWidth]
|
|
376
|
+
);
|
|
377
|
+
|
|
271
378
|
/**
|
|
272
379
|
* Get CSS styles for sidebar container
|
|
273
380
|
*/
|
|
@@ -278,9 +385,9 @@ export function useChatLayout(config?: ChatLayoutConfig): UseChatLayoutReturn {
|
|
|
278
385
|
right: 0,
|
|
279
386
|
bottom: 0,
|
|
280
387
|
width: `${sidebarWidth}px`,
|
|
281
|
-
zIndex,
|
|
388
|
+
zIndex: sidebarConfig.zIndex,
|
|
282
389
|
};
|
|
283
|
-
}, [sidebarWidth
|
|
390
|
+
}, [sidebarWidth]);
|
|
284
391
|
|
|
285
392
|
/**
|
|
286
393
|
* Get CSS styles for floating container
|
|
@@ -289,12 +396,12 @@ export function useChatLayout(config?: ChatLayoutConfig): UseChatLayoutReturn {
|
|
|
289
396
|
(position: 'bottom-right' | 'bottom-left'): React.CSSProperties => {
|
|
290
397
|
return {
|
|
291
398
|
position: 'fixed',
|
|
292
|
-
zIndex: zIndex - 50,
|
|
293
|
-
bottom:
|
|
294
|
-
...(position === 'bottom-right' ? { right:
|
|
399
|
+
zIndex: sidebarConfig.zIndex - 50,
|
|
400
|
+
bottom: fabConfig.bottom,
|
|
401
|
+
...(position === 'bottom-right' ? { right: fabConfig.right } : { left: fabConfig.right }),
|
|
295
402
|
};
|
|
296
403
|
},
|
|
297
|
-
[
|
|
404
|
+
[]
|
|
298
405
|
);
|
|
299
406
|
|
|
300
407
|
/**
|
|
@@ -304,12 +411,12 @@ export function useChatLayout(config?: ChatLayoutConfig): UseChatLayoutReturn {
|
|
|
304
411
|
(position: 'bottom-right' | 'bottom-left'): React.CSSProperties => {
|
|
305
412
|
return {
|
|
306
413
|
position: 'fixed',
|
|
307
|
-
zIndex: zIndex - 50,
|
|
308
|
-
bottom:
|
|
309
|
-
...(position === 'bottom-right' ? { right:
|
|
414
|
+
zIndex: sidebarConfig.zIndex - 50,
|
|
415
|
+
bottom: fabConfig.bottom,
|
|
416
|
+
...(position === 'bottom-right' ? { right: fabConfig.right } : { left: fabConfig.right }),
|
|
310
417
|
};
|
|
311
418
|
},
|
|
312
|
-
[
|
|
419
|
+
[]
|
|
313
420
|
);
|
|
314
421
|
|
|
315
422
|
// Cleanup on unmount
|
|
@@ -320,8 +427,12 @@ export function useChatLayout(config?: ChatLayoutConfig): UseChatLayoutReturn {
|
|
|
320
427
|
}, [resetLayout]);
|
|
321
428
|
|
|
322
429
|
return {
|
|
430
|
+
sidebarWidth,
|
|
323
431
|
applyLayout,
|
|
324
432
|
resetLayout,
|
|
433
|
+
updateWidth,
|
|
434
|
+
startResize,
|
|
435
|
+
isResizing,
|
|
325
436
|
getSidebarStyles,
|
|
326
437
|
getFloatingStyles,
|
|
327
438
|
getFabStyles,
|