@djangocfg/layouts 2.0.7 → 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/README.md +65 -6
- package/package.json +5 -5
- package/src/auth/context/AuthContext.tsx +11 -6
- package/src/auth/hooks/index.ts +1 -0
- package/src/auth/hooks/useAuthGuard.ts +2 -2
- package/src/auth/hooks/useAutoAuth.ts +2 -2
- package/src/auth/hooks/useGithubAuth.ts +184 -0
- package/src/components/RedirectPage/RedirectPage.tsx +2 -2
- package/src/layouts/AuthLayout/AuthContext.tsx +2 -0
- package/src/layouts/AuthLayout/AuthLayout.tsx +22 -5
- package/src/layouts/AuthLayout/IdentifierForm.tsx +4 -0
- package/src/layouts/AuthLayout/OAuthCallback.tsx +172 -0
- package/src/layouts/AuthLayout/OAuthProviders.tsx +85 -0
- package/src/layouts/AuthLayout/index.ts +4 -0
- package/src/layouts/AuthLayout/types.ts +4 -0
- package/src/snippets/Analytics/events.ts +5 -0
- 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
|
@@ -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,
|