@nextclaw/ui 0.3.15 → 0.3.17

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.
@@ -0,0 +1,272 @@
1
+ import { useState, useRef, useCallback, useEffect } from 'react';
2
+ import { DOCS_DEFAULT_BASE_URL, useDocBrowser } from './DocBrowserContext';
3
+ import { cn } from '@/lib/utils';
4
+ import { t } from '@/lib/i18n';
5
+ import {
6
+ ArrowLeft,
7
+ ArrowRight,
8
+ X,
9
+ ExternalLink,
10
+ PanelRightOpen,
11
+ Maximize2,
12
+ GripVertical,
13
+ Search,
14
+ BookOpen,
15
+ } from 'lucide-react';
16
+
17
+ /**
18
+ * DocBrowser — An in-app micro-browser for documentation.
19
+ *
20
+ * Supports two modes:
21
+ * - `docked`: Renders as a right sidebar panel (horizontally resizable)
22
+ * - `floating`: Renders as a draggable, resizable overlay
23
+ */
24
+ export function DocBrowser() {
25
+ const {
26
+ isOpen, mode, currentUrl,
27
+ close, toggleMode,
28
+ goBack, goForward, canGoBack, canGoForward,
29
+ navigate,
30
+ } = useDocBrowser();
31
+
32
+ const [urlInput, setUrlInput] = useState('');
33
+ const [isDragging, setIsDragging] = useState(false);
34
+ const [floatPos, setFloatPos] = useState({ x: 120, y: 80 });
35
+ const [floatSize, setFloatSize] = useState({ w: 480, h: 600 });
36
+ const [dockedWidth, setDockedWidth] = useState(420);
37
+ const dragRef = useRef<{ startX: number; startY: number; startPosX: number; startPosY: number } | null>(null);
38
+ const resizeRef = useRef<{ startX: number; startY: number; startW: number; startH: number } | null>(null);
39
+ const dockResizeRef = useRef<{ startX: number; startW: number } | null>(null);
40
+
41
+ // Sync URL input with current URL
42
+ useEffect(() => {
43
+ try {
44
+ const parsed = new URL(currentUrl);
45
+ setUrlInput(parsed.pathname);
46
+ } catch {
47
+ setUrlInput(currentUrl);
48
+ }
49
+ }, [currentUrl]);
50
+
51
+ const handleUrlSubmit = useCallback((e: React.FormEvent) => {
52
+ e.preventDefault();
53
+ const input = urlInput.trim();
54
+ if (!input) return;
55
+ if (input.startsWith('/')) {
56
+ navigate(`${DOCS_DEFAULT_BASE_URL}${input}`);
57
+ } else if (input.startsWith('http')) {
58
+ navigate(input);
59
+ } else {
60
+ navigate(`${DOCS_DEFAULT_BASE_URL}/${input}`);
61
+ }
62
+ }, [urlInput, navigate]);
63
+
64
+ // --- Dragging logic (floating mode) ---
65
+ const onDragStart = useCallback((e: React.MouseEvent) => {
66
+ if (mode !== 'floating') return;
67
+ setIsDragging(true);
68
+ dragRef.current = {
69
+ startX: e.clientX,
70
+ startY: e.clientY,
71
+ startPosX: floatPos.x,
72
+ startPosY: floatPos.y,
73
+ };
74
+ }, [mode, floatPos]);
75
+
76
+ useEffect(() => {
77
+ if (!isDragging) return;
78
+ const onMove = (e: MouseEvent) => {
79
+ if (!dragRef.current) return;
80
+ setFloatPos({
81
+ x: dragRef.current.startPosX + (e.clientX - dragRef.current.startX),
82
+ y: dragRef.current.startPosY + (e.clientY - dragRef.current.startY),
83
+ });
84
+ };
85
+ const onUp = () => {
86
+ setIsDragging(false);
87
+ dragRef.current = null;
88
+ };
89
+ window.addEventListener('mousemove', onMove);
90
+ window.addEventListener('mouseup', onUp);
91
+ return () => {
92
+ window.removeEventListener('mousemove', onMove);
93
+ window.removeEventListener('mouseup', onUp);
94
+ };
95
+ }, [isDragging]);
96
+
97
+ // --- Resize logic (floating mode — bottom-right corner) ---
98
+ const onResizeStart = useCallback((e: React.MouseEvent) => {
99
+ e.preventDefault();
100
+ e.stopPropagation();
101
+ resizeRef.current = {
102
+ startX: e.clientX,
103
+ startY: e.clientY,
104
+ startW: floatSize.w,
105
+ startH: floatSize.h,
106
+ };
107
+ const onMove = (ev: MouseEvent) => {
108
+ if (!resizeRef.current) return;
109
+ setFloatSize({
110
+ w: Math.max(360, resizeRef.current.startW + (ev.clientX - resizeRef.current.startX)),
111
+ h: Math.max(400, resizeRef.current.startH + (ev.clientY - resizeRef.current.startY)),
112
+ });
113
+ };
114
+ const onUp = () => {
115
+ resizeRef.current = null;
116
+ window.removeEventListener('mousemove', onMove);
117
+ window.removeEventListener('mouseup', onUp);
118
+ };
119
+ window.addEventListener('mousemove', onMove);
120
+ window.addEventListener('mouseup', onUp);
121
+ }, [floatSize]);
122
+
123
+ // --- Horizontal resize logic (docked mode — left edge) ---
124
+ const onDockResizeStart = useCallback((e: React.MouseEvent) => {
125
+ e.preventDefault();
126
+ e.stopPropagation();
127
+ dockResizeRef.current = { startX: e.clientX, startW: dockedWidth };
128
+ const onMove = (ev: MouseEvent) => {
129
+ if (!dockResizeRef.current) return;
130
+ // Dragging left should increase width (since resize handle is on the left edge)
131
+ const delta = dockResizeRef.current.startX - ev.clientX;
132
+ setDockedWidth(Math.max(320, Math.min(800, dockResizeRef.current.startW + delta)));
133
+ };
134
+ const onUp = () => {
135
+ dockResizeRef.current = null;
136
+ window.removeEventListener('mousemove', onMove);
137
+ window.removeEventListener('mouseup', onUp);
138
+ };
139
+ window.addEventListener('mousemove', onMove);
140
+ window.addEventListener('mouseup', onUp);
141
+ }, [dockedWidth]);
142
+
143
+ if (!isOpen) return null;
144
+
145
+ const isDocked = mode === 'docked';
146
+
147
+ const panel = (
148
+ <div
149
+ className={cn(
150
+ 'flex flex-col bg-white overflow-hidden relative',
151
+ isDocked
152
+ ? 'h-full border-l border-gray-200 shrink-0'
153
+ : 'rounded-2xl shadow-2xl border border-gray-200',
154
+ )}
155
+ style={
156
+ isDocked
157
+ ? { width: dockedWidth }
158
+ : {
159
+ position: 'fixed',
160
+ left: floatPos.x,
161
+ top: floatPos.y,
162
+ width: floatSize.w,
163
+ height: floatSize.h,
164
+ zIndex: 9999,
165
+ }
166
+ }
167
+ >
168
+ {/* Docked mode: left-edge resize handle */}
169
+ {isDocked && (
170
+ <div
171
+ className="absolute top-0 left-0 w-1.5 h-full cursor-ew-resize z-20 hover:bg-primary/10 transition-colors"
172
+ onMouseDown={onDockResizeStart}
173
+ />
174
+ )}
175
+
176
+ {/* Title Bar */}
177
+ <div
178
+ className={cn(
179
+ 'flex items-center justify-between px-4 py-2.5 bg-gray-50 border-b border-gray-200 shrink-0 select-none',
180
+ !isDocked && 'cursor-grab active:cursor-grabbing',
181
+ )}
182
+ onMouseDown={!isDocked ? onDragStart : undefined}
183
+ >
184
+ <div className="flex items-center gap-2.5">
185
+ <BookOpen className="w-4 h-4 text-primary" />
186
+ <span className="text-sm font-semibold text-gray-900">{t('docBrowserTitle')}</span>
187
+ </div>
188
+ <div className="flex items-center gap-1">
189
+ <button
190
+ onClick={toggleMode}
191
+ className="hover:bg-gray-200 rounded-md p-1.5 text-gray-500 hover:text-gray-700 transition-colors"
192
+ title={isDocked ? t('docBrowserFloatMode') : t('docBrowserDockMode')}
193
+ >
194
+ {isDocked ? <Maximize2 className="w-3.5 h-3.5" /> : <PanelRightOpen className="w-3.5 h-3.5" />}
195
+ </button>
196
+ <button
197
+ onClick={close}
198
+ className="hover:bg-gray-200 rounded-md p-1.5 text-gray-500 hover:text-gray-700 transition-colors"
199
+ title={t('docBrowserClose')}
200
+ >
201
+ <X className="w-3.5 h-3.5" />
202
+ </button>
203
+ </div>
204
+ </div>
205
+
206
+ {/* Navigation Bar */}
207
+ <div className="flex items-center gap-2 px-3.5 py-2 bg-white border-b border-gray-100 shrink-0">
208
+ <button
209
+ onClick={goBack}
210
+ disabled={!canGoBack}
211
+ className="p-1.5 rounded-md hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed text-gray-600 transition-colors"
212
+ >
213
+ <ArrowLeft className="w-4 h-4" />
214
+ </button>
215
+ <button
216
+ onClick={goForward}
217
+ disabled={!canGoForward}
218
+ className="p-1.5 rounded-md hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed text-gray-600 transition-colors"
219
+ >
220
+ <ArrowRight className="w-4 h-4" />
221
+ </button>
222
+
223
+ <form onSubmit={handleUrlSubmit} className="flex-1 relative">
224
+ <Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
225
+ <input
226
+ type="text"
227
+ value={urlInput}
228
+ onChange={(e) => setUrlInput(e.target.value)}
229
+ placeholder={t('docBrowserSearchPlaceholder')}
230
+ className="w-full h-8 pl-8 pr-3 rounded-lg bg-gray-50 border border-gray-200 text-xs text-gray-700 focus:outline-none focus:ring-1 focus:ring-primary/30 focus:border-primary/40 transition-colors placeholder:text-gray-400"
231
+ />
232
+ </form>
233
+ </div>
234
+
235
+ {/* Iframe Content */}
236
+ <div className="flex-1 relative overflow-hidden">
237
+ <iframe
238
+ src={currentUrl}
239
+ className="absolute inset-0 w-full h-full border-0"
240
+ title="NextClaw Documentation"
241
+ sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
242
+ allow="clipboard-read; clipboard-write"
243
+ />
244
+ </div>
245
+
246
+ {/* Footer */}
247
+ <div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-t border-gray-200 shrink-0">
248
+ <a
249
+ href={currentUrl}
250
+ target="_blank"
251
+ rel="noopener noreferrer"
252
+ className="flex items-center gap-1.5 text-xs text-primary hover:text-primary-hover font-medium transition-colors"
253
+ >
254
+ {t('docBrowserOpenExternal')}
255
+ <ExternalLink className="w-3 h-3" />
256
+ </a>
257
+ </div>
258
+
259
+ {/* Resize Handle (floating only — bottom-right corner) */}
260
+ {!isDocked && (
261
+ <div
262
+ className="absolute bottom-0 right-0 w-5 h-5 cursor-se-resize flex items-center justify-center text-gray-300 hover:text-gray-500 transition-colors"
263
+ onMouseDown={onResizeStart}
264
+ >
265
+ <GripVertical className="w-3 h-3 rotate-[-45deg]" />
266
+ </div>
267
+ )}
268
+ </div>
269
+ );
270
+
271
+ return panel;
272
+ }
@@ -0,0 +1,134 @@
1
+ import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react';
2
+
3
+ const DOCS_PRIMARY_DOMAIN = 'docs.nextclaw.io';
4
+ const DOCS_FALLBACK_DOMAIN = 'nextclaw-docs.pages.dev';
5
+ const DOCS_HOSTS = new Set([
6
+ DOCS_PRIMARY_DOMAIN,
7
+ `www.${DOCS_PRIMARY_DOMAIN}`,
8
+ DOCS_FALLBACK_DOMAIN,
9
+ `www.${DOCS_FALLBACK_DOMAIN}`,
10
+ ]);
11
+
12
+ export const DOCS_DEFAULT_BASE_URL = `https://${DOCS_FALLBACK_DOMAIN}`;
13
+
14
+ export type DocBrowserMode = 'floating' | 'docked';
15
+
16
+ interface DocBrowserState {
17
+ isOpen: boolean;
18
+ mode: DocBrowserMode;
19
+ currentUrl: string;
20
+ history: string[];
21
+ historyIndex: number;
22
+ }
23
+
24
+ interface DocBrowserActions {
25
+ open: (url?: string) => void;
26
+ close: () => void;
27
+ toggleMode: () => void;
28
+ setMode: (mode: DocBrowserMode) => void;
29
+ navigate: (url: string) => void;
30
+ goBack: () => void;
31
+ goForward: () => void;
32
+ canGoBack: boolean;
33
+ canGoForward: boolean;
34
+ }
35
+
36
+ type DocBrowserContextValue = DocBrowserState & DocBrowserActions;
37
+
38
+ const DocBrowserContext = createContext<DocBrowserContextValue | null>(null);
39
+
40
+ export function useDocBrowser(): DocBrowserContextValue {
41
+ const ctx = useContext(DocBrowserContext);
42
+ if (!ctx) throw new Error('useDocBrowser must be used within DocBrowserProvider');
43
+ return ctx;
44
+ }
45
+
46
+ /** Check if a URL belongs to the docs domain */
47
+ export function isDocsUrl(url: string): boolean {
48
+ try {
49
+ const parsed = new URL(url, window.location.origin);
50
+ return DOCS_HOSTS.has(parsed.hostname);
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ export function DocBrowserProvider({ children }: { children: ReactNode }) {
57
+ const [state, setState] = useState<DocBrowserState>({
58
+ isOpen: false,
59
+ mode: 'docked',
60
+ currentUrl: `${DOCS_DEFAULT_BASE_URL}/guide/getting-started`,
61
+ history: [`${DOCS_DEFAULT_BASE_URL}/guide/getting-started`],
62
+ historyIndex: 0,
63
+ });
64
+
65
+ const open = useCallback((url?: string) => {
66
+ const targetUrl = url || state.currentUrl || `${DOCS_DEFAULT_BASE_URL}/guide/getting-started`;
67
+ setState(prev => ({
68
+ ...prev,
69
+ isOpen: true,
70
+ currentUrl: targetUrl,
71
+ history: [...prev.history.slice(0, prev.historyIndex + 1), targetUrl],
72
+ historyIndex: prev.historyIndex + 1,
73
+ }));
74
+ }, [state.currentUrl]);
75
+
76
+ const close = useCallback(() => {
77
+ setState(prev => ({ ...prev, isOpen: false }));
78
+ }, []);
79
+
80
+ const toggleMode = useCallback(() => {
81
+ setState(prev => ({ ...prev, mode: prev.mode === 'floating' ? 'docked' : 'floating' }));
82
+ }, []);
83
+
84
+ const setMode = useCallback((mode: DocBrowserMode) => {
85
+ setState(prev => ({ ...prev, mode }));
86
+ }, []);
87
+
88
+ const navigate = useCallback((url: string) => {
89
+ setState(prev => ({
90
+ ...prev,
91
+ currentUrl: url,
92
+ history: [...prev.history.slice(0, prev.historyIndex + 1), url],
93
+ historyIndex: prev.historyIndex + 1,
94
+ }));
95
+ }, []);
96
+
97
+ const goBack = useCallback(() => {
98
+ setState(prev => {
99
+ if (prev.historyIndex <= 0) return prev;
100
+ const newIndex = prev.historyIndex - 1;
101
+ return { ...prev, historyIndex: newIndex, currentUrl: prev.history[newIndex] };
102
+ });
103
+ }, []);
104
+
105
+ const goForward = useCallback(() => {
106
+ setState(prev => {
107
+ if (prev.historyIndex >= prev.history.length - 1) return prev;
108
+ const newIndex = prev.historyIndex + 1;
109
+ return { ...prev, historyIndex: newIndex, currentUrl: prev.history[newIndex] };
110
+ });
111
+ }, []);
112
+
113
+ const canGoBack = state.historyIndex > 0;
114
+ const canGoForward = state.historyIndex < state.history.length - 1;
115
+
116
+ const value = useMemo<DocBrowserContextValue>(() => ({
117
+ ...state,
118
+ open,
119
+ close,
120
+ toggleMode,
121
+ setMode,
122
+ navigate,
123
+ goBack,
124
+ goForward,
125
+ canGoBack,
126
+ canGoForward,
127
+ }), [state, open, close, toggleMode, setMode, navigate, goBack, goForward, canGoBack, canGoForward]);
128
+
129
+ return (
130
+ <DocBrowserContext.Provider value={value}>
131
+ {children}
132
+ </DocBrowserContext.Provider>
133
+ );
134
+ }
@@ -0,0 +1,3 @@
1
+ export { DocBrowser } from './DocBrowser';
2
+ export { DocBrowserProvider, useDocBrowser, isDocsUrl } from './DocBrowserContext';
3
+ export { useDocLinkInterceptor } from './useDocLinkInterceptor';
@@ -0,0 +1,33 @@
1
+ import { useEffect } from 'react';
2
+ import { isDocsUrl, useDocBrowser } from './DocBrowserContext';
3
+
4
+ /**
5
+ * Global click interceptor for docs links.
6
+ * Captures clicks on <a> tags pointing to the docs domain
7
+ * and opens them in the in-app micro-browser instead.
8
+ */
9
+ export function useDocLinkInterceptor() {
10
+ const docBrowser = useDocBrowser();
11
+
12
+ useEffect(() => {
13
+ const handler = (e: MouseEvent) => {
14
+ // Walk up from the click target to find an anchor
15
+ const anchor = (e.target as HTMLElement).closest<HTMLAnchorElement>('a[href]');
16
+ if (!anchor) return;
17
+
18
+ const href = anchor.getAttribute('href') || '';
19
+ if (!isDocsUrl(href)) return;
20
+
21
+ // Don't intercept if modifier keys are held (user wants new tab behavior)
22
+ if (e.ctrlKey || e.metaKey || e.shiftKey) return;
23
+
24
+ e.preventDefault();
25
+ e.stopPropagation();
26
+ docBrowser.open(href);
27
+ };
28
+
29
+ // Use capture phase to intercept before React's synthetic events
30
+ document.addEventListener('click', handler, true);
31
+ return () => document.removeEventListener('click', handler, true);
32
+ }, [docBrowser]);
33
+ }
@@ -1,20 +1,37 @@
1
1
  import { Sidebar } from './Sidebar';
2
+ import { DocBrowserProvider, DocBrowser, useDocBrowser, useDocLinkInterceptor } from '@/components/doc-browser';
2
3
 
3
4
  interface AppLayoutProps {
4
5
  children: React.ReactNode;
5
6
  }
6
7
 
7
- export function AppLayout({ children }: AppLayoutProps) {
8
+ function AppLayoutInner({ children }: AppLayoutProps) {
9
+ const { isOpen, mode } = useDocBrowser();
10
+ useDocLinkInterceptor();
11
+
8
12
  return (
9
- <div className="h-screen flex bg-[hsl(40,20%,98%)] p-2">
13
+ <div className="h-screen flex bg-white font-sans text-foreground">
10
14
  <Sidebar />
11
- <div className="flex-1 flex flex-col min-w-0 bg-white rounded-[2rem] shadow-sm overflow-hidden border border-[hsl(40,10%,94%)]">
12
- <main className="flex-1 overflow-auto custom-scrollbar p-8">
13
- <div className="max-w-6xl mx-auto animate-fade-in h-full">
14
- {children}
15
- </div>
16
- </main>
15
+ <div className="flex-1 flex min-w-0 overflow-hidden relative">
16
+ <div className="flex-1 flex flex-col min-w-0 bg-gray-50 overflow-hidden">
17
+ <main className="flex-1 overflow-auto custom-scrollbar p-10">
18
+ <div className="max-w-6xl mx-auto animate-fade-in h-full">
19
+ {children}
20
+ </div>
21
+ </main>
22
+ </div>
23
+ {/* Doc Browser: docked mode renders inline, floating mode renders as overlay */}
24
+ {isOpen && mode === 'docked' && <DocBrowser />}
17
25
  </div>
26
+ {isOpen && mode === 'floating' && <DocBrowser />}
18
27
  </div>
19
28
  );
20
29
  }
30
+
31
+ export function AppLayout({ children }: AppLayoutProps) {
32
+ return (
33
+ <DocBrowserProvider>
34
+ <AppLayoutInner>{children}</AppLayoutInner>
35
+ </DocBrowserProvider>
36
+ );
37
+ }
@@ -1,7 +1,8 @@
1
1
  import { cn } from '@/lib/utils';
2
2
  import { t } from '@/lib/i18n';
3
- import { Cpu, GitBranch, History, MessageSquare, Sparkles } from 'lucide-react';
3
+ import { Cpu, GitBranch, History, MessageSquare, Sparkles, BookOpen, Store } from 'lucide-react';
4
4
  import { NavLink } from 'react-router-dom';
5
+ import { useDocBrowser } from '@/components/doc-browser';
5
6
 
6
7
  const navItems = [
7
8
  {
@@ -28,12 +29,19 @@ const navItems = [
28
29
  target: '/sessions',
29
30
  label: t('sessions'),
30
31
  icon: History,
32
+ },
33
+ {
34
+ target: '/marketplace',
35
+ label: 'Marketplace',
36
+ icon: Store,
31
37
  }
32
38
  ];
33
39
 
34
40
  export function Sidebar() {
41
+ const docBrowser = useDocBrowser();
42
+
35
43
  return (
36
- <aside className="w-[240px] bg-transparent flex flex-col h-full py-6 px-4">
44
+ <aside className="w-[240px] bg-white border-r border-gray-200 flex flex-col h-full py-6 px-4">
37
45
  {/* Logo Area */}
38
46
  <div className="px-3 mb-8">
39
47
  <div className="flex items-center gap-2.5 group cursor-pointer">
@@ -55,10 +63,10 @@ export function Sidebar() {
55
63
  <NavLink
56
64
  to={item.target}
57
65
  className={({ isActive }) => cn(
58
- 'group w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-fast',
66
+ 'group w-full flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium transition-all duration-base',
59
67
  isActive
60
- ? 'bg-primary-100 text-primary-700'
61
- : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
68
+ ? 'bg-brand-50 text-brand-700'
69
+ : 'text-gray-600 hover:bg-gray-100/80 hover:text-gray-900'
62
70
  )}
63
71
  >
64
72
  {({ isActive }) => (
@@ -76,6 +84,25 @@ export function Sidebar() {
76
84
  })}
77
85
  </ul>
78
86
  </nav>
87
+
88
+ {/* Help Button */}
89
+ <div className="pt-2 border-t border-gray-100 mt-2">
90
+ <button
91
+ onClick={() => docBrowser.open()}
92
+ className={cn(
93
+ 'w-full flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium transition-all duration-base',
94
+ docBrowser.isOpen
95
+ ? 'bg-brand-50 text-brand-700'
96
+ : 'text-gray-600 hover:bg-gray-100/80 hover:text-gray-900'
97
+ )}
98
+ >
99
+ <BookOpen className={cn(
100
+ 'h-4 w-4',
101
+ docBrowser.isOpen ? 'text-primary' : 'text-gray-500'
102
+ )} />
103
+ <span className="flex-1 text-left">{t('docBrowserHelp')}</span>
104
+ </button>
105
+ </div>
79
106
  </aside>
80
107
  );
81
108
  }