@djangocfg/layouts 2.1.20 → 2.1.21
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/layouts/AppLayout/AppLayout.tsx +29 -27
- package/src/layouts/AppLayout/BaseApp.tsx +36 -38
- package/src/layouts/PublicLayout/PublicLayout.tsx +9 -43
- package/src/layouts/PublicLayout/components/PublicFooter/DjangoCFGLogo.tsx +45 -0
- package/src/layouts/PublicLayout/components/PublicFooter/FooterBottom.tsx +114 -0
- package/src/layouts/PublicLayout/components/PublicFooter/FooterMenuSections.tsx +53 -0
- package/src/layouts/PublicLayout/components/PublicFooter/FooterProjectInfo.tsx +77 -0
- package/src/layouts/PublicLayout/components/PublicFooter/FooterSocialLinks.tsx +82 -0
- package/src/layouts/PublicLayout/components/PublicFooter/PublicFooter.tsx +129 -0
- package/src/layouts/PublicLayout/components/PublicFooter/index.ts +17 -0
- package/src/layouts/PublicLayout/components/PublicFooter/types.ts +57 -0
- package/src/layouts/PublicLayout/components/PublicMobileDrawer.tsx +3 -6
- package/src/layouts/PublicLayout/components/PublicNavigation.tsx +3 -6
- package/src/layouts/PublicLayout/index.ts +12 -1
- package/src/layouts/_components/UserMenu.tsx +159 -38
- package/src/layouts/index.ts +4 -1
- package/src/layouts/shared/README.md +86 -0
- package/src/layouts/shared/index.ts +21 -0
- package/src/layouts/shared/types.ts +215 -0
- package/src/snippets/McpChat/components/AIChatWidget.tsx +150 -53
- package/src/snippets/McpChat/components/AskAIButton.tsx +2 -5
- package/src/snippets/McpChat/components/ChatMessages.tsx +30 -9
- package/src/snippets/McpChat/components/ChatPanel.tsx +1 -1
- package/src/snippets/McpChat/components/ChatSidebar.tsx +1 -1
- package/src/snippets/McpChat/components/MessageBubble.tsx +46 -34
- package/src/snippets/McpChat/context/AIChatContext.tsx +23 -6
- package/src/layouts/PublicLayout/components/PublicFooter.tsx +0 -190
|
@@ -11,6 +11,7 @@ import type { AIChatMessage } from '../types';
|
|
|
11
11
|
*/
|
|
12
12
|
export interface ChatMessagesHandle {
|
|
13
13
|
scrollToBottom: (instant?: boolean) => void;
|
|
14
|
+
scrollToLastMessage: (instant?: boolean) => void;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export interface ChatMessagesProps {
|
|
@@ -57,6 +58,7 @@ export const ChatMessages = forwardRef<ChatMessagesHandle, ChatMessagesProps>(
|
|
|
57
58
|
ref
|
|
58
59
|
) => {
|
|
59
60
|
const scrollAreaRef = useRef<ScrollAreaHandle>(null);
|
|
61
|
+
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
|
60
62
|
const hasScrolledOnLoad = useRef(false);
|
|
61
63
|
|
|
62
64
|
// Scroll to bottom helper
|
|
@@ -66,27 +68,44 @@ export const ChatMessages = forwardRef<ChatMessagesHandle, ChatMessagesProps>(
|
|
|
66
68
|
}
|
|
67
69
|
}, []);
|
|
68
70
|
|
|
69
|
-
//
|
|
71
|
+
// Scroll to last message (shows top of last message)
|
|
72
|
+
const scrollToLastMessage = useCallback((instant = false) => {
|
|
73
|
+
if (messagesContainerRef.current) {
|
|
74
|
+
const messageElements = messagesContainerRef.current.querySelectorAll('[data-message-bubble]');
|
|
75
|
+
const lastMessage = messageElements[messageElements.length - 1];
|
|
76
|
+
|
|
77
|
+
if (lastMessage) {
|
|
78
|
+
lastMessage.scrollIntoView({
|
|
79
|
+
behavior: instant ? 'instant' : 'smooth',
|
|
80
|
+
block: 'start',
|
|
81
|
+
inline: 'nearest',
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
// Expose methods via ref
|
|
70
88
|
useImperativeHandle(ref, () => ({
|
|
71
89
|
scrollToBottom,
|
|
72
|
-
|
|
90
|
+
scrollToLastMessage,
|
|
91
|
+
}), [scrollToBottom, scrollToLastMessage]);
|
|
73
92
|
|
|
74
93
|
// Initial scroll when history loads (instant, no animation)
|
|
75
94
|
useEffect(() => {
|
|
76
95
|
if (!isLoading && messages.length > 0 && !hasScrolledOnLoad.current) {
|
|
77
96
|
requestAnimationFrame(() => {
|
|
78
|
-
|
|
97
|
+
scrollToLastMessage(true);
|
|
79
98
|
hasScrolledOnLoad.current = true;
|
|
80
99
|
});
|
|
81
100
|
}
|
|
82
|
-
}, [isLoading, messages.length,
|
|
101
|
+
}, [isLoading, messages.length, scrollToLastMessage]);
|
|
83
102
|
|
|
84
|
-
// Scroll to
|
|
103
|
+
// Scroll to last message on new messages (smooth animation, shows top of message)
|
|
85
104
|
useEffect(() => {
|
|
86
105
|
if (hasScrolledOnLoad.current && messages.length > 0) {
|
|
87
|
-
|
|
106
|
+
scrollToLastMessage(false);
|
|
88
107
|
}
|
|
89
|
-
}, [messages,
|
|
108
|
+
}, [messages, scrollToLastMessage]);
|
|
90
109
|
|
|
91
110
|
const GreetingIcon = greetingIcon === 'message' ? MessageSquare : Bot;
|
|
92
111
|
const iconSize = largeGreetingIcon ? { container: '64px', icon: 'h-8 w-8' } : { container: '48px', icon: 'h-6 w-6' };
|
|
@@ -94,7 +113,7 @@ export const ChatMessages = forwardRef<ChatMessagesHandle, ChatMessagesProps>(
|
|
|
94
113
|
|
|
95
114
|
return (
|
|
96
115
|
<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`}>
|
|
116
|
+
<div ref={messagesContainerRef} className={`${isCompact ? 'p-3' : 'p-4'} space-y-4 max-w-full overflow-x-hidden`}>
|
|
98
117
|
{/* Greeting */}
|
|
99
118
|
{messages.length === 0 && greeting && (
|
|
100
119
|
<div className={`text-center ${padding}`}>
|
|
@@ -115,7 +134,9 @@ export const ChatMessages = forwardRef<ChatMessagesHandle, ChatMessagesProps>(
|
|
|
115
134
|
|
|
116
135
|
{/* Messages */}
|
|
117
136
|
{messages.map((message) => (
|
|
118
|
-
<
|
|
137
|
+
<div key={message.id} data-message-bubble>
|
|
138
|
+
<MessageBubble message={message} isCompact={isCompact} />
|
|
139
|
+
</div>
|
|
119
140
|
))}
|
|
120
141
|
|
|
121
142
|
{/* Loading indicator with stop button */}
|
|
@@ -59,7 +59,7 @@ export const ChatPanel = React.memo(() => {
|
|
|
59
59
|
</div>
|
|
60
60
|
<div>
|
|
61
61
|
<h3 className="font-semibold text-sm">{config.title || 'DjangoCFG AI'}</h3>
|
|
62
|
-
<p className="text-xs text-muted-foreground">
|
|
62
|
+
<p className="text-xs text-muted-foreground">AI Assistant</p>
|
|
63
63
|
</div>
|
|
64
64
|
</div>
|
|
65
65
|
<div className="flex gap-1">
|
|
@@ -86,7 +86,7 @@ export const ChatSidebar = React.memo<ChatSidebarProps>(({
|
|
|
86
86
|
</div>
|
|
87
87
|
<div>
|
|
88
88
|
<h3 className="font-semibold text-sm">{config.title || 'DjangoCFG AI'}</h3>
|
|
89
|
-
<p className="text-xs text-muted-foreground">
|
|
89
|
+
<p className="text-xs text-muted-foreground">AI Assistant</p>
|
|
90
90
|
</div>
|
|
91
91
|
</div>
|
|
92
92
|
<div className="flex gap-1">
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import React from 'react';
|
|
4
|
-
import { Card, CardContent, Badge, MarkdownMessage } from '@djangocfg/ui-nextjs';
|
|
4
|
+
import { Card, CardContent, Badge, MarkdownMessage, Avatar, AvatarImage, AvatarFallback } from '@djangocfg/ui-nextjs';
|
|
5
5
|
import { User, Bot, ExternalLink, Loader2 } from 'lucide-react';
|
|
6
|
+
import { useAuth } from '@djangocfg/api/auth';
|
|
6
7
|
import type { AIChatMessage } from '../types';
|
|
7
8
|
|
|
8
9
|
export interface MessageBubbleProps {
|
|
@@ -21,6 +22,15 @@ export const MessageBubble = React.memo<MessageBubbleProps>(
|
|
|
21
22
|
({ message, isCompact = false }) => {
|
|
22
23
|
const isUser = message.role === 'user';
|
|
23
24
|
const isAssistant = message.role === 'assistant';
|
|
25
|
+
const { user, isAuthenticated } = useAuth();
|
|
26
|
+
|
|
27
|
+
// Prepare user data BEFORE render
|
|
28
|
+
const showUserAvatar = isUser && isAuthenticated && user;
|
|
29
|
+
const userAvatar = user?.avatar || '';
|
|
30
|
+
const userDisplayName = user?.display_username || user?.email || 'User';
|
|
31
|
+
const userInitial = userDisplayName.charAt(0).toUpperCase();
|
|
32
|
+
const avatarSize = isCompact ? '28px' : '36px';
|
|
33
|
+
const iconSize = isCompact ? 'h-3.5 w-3.5' : 'h-4 w-4';
|
|
24
34
|
|
|
25
35
|
return (
|
|
26
36
|
<div
|
|
@@ -29,30 +39,38 @@ export const MessageBubble = React.memo<MessageBubbleProps>(
|
|
|
29
39
|
}`}
|
|
30
40
|
>
|
|
31
41
|
{/* Avatar */}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
42
|
+
{showUserAvatar ? (
|
|
43
|
+
// Authenticated user avatar
|
|
44
|
+
<Avatar className="flex-shrink-0" style={{ width: avatarSize, height: avatarSize }}>
|
|
45
|
+
<AvatarImage src={userAvatar} alt={userDisplayName} />
|
|
46
|
+
<AvatarFallback className="bg-primary text-primary-foreground text-xs">
|
|
47
|
+
{userInitial}
|
|
48
|
+
</AvatarFallback>
|
|
49
|
+
</Avatar>
|
|
50
|
+
) : isUser ? (
|
|
51
|
+
// Guest user icon
|
|
52
|
+
<div
|
|
53
|
+
className="flex-shrink-0 rounded-full flex items-center justify-center bg-primary text-primary-foreground"
|
|
54
|
+
style={{ width: avatarSize, height: avatarSize }}
|
|
55
|
+
>
|
|
56
|
+
<User className={iconSize} />
|
|
57
|
+
</div>
|
|
58
|
+
) : (
|
|
59
|
+
// Bot icon
|
|
60
|
+
<div
|
|
61
|
+
className="flex-shrink-0 rounded-full flex items-center justify-center bg-muted text-muted-foreground"
|
|
62
|
+
style={{ width: avatarSize, height: avatarSize }}
|
|
63
|
+
>
|
|
64
|
+
<Bot className={iconSize} />
|
|
65
|
+
</div>
|
|
66
|
+
)}
|
|
49
67
|
|
|
50
68
|
{/* Message Content */}
|
|
51
69
|
<div className={`flex-1 min-w-0 ${isUser ? 'max-w-[80%] ml-auto' : 'max-w-[85%]'}`}>
|
|
52
70
|
{/* Header */}
|
|
53
71
|
<div className={`flex items-baseline gap-2 mb-1 ${isUser ? 'justify-end' : ''}`}>
|
|
54
72
|
<span className={`font-medium ${isCompact ? 'text-xs' : 'text-sm'}`}>
|
|
55
|
-
{isUser ?
|
|
73
|
+
{isUser ? userDisplayName : 'DjangoCFG AI'}
|
|
56
74
|
</span>
|
|
57
75
|
<span className="text-xs text-muted-foreground">
|
|
58
76
|
{formatTime(message.timestamp)}
|
|
@@ -70,21 +88,15 @@ export const MessageBubble = React.memo<MessageBubbleProps>(
|
|
|
70
88
|
<CardContent className={isCompact ? 'p-2' : 'p-3'}>
|
|
71
89
|
{/* Message Text */}
|
|
72
90
|
<div className={`${isCompact ? 'text-xs' : 'text-sm'} overflow-hidden`} style={{ overflowWrap: 'anywhere', wordBreak: 'break-word' }}>
|
|
73
|
-
{
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
// Streaming complete - render with markdown formatting
|
|
83
|
-
<MarkdownMessage
|
|
84
|
-
content={message.content}
|
|
85
|
-
isUser={isUser}
|
|
86
|
-
isCompact={isCompact}
|
|
87
|
-
/>
|
|
91
|
+
{/* Always render markdown for immediate code block highlighting */}
|
|
92
|
+
<MarkdownMessage
|
|
93
|
+
content={message.content}
|
|
94
|
+
isUser={isUser}
|
|
95
|
+
isCompact={isCompact}
|
|
96
|
+
/>
|
|
97
|
+
{/* Show spinner during streaming */}
|
|
98
|
+
{message.isStreaming && (
|
|
99
|
+
<Loader2 className="inline-block ml-1 h-3 w-3 animate-spin" />
|
|
88
100
|
)}
|
|
89
101
|
</div>
|
|
90
102
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { createContext, useContext, useState, useCallback, useMemo, useEffect, type ReactNode } from 'react';
|
|
3
|
+
import { createContext, useContext, useState, useCallback, useMemo, useEffect, useRef, type ReactNode } from 'react';
|
|
4
4
|
import { useLocalStorage, useIsMobile } from '@djangocfg/ui-nextjs/hooks';
|
|
5
5
|
import { useAIChat } from '../hooks/useAIChat';
|
|
6
6
|
import { mcpEndpoints, type AIChatMessage, type ChatWidgetConfig, type ChatDisplayMode } from '../types';
|
|
@@ -119,10 +119,24 @@ export function AIChatProvider({
|
|
|
119
119
|
// Derived state: isOpen is true when not in 'closed' mode
|
|
120
120
|
const isOpen = displayMode !== 'closed';
|
|
121
121
|
|
|
122
|
+
// Track isOpen in a ref for event handler
|
|
123
|
+
const isOpenRef = useRef(isOpen);
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
isOpenRef.current = isOpen;
|
|
126
|
+
}, [isOpen]);
|
|
127
|
+
|
|
128
|
+
// Track last active mode (non-closed) to restore when reopening
|
|
129
|
+
const lastActiveModeRef = useRef<Exclude<ChatDisplayMode, 'closed'>>('floating');
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (displayMode !== 'closed') {
|
|
132
|
+
lastActiveModeRef.current = displayMode;
|
|
133
|
+
}
|
|
134
|
+
}, [displayMode]);
|
|
135
|
+
|
|
122
136
|
const config: ChatWidgetConfig = useMemo(
|
|
123
137
|
() => ({
|
|
124
138
|
apiEndpoint,
|
|
125
|
-
title: 'DjangoCFG AI
|
|
139
|
+
title: 'DjangoCFG AI',
|
|
126
140
|
placeholder: 'Ask about DjangoCFG...',
|
|
127
141
|
greeting:
|
|
128
142
|
"Hi! I'm your DjangoCFG AI assistant powered by GPT. Ask me anything about configuration, features, or how to use the library.",
|
|
@@ -145,7 +159,8 @@ export function AIChatProvider({
|
|
|
145
159
|
}, [clearAIMessages]);
|
|
146
160
|
|
|
147
161
|
const openChat = useCallback(() => {
|
|
148
|
-
|
|
162
|
+
// Restore last active mode instead of always opening in floating
|
|
163
|
+
setStoredMode(lastActiveModeRef.current);
|
|
149
164
|
setIsMinimized(false);
|
|
150
165
|
}, [setStoredMode]);
|
|
151
166
|
|
|
@@ -258,12 +273,14 @@ export function AIChatProvider({
|
|
|
258
273
|
}
|
|
259
274
|
}
|
|
260
275
|
|
|
261
|
-
// Open chat in requested mode
|
|
276
|
+
// Open chat in requested mode, or keep current mode if already open
|
|
262
277
|
if (requestedMode) {
|
|
263
278
|
setDisplayMode(requestedMode);
|
|
264
|
-
} else {
|
|
279
|
+
} else if (!isOpenRef.current) {
|
|
280
|
+
// Only open if chat is currently closed
|
|
265
281
|
openChat();
|
|
266
282
|
}
|
|
283
|
+
// If already open, keep current mode
|
|
267
284
|
|
|
268
285
|
// Auto-send message if requested
|
|
269
286
|
if (autoSend) {
|
|
@@ -282,7 +299,7 @@ export function AIChatProvider({
|
|
|
282
299
|
(window as any).__MCP_CHAT_AVAILABLE__ = false;
|
|
283
300
|
}
|
|
284
301
|
};
|
|
285
|
-
}, [sendMessage,
|
|
302
|
+
}, [sendMessage, setDisplayMode, openChat]);
|
|
286
303
|
|
|
287
304
|
return <AIChatContext.Provider value={value}>{children}</AIChatContext.Provider>;
|
|
288
305
|
}
|
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Public Layout Footer
|
|
3
|
-
*
|
|
4
|
-
* Footer component for PublicLayout
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
'use client';
|
|
8
|
-
|
|
9
|
-
import React from 'react';
|
|
10
|
-
import Link from 'next/link';
|
|
11
|
-
import { useIsMobile } from '@djangocfg/ui-nextjs/hooks';
|
|
12
|
-
|
|
13
|
-
interface FooterConfig {
|
|
14
|
-
links?: {
|
|
15
|
-
privacy?: string;
|
|
16
|
-
terms?: string;
|
|
17
|
-
security?: string;
|
|
18
|
-
cookies?: string;
|
|
19
|
-
docs?: string;
|
|
20
|
-
};
|
|
21
|
-
copyright?: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
interface PublicFooterProps {
|
|
25
|
-
logo?: string;
|
|
26
|
-
siteName: string;
|
|
27
|
-
footer?: FooterConfig;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function PublicFooter({
|
|
31
|
-
logo,
|
|
32
|
-
siteName,
|
|
33
|
-
footer,
|
|
34
|
-
}: PublicFooterProps) {
|
|
35
|
-
const isMobile = useIsMobile();
|
|
36
|
-
const currentYear = new Date().getFullYear();
|
|
37
|
-
const copyright =
|
|
38
|
-
footer?.copyright ||
|
|
39
|
-
`© ${currentYear} ${siteName}. All rights reserved.`;
|
|
40
|
-
|
|
41
|
-
if (isMobile) {
|
|
42
|
-
return (
|
|
43
|
-
<footer className="lg:hidden bg-background border-t border-border mt-auto">
|
|
44
|
-
<div className="w-full px-4 py-8">
|
|
45
|
-
{/* Project Info */}
|
|
46
|
-
<div className="text-center space-y-4 mb-6">
|
|
47
|
-
<div className="flex items-center justify-center gap-2">
|
|
48
|
-
{logo && (
|
|
49
|
-
<div className="w-6 h-6 flex items-center justify-center">
|
|
50
|
-
<img
|
|
51
|
-
src={logo}
|
|
52
|
-
alt={`${siteName} Logo`}
|
|
53
|
-
className="w-full h-full object-contain"
|
|
54
|
-
/>
|
|
55
|
-
</div>
|
|
56
|
-
)}
|
|
57
|
-
<span className="text-lg font-bold text-foreground">
|
|
58
|
-
{siteName}
|
|
59
|
-
</span>
|
|
60
|
-
</div>
|
|
61
|
-
</div>
|
|
62
|
-
|
|
63
|
-
{/* Quick Links */}
|
|
64
|
-
<div className="flex flex-wrap justify-center gap-4 mb-6 items-center">
|
|
65
|
-
{footer?.links?.docs && (
|
|
66
|
-
<a
|
|
67
|
-
href={footer.links.docs}
|
|
68
|
-
target="_blank"
|
|
69
|
-
rel="noopener noreferrer"
|
|
70
|
-
className="text-sm text-muted-foreground hover:text-primary transition-colors"
|
|
71
|
-
title="Documentation"
|
|
72
|
-
>
|
|
73
|
-
Docs
|
|
74
|
-
</a>
|
|
75
|
-
)}
|
|
76
|
-
{footer?.links?.privacy && (
|
|
77
|
-
<Link
|
|
78
|
-
href={footer.links.privacy}
|
|
79
|
-
className="text-sm text-muted-foreground hover:text-primary transition-colors"
|
|
80
|
-
>
|
|
81
|
-
Privacy
|
|
82
|
-
</Link>
|
|
83
|
-
)}
|
|
84
|
-
{footer?.links?.terms && (
|
|
85
|
-
<Link
|
|
86
|
-
href={footer.links.terms}
|
|
87
|
-
className="text-sm text-muted-foreground hover:text-primary transition-colors"
|
|
88
|
-
>
|
|
89
|
-
Terms
|
|
90
|
-
</Link>
|
|
91
|
-
)}
|
|
92
|
-
</div>
|
|
93
|
-
|
|
94
|
-
{/* Bottom Section */}
|
|
95
|
-
<div className="border-t border-border pt-4">
|
|
96
|
-
<div className="text-center space-y-2">
|
|
97
|
-
<div className="text-xs text-muted-foreground">{copyright}</div>
|
|
98
|
-
</div>
|
|
99
|
-
</div>
|
|
100
|
-
</div>
|
|
101
|
-
</footer>
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Desktop Footer
|
|
106
|
-
return (
|
|
107
|
-
<footer className="max-lg:hidden bg-background border-t border-border mt-auto">
|
|
108
|
-
<div className="w-full px-8 lg:px-16 xl:px-24 py-12">
|
|
109
|
-
<div className="flex flex-col gap-8">
|
|
110
|
-
{/* Top Section */}
|
|
111
|
-
<div className="flex gap-8">
|
|
112
|
-
{/* Left Column - Project Info */}
|
|
113
|
-
<div className="space-y-4" style={{ width: '30%', minWidth: '300px' }}>
|
|
114
|
-
<div className="flex items-center gap-2">
|
|
115
|
-
{logo && (
|
|
116
|
-
<div className="w-8 h-8 flex items-center justify-center">
|
|
117
|
-
<img
|
|
118
|
-
src={logo}
|
|
119
|
-
alt={`${siteName} Logo`}
|
|
120
|
-
className="w-full h-full object-contain"
|
|
121
|
-
/>
|
|
122
|
-
</div>
|
|
123
|
-
)}
|
|
124
|
-
<span className="text-xl font-bold text-foreground">
|
|
125
|
-
{siteName}
|
|
126
|
-
</span>
|
|
127
|
-
</div>
|
|
128
|
-
</div>
|
|
129
|
-
</div>
|
|
130
|
-
|
|
131
|
-
{/* Bottom Section */}
|
|
132
|
-
<div
|
|
133
|
-
className="border-t border-border"
|
|
134
|
-
style={{ marginTop: '2rem', paddingTop: '2rem' }}
|
|
135
|
-
>
|
|
136
|
-
<div className="flex justify-between items-center gap-4">
|
|
137
|
-
<div className="text-xs text-muted-foreground">{copyright}</div>
|
|
138
|
-
<div className="flex flex-wrap items-center gap-4">
|
|
139
|
-
{footer?.links?.docs && (
|
|
140
|
-
<a
|
|
141
|
-
href={footer.links.docs}
|
|
142
|
-
target="_blank"
|
|
143
|
-
rel="noopener noreferrer"
|
|
144
|
-
className="text-xs text-muted-foreground hover:text-primary transition-colors"
|
|
145
|
-
title="Documentation"
|
|
146
|
-
>
|
|
147
|
-
Docs
|
|
148
|
-
</a>
|
|
149
|
-
)}
|
|
150
|
-
{footer?.links?.privacy && (
|
|
151
|
-
<Link
|
|
152
|
-
href={footer.links.privacy}
|
|
153
|
-
className="text-xs text-muted-foreground hover:text-primary transition-colors"
|
|
154
|
-
>
|
|
155
|
-
Privacy Policy
|
|
156
|
-
</Link>
|
|
157
|
-
)}
|
|
158
|
-
{footer?.links?.terms && (
|
|
159
|
-
<Link
|
|
160
|
-
href={footer.links.terms}
|
|
161
|
-
className="text-xs text-muted-foreground hover:text-primary transition-colors"
|
|
162
|
-
>
|
|
163
|
-
Terms of Service
|
|
164
|
-
</Link>
|
|
165
|
-
)}
|
|
166
|
-
{footer?.links?.security && (
|
|
167
|
-
<Link
|
|
168
|
-
href={footer.links.security}
|
|
169
|
-
className="text-xs text-muted-foreground hover:text-primary transition-colors"
|
|
170
|
-
>
|
|
171
|
-
Security
|
|
172
|
-
</Link>
|
|
173
|
-
)}
|
|
174
|
-
{footer?.links?.cookies && (
|
|
175
|
-
<Link
|
|
176
|
-
href={footer.links.cookies}
|
|
177
|
-
className="text-xs text-muted-foreground hover:text-primary transition-colors"
|
|
178
|
-
>
|
|
179
|
-
Cookies
|
|
180
|
-
</Link>
|
|
181
|
-
)}
|
|
182
|
-
</div>
|
|
183
|
-
</div>
|
|
184
|
-
</div>
|
|
185
|
-
</div>
|
|
186
|
-
</div>
|
|
187
|
-
</footer>
|
|
188
|
-
);
|
|
189
|
-
}
|
|
190
|
-
|