@nextclaw/ui 0.3.16 → 0.4.0
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/CHANGELOG.md +19 -0
- package/dist/assets/index-D16PLMLv.js +337 -0
- package/dist/assets/index-Wn63frSd.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/App.tsx +2 -0
- package/src/api/marketplace.ts +110 -0
- package/src/api/types.ts +85 -0
- package/src/components/config/ChannelsList.tsx +2 -2
- package/src/components/config/ModelConfig.tsx +2 -2
- package/src/components/config/ProvidersList.tsx +3 -3
- package/src/components/config/SessionsConfig.tsx +93 -81
- package/src/components/doc-browser/DocBrowser.tsx +283 -0
- package/src/components/doc-browser/DocBrowserContext.tsx +134 -0
- package/src/components/doc-browser/index.ts +3 -0
- package/src/components/doc-browser/useDocLinkInterceptor.ts +33 -0
- package/src/components/layout/AppLayout.tsx +25 -8
- package/src/components/layout/Sidebar.tsx +32 -5
- package/src/components/marketplace/MarketplacePage.tsx +408 -0
- package/src/hooks/useMarketplace.ts +59 -0
- package/src/index.css +11 -4
- package/src/lib/i18n.ts +10 -1
- package/src/styles/design-system.css +256 -214
- package/dist/assets/index-DuW0OWcM.js +0 -298
- package/dist/assets/index-xwCviEXg.css +0 -1
|
@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input';
|
|
|
6
6
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
7
7
|
import { cn } from '@/lib/utils';
|
|
8
8
|
import { t } from '@/lib/i18n';
|
|
9
|
-
import { RefreshCw, Search, Clock, Inbox, Hash, Bot, User, MessageCircle } from 'lucide-react';
|
|
9
|
+
import { RefreshCw, Search, Clock, Inbox, Hash, Bot, User, MessageCircle, Settings as SettingsIcon } from 'lucide-react';
|
|
10
10
|
|
|
11
11
|
const UNKNOWN_CHANNEL_KEY = '__unknown_channel__';
|
|
12
12
|
|
|
@@ -56,26 +56,28 @@ function SessionListItem({ session, channel, isSelected, onSelect }: SessionList
|
|
|
56
56
|
<button
|
|
57
57
|
onClick={onSelect}
|
|
58
58
|
className={cn(
|
|
59
|
-
"w-full text-left p-3 rounded-xl
|
|
59
|
+
"w-full text-left p-3.5 rounded-xl transition-all duration-200 outline-none focus:outline-none focus:ring-0 group",
|
|
60
60
|
isSelected
|
|
61
|
-
? "bg-
|
|
62
|
-
: "bg-
|
|
61
|
+
? "bg-brand-50 border border-brand-100/50"
|
|
62
|
+
: "bg-transparent border border-transparent hover:bg-gray-50/80"
|
|
63
63
|
)}
|
|
64
64
|
>
|
|
65
65
|
<div className="flex items-start justify-between mb-1.5">
|
|
66
|
-
<div className="font-
|
|
67
|
-
|
|
66
|
+
<div className={cn("font-semibold truncate pr-2 flex-1 text-sm", isSelected ? "text-brand-800" : "text-gray-900")}>
|
|
67
|
+
{displayName}
|
|
68
|
+
</div>
|
|
69
|
+
<div className={cn("text-[10px] font-bold px-2 py-0.5 rounded-full shrink-0 capitalize", isSelected ? "bg-white text-brand-600 shadow-[0_1px_2px_rgba(0,0,0,0.02)]" : "bg-gray-100 text-gray-500")}>
|
|
68
70
|
{channelDisplay}
|
|
69
71
|
</div>
|
|
70
72
|
</div>
|
|
71
73
|
|
|
72
|
-
<div className="flex items-center text-xs text-
|
|
74
|
+
<div className={cn("flex items-center text-xs justify-between mt-2 font-medium", isSelected ? "text-brand-600/80" : "text-gray-400")}>
|
|
73
75
|
<div className="flex items-center gap-1.5">
|
|
74
|
-
<Clock className="w-3.5 h-3.5" />
|
|
76
|
+
<Clock className="w-3.5 h-3.5 opacity-70" />
|
|
75
77
|
<span className="truncate max-w-[100px]">{formatDate(session.updatedAt).split(' ')[0]}</span>
|
|
76
78
|
</div>
|
|
77
79
|
<div className="flex items-center gap-1">
|
|
78
|
-
<MessageCircle className="w-3.5 h-3.5" />
|
|
80
|
+
<MessageCircle className="w-3.5 h-3.5 opacity-70" />
|
|
79
81
|
<span>{session.messageCount}</span>
|
|
80
82
|
</div>
|
|
81
83
|
</div>
|
|
@@ -91,12 +93,12 @@ function SessionMessageBubble({ message }: { message: SessionMessageView }) {
|
|
|
91
93
|
const isUser = message.role.toLowerCase() === 'user';
|
|
92
94
|
|
|
93
95
|
return (
|
|
94
|
-
<div className={cn("flex w-full mb-
|
|
96
|
+
<div className={cn("flex w-full mb-6", isUser ? "justify-end" : "justify-start")}>
|
|
95
97
|
<div className={cn(
|
|
96
|
-
"max-w-[85%] rounded-
|
|
98
|
+
"max-w-[85%] rounded-[1.25rem] p-5 flex gap-3 text-sm",
|
|
97
99
|
isUser
|
|
98
|
-
? "bg-primary text-white rounded-tr-sm
|
|
99
|
-
: "bg-
|
|
100
|
+
? "bg-primary text-white rounded-tr-sm"
|
|
101
|
+
: "bg-gray-50 text-gray-800 rounded-tl-sm border border-gray-100/50"
|
|
100
102
|
)}>
|
|
101
103
|
<div className="shrink-0 pt-0.5">
|
|
102
104
|
{isUser ? <User className="w-4 h-4 text-primary-100" /> : <Bot className="w-4 h-4 text-gray-400" />}
|
|
@@ -110,7 +112,7 @@ function SessionMessageBubble({ message }: { message: SessionMessageView }) {
|
|
|
110
112
|
{formatDate(message.timestamp)}
|
|
111
113
|
</span>
|
|
112
114
|
</div>
|
|
113
|
-
<div className="whitespace-pre-wrap break-words leading-relaxed">
|
|
115
|
+
<div className="whitespace-pre-wrap break-words leading-relaxed text-[15px]">
|
|
114
116
|
{message.content}
|
|
115
117
|
</div>
|
|
116
118
|
</div>
|
|
@@ -133,6 +135,7 @@ export function SessionsConfig() {
|
|
|
133
135
|
// Local state drafts for editing the currently selected session
|
|
134
136
|
const [draftLabel, setDraftLabel] = useState('');
|
|
135
137
|
const [draftModel, setDraftModel] = useState('');
|
|
138
|
+
const [isEditingMeta, setIsEditingMeta] = useState(false);
|
|
136
139
|
|
|
137
140
|
const sessionsParams = useMemo(() => ({ q: query.trim() || undefined, limit, activeMinutes }), [query, limit, activeMinutes]);
|
|
138
141
|
const sessionsQuery = useSessions(sessionsParams);
|
|
@@ -170,6 +173,7 @@ export function SessionsConfig() {
|
|
|
170
173
|
setDraftLabel('');
|
|
171
174
|
setDraftModel('');
|
|
172
175
|
}
|
|
176
|
+
setIsEditingMeta(false); // Reset editing state when switching sessions
|
|
173
177
|
}, [selectedSession]);
|
|
174
178
|
|
|
175
179
|
const handleSaveMeta = () => {
|
|
@@ -181,6 +185,7 @@ export function SessionsConfig() {
|
|
|
181
185
|
preferredModel: draftModel.trim() || null
|
|
182
186
|
}
|
|
183
187
|
});
|
|
188
|
+
setIsEditingMeta(false); // Close editor on save
|
|
184
189
|
};
|
|
185
190
|
|
|
186
191
|
const handleClearHistory = () => {
|
|
@@ -205,53 +210,54 @@ export function SessionsConfig() {
|
|
|
205
210
|
return (
|
|
206
211
|
<div className="h-[calc(100vh-80px)] w-full max-w-[1400px] mx-auto animate-fade-in flex flex-col pt-6 pb-2">
|
|
207
212
|
|
|
208
|
-
{/* Header */}
|
|
209
213
|
<div className="flex items-center justify-between mb-6 shrink-0">
|
|
210
214
|
<div>
|
|
211
215
|
<h2 className="text-2xl font-bold text-gray-900 tracking-tight">{t('sessionsPageTitle')}</h2>
|
|
212
216
|
<p className="text-sm text-gray-500 mt-1">{t('sessionsPageDescription')}</p>
|
|
213
217
|
</div>
|
|
214
|
-
|
|
215
|
-
{/* Global Toolbar */}
|
|
216
|
-
<div className="flex items-center gap-3">
|
|
217
|
-
<Select value={selectedChannel} onValueChange={setSelectedChannel}>
|
|
218
|
-
<SelectTrigger className="w-[180px] h-9 rounded-full bg-gray-50/50 hover:bg-gray-100 border-gray-200 focus:ring-0 shadow-none font-medium text-gray-700">
|
|
219
|
-
<SelectValue placeholder="All Channels" />
|
|
220
|
-
</SelectTrigger>
|
|
221
|
-
<SelectContent className="rounded-xl shadow-lg border-gray-100">
|
|
222
|
-
<SelectItem value="all" className="rounded-lg">All Channels</SelectItem>
|
|
223
|
-
{channels.map(c => (
|
|
224
|
-
<SelectItem key={c} value={c} className="rounded-lg">{displayChannelName(c)}</SelectItem>
|
|
225
|
-
))}
|
|
226
|
-
</SelectContent>
|
|
227
|
-
</Select>
|
|
228
|
-
|
|
229
|
-
<div className="relative w-64">
|
|
230
|
-
<Search className="h-4 w-4 absolute left-3 top-2.5 text-gray-400" />
|
|
231
|
-
<Input
|
|
232
|
-
value={query}
|
|
233
|
-
onChange={(e) => setQuery(e.target.value)}
|
|
234
|
-
placeholder={t('sessionsSearchPlaceholder')}
|
|
235
|
-
className="pl-9 h-9 rounded-full bg-gray-50/50 border-gray-200 focus-visible:bg-white"
|
|
236
|
-
/>
|
|
237
|
-
</div>
|
|
238
|
-
<Button variant="outline" size="icon" className="h-9 w-9 rounded-full text-gray-500" onClick={() => sessionsQuery.refetch()}>
|
|
239
|
-
<RefreshCw className={cn("h-4 w-4", sessionsQuery.isFetching && "animate-spin")} />
|
|
240
|
-
</Button>
|
|
241
|
-
</div>
|
|
242
218
|
</div>
|
|
243
219
|
|
|
244
220
|
{/* Main Mailbox Layout */}
|
|
245
221
|
<div className="flex-1 flex gap-6 min-h-0 relative">
|
|
246
222
|
|
|
247
|
-
{/* LEFT COLUMN: List */}
|
|
248
|
-
<div className="w-[320px] flex flex-col shrink-0">
|
|
249
|
-
|
|
250
|
-
|
|
223
|
+
{/* LEFT COLUMN: List Card */}
|
|
224
|
+
<div className="w-[320px] flex flex-col shrink-0 bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
|
|
225
|
+
|
|
226
|
+
{/* List Card Header & Toolbar */}
|
|
227
|
+
<div className="px-4 py-4 border-b border-gray-100 bg-white z-10 shrink-0 space-y-3">
|
|
228
|
+
<div className="flex items-center justify-between">
|
|
229
|
+
<span className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider">
|
|
230
|
+
{sessions.length} {t('sessionsListTitle')}
|
|
231
|
+
</span>
|
|
232
|
+
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100" onClick={() => sessionsQuery.refetch()}>
|
|
233
|
+
<RefreshCw className={cn("h-3.5 w-3.5", sessionsQuery.isFetching && "animate-spin")} />
|
|
234
|
+
</Button>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
<Select value={selectedChannel} onValueChange={setSelectedChannel}>
|
|
238
|
+
<SelectTrigger className="w-full h-8.5 rounded-lg bg-gray-50/50 hover:bg-gray-100 border-gray-200 focus:ring-0 shadow-none text-xs font-medium text-gray-700">
|
|
239
|
+
<SelectValue placeholder="All Channels" />
|
|
240
|
+
</SelectTrigger>
|
|
241
|
+
<SelectContent className="rounded-xl shadow-lg border-gray-100 max-w-[280px]">
|
|
242
|
+
<SelectItem value="all" className="rounded-lg text-xs">All Channels</SelectItem>
|
|
243
|
+
{channels.map(c => (
|
|
244
|
+
<SelectItem key={c} value={c} className="rounded-lg text-xs truncate pr-6">{displayChannelName(c)}</SelectItem>
|
|
245
|
+
))}
|
|
246
|
+
</SelectContent>
|
|
247
|
+
</Select>
|
|
248
|
+
|
|
249
|
+
<div className="relative w-full">
|
|
250
|
+
<Search className="h-3.5 w-3.5 absolute left-3 top-2.5 text-gray-400" />
|
|
251
|
+
<Input
|
|
252
|
+
value={query}
|
|
253
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
254
|
+
placeholder={t('sessionsSearchPlaceholder')}
|
|
255
|
+
className="pl-8 h-8.5 rounded-lg bg-gray-50/50 border-gray-200 focus-visible:bg-white text-xs"
|
|
256
|
+
/>
|
|
257
|
+
</div>
|
|
251
258
|
</div>
|
|
252
259
|
|
|
253
|
-
<div className="flex-1 overflow-y-auto
|
|
254
|
-
[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:bg-gray-200 [&::-webkit-scrollbar-thumb]:rounded-full hover:[&::-webkit-scrollbar-thumb]:bg-gray-300">
|
|
260
|
+
<div className="flex-1 overflow-y-auto p-3 space-y-1 pb-10 custom-scrollbar relative">
|
|
255
261
|
{sessionsQuery.isLoading ? (
|
|
256
262
|
<div className="text-sm text-gray-400 p-4 text-center">{t('sessionsLoading')}</div>
|
|
257
263
|
) : filteredSessions.length === 0 ? (
|
|
@@ -273,11 +279,11 @@ export function SessionsConfig() {
|
|
|
273
279
|
</div>
|
|
274
280
|
</div>
|
|
275
281
|
|
|
276
|
-
{/* RIGHT COLUMN: Detail View */}
|
|
277
|
-
<div className="flex-1 min-w-0
|
|
282
|
+
{/* RIGHT COLUMN: Detail View Card */}
|
|
283
|
+
<div className="flex-1 min-w-0 flex flex-col overflow-hidden relative bg-white rounded-2xl shadow-sm border border-gray-200">
|
|
278
284
|
|
|
279
285
|
{(updateSession.isPending || deleteSession.isPending) && (
|
|
280
|
-
<div className="absolute top-0 left-0 w-full h-1 bg-primary/20 overflow-hidden z-
|
|
286
|
+
<div className="absolute top-0 left-0 w-full h-1 bg-primary/20 overflow-hidden z-20">
|
|
281
287
|
<div className="h-full bg-primary animate-pulse w-1/3 rounded-r-full" />
|
|
282
288
|
</div>
|
|
283
289
|
)}
|
|
@@ -285,53 +291,59 @@ export function SessionsConfig() {
|
|
|
285
291
|
{selectedKey && selectedSession ? (
|
|
286
292
|
<>
|
|
287
293
|
{/* Detail Header / Metdata Editor */}
|
|
288
|
-
<div className="shrink-0
|
|
289
|
-
<div className="flex items-
|
|
290
|
-
<div className="flex items-center gap-
|
|
291
|
-
<div className="h-
|
|
292
|
-
<Hash className="h-
|
|
294
|
+
<div className="shrink-0 border-b border-gray-100 bg-white px-8 py-5 z-10 space-y-4">
|
|
295
|
+
<div className="flex items-center justify-between">
|
|
296
|
+
<div className="flex items-center gap-4">
|
|
297
|
+
<div className="h-12 w-12 rounded-[14px] bg-gray-50 border border-gray-100 flex items-center justify-center text-gray-400 shrink-0">
|
|
298
|
+
<Hash className="h-6 w-6" />
|
|
293
299
|
</div>
|
|
294
300
|
<div>
|
|
295
|
-
<div className="flex items-center gap-2 mb-1">
|
|
296
|
-
<h3 className="text-
|
|
301
|
+
<div className="flex items-center gap-2.5 mb-1.5">
|
|
302
|
+
<h3 className="text-lg font-bold text-gray-900 tracking-tight">
|
|
297
303
|
{selectedSession.label || selectedSession.key.split(':').pop() || selectedSession.key}
|
|
298
304
|
</h3>
|
|
299
|
-
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-gray-100 text-gray-
|
|
305
|
+
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-gray-100 text-gray-500 uppercase tracking-widest">
|
|
300
306
|
{displayChannelName(resolveChannelFromSessionKey(selectedSession.key))}
|
|
301
307
|
</span>
|
|
302
308
|
</div>
|
|
303
|
-
<div className="text-xs text-gray-500 font-mono break-all line-clamp-1" title={selectedKey}>
|
|
309
|
+
<div className="text-xs text-gray-500 font-mono break-all line-clamp-1 opacity-70" title={selectedKey}>
|
|
304
310
|
{selectedKey}
|
|
305
311
|
</div>
|
|
306
312
|
</div>
|
|
307
313
|
</div>
|
|
308
314
|
<div className="flex items-center gap-2 shrink-0">
|
|
309
|
-
<Button variant="outline" size="sm" onClick={
|
|
315
|
+
<Button variant="outline" size="sm" onClick={() => setIsEditingMeta(!isEditingMeta)} className={cn("h-8.5 rounded-lg shadow-none border-gray-200 transition-all text-xs font-semibold", isEditingMeta ? "bg-gray-100 text-gray-900" : "hover:bg-gray-50 hover:text-gray-900")}>
|
|
316
|
+
<SettingsIcon className="w-3.5 h-3.5 mr-1.5" />
|
|
317
|
+
Metadata
|
|
318
|
+
</Button>
|
|
319
|
+
<Button variant="outline" size="sm" onClick={handleClearHistory} className="h-8.5 rounded-lg shadow-none hover:bg-gray-50 hover:text-gray-900 border-gray-200 text-xs font-semibold text-gray-500">
|
|
310
320
|
{t('sessionsClearHistory')}
|
|
311
321
|
</Button>
|
|
312
|
-
<Button variant="outline" size="sm" onClick={handleDeleteSession} className="h-8 shadow-none hover:bg-red-50 hover:text-red-600 hover:border-red-200 border-gray-200">
|
|
322
|
+
<Button variant="outline" size="sm" onClick={handleDeleteSession} className="h-8.5 rounded-lg shadow-none hover:bg-red-50 hover:text-red-600 hover:border-red-200 border-gray-200 text-xs font-semibold text-red-500">
|
|
313
323
|
{t('delete')}
|
|
314
324
|
</Button>
|
|
315
325
|
</div>
|
|
316
326
|
</div>
|
|
317
327
|
|
|
318
|
-
|
|
319
|
-
<
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
{
|
|
333
|
-
|
|
334
|
-
|
|
328
|
+
{isEditingMeta && (
|
|
329
|
+
<div className="flex items-center gap-3 bg-gray-50/50 p-3 rounded-lg border border-gray-100 animate-slide-in">
|
|
330
|
+
<Input
|
|
331
|
+
placeholder={t('sessionsLabelPlaceholder')}
|
|
332
|
+
value={draftLabel}
|
|
333
|
+
onChange={e => setDraftLabel(e.target.value)}
|
|
334
|
+
className="h-8 text-sm bg-white"
|
|
335
|
+
/>
|
|
336
|
+
<Input
|
|
337
|
+
placeholder={t('sessionsModelPlaceholder')}
|
|
338
|
+
value={draftModel}
|
|
339
|
+
onChange={e => setDraftModel(e.target.value)}
|
|
340
|
+
className="h-8 text-sm bg-white"
|
|
341
|
+
/>
|
|
342
|
+
<Button size="sm" onClick={handleSaveMeta} className="h-8 px-4 shrink-0 shadow-none" disabled={updateSession.isPending}>
|
|
343
|
+
{t('sessionsSaveMeta')}
|
|
344
|
+
</Button>
|
|
345
|
+
</div>
|
|
346
|
+
)}
|
|
335
347
|
</div>
|
|
336
348
|
|
|
337
349
|
{/* Chat History Area */}
|
|
@@ -370,7 +382,7 @@ export function SessionsConfig() {
|
|
|
370
382
|
) : (
|
|
371
383
|
/* Empty State */
|
|
372
384
|
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 p-8 h-full bg-white">
|
|
373
|
-
<div className="w-20 h-20 bg-gray-50 rounded-3xl flex items-center justify-center mb-6 border border-gray-100 shadow-
|
|
385
|
+
<div className="w-20 h-20 bg-gray-50 rounded-3xl flex items-center justify-center mb-6 border border-gray-100 shadow-[0_2px_8px_-2px_rgba(0,0,0,0.02)] rotate-3">
|
|
374
386
|
<Inbox className="h-8 w-8 text-gray-300 -rotate-3" />
|
|
375
387
|
</div>
|
|
376
388
|
<h3 className="text-lg font-bold text-gray-900 mb-2">No Session Selected</h3>
|
|
@@ -0,0 +1,283 @@
|
|
|
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
|
+
// Listen for route changes from the iframe via postMessage
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
const handler = (e: MessageEvent) => {
|
|
54
|
+
if (e.data?.type === 'docs-route-change' && typeof e.data.url === 'string') {
|
|
55
|
+
navigate(e.data.url);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
window.addEventListener('message', handler);
|
|
59
|
+
return () => window.removeEventListener('message', handler);
|
|
60
|
+
}, [navigate]);
|
|
61
|
+
|
|
62
|
+
const handleUrlSubmit = useCallback((e: React.FormEvent) => {
|
|
63
|
+
e.preventDefault();
|
|
64
|
+
const input = urlInput.trim();
|
|
65
|
+
if (!input) return;
|
|
66
|
+
if (input.startsWith('/')) {
|
|
67
|
+
navigate(`${DOCS_DEFAULT_BASE_URL}${input}`);
|
|
68
|
+
} else if (input.startsWith('http')) {
|
|
69
|
+
navigate(input);
|
|
70
|
+
} else {
|
|
71
|
+
navigate(`${DOCS_DEFAULT_BASE_URL}/${input}`);
|
|
72
|
+
}
|
|
73
|
+
}, [urlInput, navigate]);
|
|
74
|
+
|
|
75
|
+
// --- Dragging logic (floating mode) ---
|
|
76
|
+
const onDragStart = useCallback((e: React.MouseEvent) => {
|
|
77
|
+
if (mode !== 'floating') return;
|
|
78
|
+
setIsDragging(true);
|
|
79
|
+
dragRef.current = {
|
|
80
|
+
startX: e.clientX,
|
|
81
|
+
startY: e.clientY,
|
|
82
|
+
startPosX: floatPos.x,
|
|
83
|
+
startPosY: floatPos.y,
|
|
84
|
+
};
|
|
85
|
+
}, [mode, floatPos]);
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (!isDragging) return;
|
|
89
|
+
const onMove = (e: MouseEvent) => {
|
|
90
|
+
if (!dragRef.current) return;
|
|
91
|
+
setFloatPos({
|
|
92
|
+
x: dragRef.current.startPosX + (e.clientX - dragRef.current.startX),
|
|
93
|
+
y: dragRef.current.startPosY + (e.clientY - dragRef.current.startY),
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
const onUp = () => {
|
|
97
|
+
setIsDragging(false);
|
|
98
|
+
dragRef.current = null;
|
|
99
|
+
};
|
|
100
|
+
window.addEventListener('mousemove', onMove);
|
|
101
|
+
window.addEventListener('mouseup', onUp);
|
|
102
|
+
return () => {
|
|
103
|
+
window.removeEventListener('mousemove', onMove);
|
|
104
|
+
window.removeEventListener('mouseup', onUp);
|
|
105
|
+
};
|
|
106
|
+
}, [isDragging]);
|
|
107
|
+
|
|
108
|
+
// --- Resize logic (floating mode — bottom-right corner) ---
|
|
109
|
+
const onResizeStart = useCallback((e: React.MouseEvent) => {
|
|
110
|
+
e.preventDefault();
|
|
111
|
+
e.stopPropagation();
|
|
112
|
+
resizeRef.current = {
|
|
113
|
+
startX: e.clientX,
|
|
114
|
+
startY: e.clientY,
|
|
115
|
+
startW: floatSize.w,
|
|
116
|
+
startH: floatSize.h,
|
|
117
|
+
};
|
|
118
|
+
const onMove = (ev: MouseEvent) => {
|
|
119
|
+
if (!resizeRef.current) return;
|
|
120
|
+
setFloatSize({
|
|
121
|
+
w: Math.max(360, resizeRef.current.startW + (ev.clientX - resizeRef.current.startX)),
|
|
122
|
+
h: Math.max(400, resizeRef.current.startH + (ev.clientY - resizeRef.current.startY)),
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
const onUp = () => {
|
|
126
|
+
resizeRef.current = null;
|
|
127
|
+
window.removeEventListener('mousemove', onMove);
|
|
128
|
+
window.removeEventListener('mouseup', onUp);
|
|
129
|
+
};
|
|
130
|
+
window.addEventListener('mousemove', onMove);
|
|
131
|
+
window.addEventListener('mouseup', onUp);
|
|
132
|
+
}, [floatSize]);
|
|
133
|
+
|
|
134
|
+
// --- Horizontal resize logic (docked mode — left edge) ---
|
|
135
|
+
const onDockResizeStart = useCallback((e: React.MouseEvent) => {
|
|
136
|
+
e.preventDefault();
|
|
137
|
+
e.stopPropagation();
|
|
138
|
+
dockResizeRef.current = { startX: e.clientX, startW: dockedWidth };
|
|
139
|
+
const onMove = (ev: MouseEvent) => {
|
|
140
|
+
if (!dockResizeRef.current) return;
|
|
141
|
+
// Dragging left should increase width (since resize handle is on the left edge)
|
|
142
|
+
const delta = dockResizeRef.current.startX - ev.clientX;
|
|
143
|
+
setDockedWidth(Math.max(320, Math.min(800, dockResizeRef.current.startW + delta)));
|
|
144
|
+
};
|
|
145
|
+
const onUp = () => {
|
|
146
|
+
dockResizeRef.current = null;
|
|
147
|
+
window.removeEventListener('mousemove', onMove);
|
|
148
|
+
window.removeEventListener('mouseup', onUp);
|
|
149
|
+
};
|
|
150
|
+
window.addEventListener('mousemove', onMove);
|
|
151
|
+
window.addEventListener('mouseup', onUp);
|
|
152
|
+
}, [dockedWidth]);
|
|
153
|
+
|
|
154
|
+
if (!isOpen) return null;
|
|
155
|
+
|
|
156
|
+
const isDocked = mode === 'docked';
|
|
157
|
+
|
|
158
|
+
const panel = (
|
|
159
|
+
<div
|
|
160
|
+
className={cn(
|
|
161
|
+
'flex flex-col bg-white overflow-hidden relative',
|
|
162
|
+
isDocked
|
|
163
|
+
? 'h-full border-l border-gray-200 shrink-0'
|
|
164
|
+
: 'rounded-2xl shadow-2xl border border-gray-200',
|
|
165
|
+
)}
|
|
166
|
+
style={
|
|
167
|
+
isDocked
|
|
168
|
+
? { width: dockedWidth }
|
|
169
|
+
: {
|
|
170
|
+
position: 'fixed',
|
|
171
|
+
left: floatPos.x,
|
|
172
|
+
top: floatPos.y,
|
|
173
|
+
width: floatSize.w,
|
|
174
|
+
height: floatSize.h,
|
|
175
|
+
zIndex: 9999,
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
>
|
|
179
|
+
{/* Docked mode: left-edge resize handle */}
|
|
180
|
+
{isDocked && (
|
|
181
|
+
<div
|
|
182
|
+
className="absolute top-0 left-0 w-1.5 h-full cursor-ew-resize z-20 hover:bg-primary/10 transition-colors"
|
|
183
|
+
onMouseDown={onDockResizeStart}
|
|
184
|
+
/>
|
|
185
|
+
)}
|
|
186
|
+
|
|
187
|
+
{/* Title Bar */}
|
|
188
|
+
<div
|
|
189
|
+
className={cn(
|
|
190
|
+
'flex items-center justify-between px-4 py-2.5 bg-gray-50 border-b border-gray-200 shrink-0 select-none',
|
|
191
|
+
!isDocked && 'cursor-grab active:cursor-grabbing',
|
|
192
|
+
)}
|
|
193
|
+
onMouseDown={!isDocked ? onDragStart : undefined}
|
|
194
|
+
>
|
|
195
|
+
<div className="flex items-center gap-2.5">
|
|
196
|
+
<BookOpen className="w-4 h-4 text-primary" />
|
|
197
|
+
<span className="text-sm font-semibold text-gray-900">{t('docBrowserTitle')}</span>
|
|
198
|
+
</div>
|
|
199
|
+
<div className="flex items-center gap-1">
|
|
200
|
+
<button
|
|
201
|
+
onClick={toggleMode}
|
|
202
|
+
className="hover:bg-gray-200 rounded-md p-1.5 text-gray-500 hover:text-gray-700 transition-colors"
|
|
203
|
+
title={isDocked ? t('docBrowserFloatMode') : t('docBrowserDockMode')}
|
|
204
|
+
>
|
|
205
|
+
{isDocked ? <Maximize2 className="w-3.5 h-3.5" /> : <PanelRightOpen className="w-3.5 h-3.5" />}
|
|
206
|
+
</button>
|
|
207
|
+
<button
|
|
208
|
+
onClick={close}
|
|
209
|
+
className="hover:bg-gray-200 rounded-md p-1.5 text-gray-500 hover:text-gray-700 transition-colors"
|
|
210
|
+
title={t('docBrowserClose')}
|
|
211
|
+
>
|
|
212
|
+
<X className="w-3.5 h-3.5" />
|
|
213
|
+
</button>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
{/* Navigation Bar */}
|
|
218
|
+
<div className="flex items-center gap-2 px-3.5 py-2 bg-white border-b border-gray-100 shrink-0">
|
|
219
|
+
<button
|
|
220
|
+
onClick={goBack}
|
|
221
|
+
disabled={!canGoBack}
|
|
222
|
+
className="p-1.5 rounded-md hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed text-gray-600 transition-colors"
|
|
223
|
+
>
|
|
224
|
+
<ArrowLeft className="w-4 h-4" />
|
|
225
|
+
</button>
|
|
226
|
+
<button
|
|
227
|
+
onClick={goForward}
|
|
228
|
+
disabled={!canGoForward}
|
|
229
|
+
className="p-1.5 rounded-md hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed text-gray-600 transition-colors"
|
|
230
|
+
>
|
|
231
|
+
<ArrowRight className="w-4 h-4" />
|
|
232
|
+
</button>
|
|
233
|
+
|
|
234
|
+
<form onSubmit={handleUrlSubmit} className="flex-1 relative">
|
|
235
|
+
<Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
|
236
|
+
<input
|
|
237
|
+
type="text"
|
|
238
|
+
value={urlInput}
|
|
239
|
+
onChange={(e) => setUrlInput(e.target.value)}
|
|
240
|
+
placeholder={t('docBrowserSearchPlaceholder')}
|
|
241
|
+
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"
|
|
242
|
+
/>
|
|
243
|
+
</form>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
{/* Iframe Content */}
|
|
247
|
+
<div className="flex-1 relative overflow-hidden">
|
|
248
|
+
<iframe
|
|
249
|
+
src={currentUrl}
|
|
250
|
+
className="absolute inset-0 w-full h-full border-0"
|
|
251
|
+
title="NextClaw Documentation"
|
|
252
|
+
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
|
|
253
|
+
allow="clipboard-read; clipboard-write"
|
|
254
|
+
/>
|
|
255
|
+
</div>
|
|
256
|
+
|
|
257
|
+
{/* Footer */}
|
|
258
|
+
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-t border-gray-200 shrink-0">
|
|
259
|
+
<a
|
|
260
|
+
href={currentUrl}
|
|
261
|
+
target="_blank"
|
|
262
|
+
rel="noopener noreferrer"
|
|
263
|
+
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary-hover font-medium transition-colors"
|
|
264
|
+
>
|
|
265
|
+
{t('docBrowserOpenExternal')}
|
|
266
|
+
<ExternalLink className="w-3 h-3" />
|
|
267
|
+
</a>
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
{/* Resize Handle (floating only — bottom-right corner) */}
|
|
271
|
+
{!isDocked && (
|
|
272
|
+
<div
|
|
273
|
+
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"
|
|
274
|
+
onMouseDown={onResizeStart}
|
|
275
|
+
>
|
|
276
|
+
<GripVertical className="w-3 h-3 rotate-[-45deg]" />
|
|
277
|
+
</div>
|
|
278
|
+
)}
|
|
279
|
+
</div>
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
return panel;
|
|
283
|
+
}
|