@nextclaw/ui 0.5.0 → 0.5.2
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 +15 -0
- package/dist/assets/index-CKTsCtI-.css +1 -0
- package/dist/assets/index-D8W5lAHk.js +337 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/api/marketplace.ts +10 -0
- package/src/api/types.ts +25 -8
- package/src/components/config/ChannelForm.tsx +14 -10
- package/src/components/config/ProviderForm.tsx +17 -16
- package/src/components/config/RuntimeConfig.tsx +33 -27
- package/src/components/doc-browser/DocBrowser.tsx +94 -19
- package/src/components/doc-browser/DocBrowserContext.tsx +33 -10
- package/src/components/marketplace/MarketplacePage.tsx +422 -65
- package/src/hooks/useMarketplace.ts +18 -1
- package/dist/assets/index-1h_LfFkZ.js +0 -337
- package/dist/assets/index-Wn63frSd.css +0 -1
package/dist/index.html
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
|
7
7
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
8
8
|
<title>NextClaw - 系统配置</title>
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-D8W5lAHk.js"></script>
|
|
10
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CKTsCtI-.css">
|
|
11
11
|
</head>
|
|
12
12
|
|
|
13
13
|
<body>
|
package/package.json
CHANGED
package/src/api/marketplace.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { api } from './client';
|
|
|
2
2
|
import type {
|
|
3
3
|
MarketplaceInstallRequest,
|
|
4
4
|
MarketplaceInstallResult,
|
|
5
|
+
MarketplaceManageRequest,
|
|
6
|
+
MarketplaceManageResult,
|
|
5
7
|
MarketplaceInstalledView,
|
|
6
8
|
MarketplaceItemType,
|
|
7
9
|
MarketplaceItemView,
|
|
@@ -108,3 +110,11 @@ export async function fetchMarketplaceInstalled(): Promise<MarketplaceInstalledV
|
|
|
108
110
|
}
|
|
109
111
|
return response.data;
|
|
110
112
|
}
|
|
113
|
+
|
|
114
|
+
export async function manageMarketplaceItem(request: MarketplaceManageRequest): Promise<MarketplaceManageResult> {
|
|
115
|
+
const response = await api.post<MarketplaceManageResult>('/api/marketplace/manage', request);
|
|
116
|
+
if (!response.ok) {
|
|
117
|
+
throw new Error(response.error.message);
|
|
118
|
+
}
|
|
119
|
+
return response.data;
|
|
120
|
+
}
|
package/src/api/types.ts
CHANGED
|
@@ -244,9 +244,9 @@ export type WsEvent =
|
|
|
244
244
|
|
|
245
245
|
export type MarketplaceItemType = 'plugin' | 'skill';
|
|
246
246
|
|
|
247
|
-
export type MarketplaceSort = 'relevance' | 'updated'
|
|
247
|
+
export type MarketplaceSort = 'relevance' | 'updated';
|
|
248
248
|
|
|
249
|
-
export type MarketplaceInstallKind = 'npm' | 'clawhub' | 'git';
|
|
249
|
+
export type MarketplaceInstallKind = 'npm' | 'clawhub' | 'git' | 'builtin';
|
|
250
250
|
|
|
251
251
|
export type MarketplaceInstallSpec = {
|
|
252
252
|
kind: MarketplaceInstallKind;
|
|
@@ -254,11 +254,6 @@ export type MarketplaceInstallSpec = {
|
|
|
254
254
|
command: string;
|
|
255
255
|
};
|
|
256
256
|
|
|
257
|
-
export type MarketplaceItemMetrics = {
|
|
258
|
-
downloads30d?: number;
|
|
259
|
-
stars?: number;
|
|
260
|
-
};
|
|
261
|
-
|
|
262
257
|
export type MarketplaceItemSummary = {
|
|
263
258
|
id: string;
|
|
264
259
|
slug: string;
|
|
@@ -268,7 +263,6 @@ export type MarketplaceItemSummary = {
|
|
|
268
263
|
tags: string[];
|
|
269
264
|
author: string;
|
|
270
265
|
install: MarketplaceInstallSpec;
|
|
271
|
-
metrics?: MarketplaceItemMetrics;
|
|
272
266
|
updatedAt: string;
|
|
273
267
|
};
|
|
274
268
|
|
|
@@ -299,10 +293,15 @@ export type MarketplaceRecommendationView = {
|
|
|
299
293
|
|
|
300
294
|
export type MarketplaceInstalledRecord = {
|
|
301
295
|
type: MarketplaceItemType;
|
|
296
|
+
id?: string;
|
|
302
297
|
spec: string;
|
|
303
298
|
label?: string;
|
|
304
299
|
source?: string;
|
|
305
300
|
installedAt?: string;
|
|
301
|
+
enabled?: boolean;
|
|
302
|
+
runtimeStatus?: string;
|
|
303
|
+
origin?: string;
|
|
304
|
+
installPath?: string;
|
|
306
305
|
};
|
|
307
306
|
|
|
308
307
|
export type MarketplaceInstalledView = {
|
|
@@ -315,6 +314,7 @@ export type MarketplaceInstalledView = {
|
|
|
315
314
|
export type MarketplaceInstallRequest = {
|
|
316
315
|
type: MarketplaceItemType;
|
|
317
316
|
spec: string;
|
|
317
|
+
kind?: MarketplaceInstallKind;
|
|
318
318
|
version?: string;
|
|
319
319
|
registry?: string;
|
|
320
320
|
force?: boolean;
|
|
@@ -326,3 +326,20 @@ export type MarketplaceInstallResult = {
|
|
|
326
326
|
message: string;
|
|
327
327
|
output?: string;
|
|
328
328
|
};
|
|
329
|
+
|
|
330
|
+
export type MarketplaceManageAction = 'enable' | 'disable' | 'uninstall';
|
|
331
|
+
|
|
332
|
+
export type MarketplaceManageRequest = {
|
|
333
|
+
type: MarketplaceItemType;
|
|
334
|
+
action: MarketplaceManageAction;
|
|
335
|
+
id?: string;
|
|
336
|
+
spec?: string;
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
export type MarketplaceManageResult = {
|
|
340
|
+
type: MarketplaceItemType;
|
|
341
|
+
action: MarketplaceManageAction;
|
|
342
|
+
id: string;
|
|
343
|
+
message: string;
|
|
344
|
+
output?: string;
|
|
345
|
+
};
|
|
@@ -13,6 +13,7 @@ import { Button } from '@/components/ui/button';
|
|
|
13
13
|
import { Input } from '@/components/ui/input';
|
|
14
14
|
import { Label } from '@/components/ui/label';
|
|
15
15
|
import { Switch } from '@/components/ui/switch';
|
|
16
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
16
17
|
import { TagInput } from '@/components/common/TagInput';
|
|
17
18
|
import { t } from '@/lib/i18n';
|
|
18
19
|
import { hintForPath } from '@/lib/config-hints';
|
|
@@ -410,18 +411,21 @@ export function ChannelForm() {
|
|
|
410
411
|
)}
|
|
411
412
|
|
|
412
413
|
{field.type === 'select' && (
|
|
413
|
-
<
|
|
414
|
-
id={field.name}
|
|
414
|
+
<Select
|
|
415
415
|
value={(formData[field.name] as string) || ''}
|
|
416
|
-
|
|
417
|
-
className="flex h-10 w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm"
|
|
416
|
+
onValueChange={(v) => updateField(field.name, v)}
|
|
418
417
|
>
|
|
419
|
-
|
|
420
|
-
<
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
418
|
+
<SelectTrigger className="rounded-xl">
|
|
419
|
+
<SelectValue />
|
|
420
|
+
</SelectTrigger>
|
|
421
|
+
<SelectContent>
|
|
422
|
+
{(field.options ?? []).map((option) => (
|
|
423
|
+
<SelectItem key={option.value} value={option.value}>
|
|
424
|
+
{option.label}
|
|
425
|
+
</SelectItem>
|
|
426
|
+
))}
|
|
427
|
+
</SelectContent>
|
|
428
|
+
</Select>
|
|
425
429
|
)}
|
|
426
430
|
|
|
427
431
|
{field.type === 'json' && (
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import { Button } from '@/components/ui/button';
|
|
13
13
|
import { Input } from '@/components/ui/input';
|
|
14
14
|
import { Label } from '@/components/ui/label';
|
|
15
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
15
16
|
import { MaskedInput } from '@/components/common/MaskedInput';
|
|
16
17
|
import { KeyValueEditor } from '@/components/common/KeyValueEditor';
|
|
17
18
|
import { t } from '@/lib/i18n';
|
|
@@ -149,22 +150,22 @@ export function ProviderForm() {
|
|
|
149
150
|
<Hash className="h-3.5 w-3.5 text-gray-500" />
|
|
150
151
|
{wireApiHint?.label ?? t('wireApi')}
|
|
151
152
|
</Label>
|
|
152
|
-
<
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
</
|
|
153
|
+
<Select value={wireApi} onValueChange={(v) => setWireApi(v as 'auto' | 'chat' | 'responses')}>
|
|
154
|
+
<SelectTrigger className="rounded-xl">
|
|
155
|
+
<SelectValue />
|
|
156
|
+
</SelectTrigger>
|
|
157
|
+
<SelectContent>
|
|
158
|
+
{(providerSpec.wireApiOptions || ['auto', 'chat', 'responses']).map((option) => (
|
|
159
|
+
<SelectItem key={option} value={option}>
|
|
160
|
+
{option === 'chat'
|
|
161
|
+
? t('wireApiChat')
|
|
162
|
+
: option === 'responses'
|
|
163
|
+
? t('wireApiResponses')
|
|
164
|
+
: t('wireApiAuto')}
|
|
165
|
+
</SelectItem>
|
|
166
|
+
))}
|
|
167
|
+
</SelectContent>
|
|
168
|
+
</Select>
|
|
168
169
|
</div>
|
|
169
170
|
)}
|
|
170
171
|
|
|
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
|
|
5
5
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
6
6
|
import { Input } from '@/components/ui/input';
|
|
7
7
|
import { Switch } from '@/components/ui/switch';
|
|
8
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
8
9
|
import { hintForPath } from '@/lib/config-hints';
|
|
9
10
|
import { Plus, Save, Trash2 } from 'lucide-react';
|
|
10
11
|
import { toast } from 'sonner';
|
|
@@ -84,9 +85,9 @@ export function RuntimeConfig() {
|
|
|
84
85
|
accountId: binding.match?.accountId ?? '',
|
|
85
86
|
peer: binding.match?.peer
|
|
86
87
|
? {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
kind: binding.match.peer.kind,
|
|
89
|
+
id: binding.match.peer.id
|
|
90
|
+
}
|
|
90
91
|
: undefined
|
|
91
92
|
}
|
|
92
93
|
}))
|
|
@@ -260,17 +261,18 @@ export function RuntimeConfig() {
|
|
|
260
261
|
</div>
|
|
261
262
|
<div className="space-y-2">
|
|
262
263
|
<label className="text-sm font-medium text-gray-800">{dmScopeHint?.label ?? 'DM Scope'}</label>
|
|
263
|
-
<
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
264
|
+
<Select value={dmScope} onValueChange={(v) => setDmScope(v as DmScope)}>
|
|
265
|
+
<SelectTrigger>
|
|
266
|
+
<SelectValue />
|
|
267
|
+
</SelectTrigger>
|
|
268
|
+
<SelectContent>
|
|
269
|
+
{DM_SCOPE_OPTIONS.map((option) => (
|
|
270
|
+
<SelectItem key={option.value} value={option.value}>
|
|
271
|
+
{option.label}
|
|
272
|
+
</SelectItem>
|
|
273
|
+
))}
|
|
274
|
+
</SelectContent>
|
|
275
|
+
</Select>
|
|
274
276
|
</div>
|
|
275
277
|
<div className="space-y-2">
|
|
276
278
|
<label className="text-sm font-medium text-gray-800">
|
|
@@ -429,10 +431,10 @@ export function RuntimeConfig() {
|
|
|
429
431
|
}
|
|
430
432
|
placeholder="Account ID (optional)"
|
|
431
433
|
/>
|
|
432
|
-
<
|
|
433
|
-
value={peerKind}
|
|
434
|
-
|
|
435
|
-
const nextKind =
|
|
434
|
+
<Select
|
|
435
|
+
value={peerKind || '__none__'}
|
|
436
|
+
onValueChange={(v) => {
|
|
437
|
+
const nextKind = v === '__none__' ? '' : v as PeerKind;
|
|
436
438
|
if (!nextKind) {
|
|
437
439
|
updateBinding(index, {
|
|
438
440
|
...binding,
|
|
@@ -454,13 +456,17 @@ export function RuntimeConfig() {
|
|
|
454
456
|
}
|
|
455
457
|
});
|
|
456
458
|
}}
|
|
457
|
-
className="flex h-10 w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm"
|
|
458
459
|
>
|
|
459
|
-
<
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
<
|
|
463
|
-
|
|
460
|
+
<SelectTrigger>
|
|
461
|
+
<SelectValue />
|
|
462
|
+
</SelectTrigger>
|
|
463
|
+
<SelectContent>
|
|
464
|
+
<SelectItem value="__none__">Peer kind (optional)</SelectItem>
|
|
465
|
+
<SelectItem value="direct">direct</SelectItem>
|
|
466
|
+
<SelectItem value="group">group</SelectItem>
|
|
467
|
+
<SelectItem value="channel">channel</SelectItem>
|
|
468
|
+
</SelectContent>
|
|
469
|
+
</Select>
|
|
464
470
|
<Input
|
|
465
471
|
value={binding.match.peer?.id ?? ''}
|
|
466
472
|
onChange={(event) =>
|
|
@@ -470,9 +476,9 @@ export function RuntimeConfig() {
|
|
|
470
476
|
...binding.match,
|
|
471
477
|
peer: peerKind
|
|
472
478
|
? {
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
479
|
+
kind: peerKind,
|
|
480
|
+
id: event.target.value
|
|
481
|
+
}
|
|
476
482
|
: undefined
|
|
477
483
|
}
|
|
478
484
|
})
|
|
@@ -23,20 +23,26 @@ import {
|
|
|
23
23
|
*/
|
|
24
24
|
export function DocBrowser() {
|
|
25
25
|
const {
|
|
26
|
-
isOpen, mode, currentUrl,
|
|
26
|
+
isOpen, mode, currentUrl, navVersion,
|
|
27
27
|
close, toggleMode,
|
|
28
28
|
goBack, goForward, canGoBack, canGoForward,
|
|
29
|
-
navigate,
|
|
29
|
+
navigate, syncUrl,
|
|
30
30
|
} = useDocBrowser();
|
|
31
31
|
|
|
32
32
|
const [urlInput, setUrlInput] = useState('');
|
|
33
33
|
const [isDragging, setIsDragging] = useState(false);
|
|
34
|
-
const [
|
|
34
|
+
const [isResizing, setIsResizing] = useState(false);
|
|
35
|
+
const [floatPos, setFloatPos] = useState(() => ({
|
|
36
|
+
x: Math.max(40, window.innerWidth - 520),
|
|
37
|
+
y: 80,
|
|
38
|
+
}));
|
|
35
39
|
const [floatSize, setFloatSize] = useState({ w: 480, h: 600 });
|
|
36
40
|
const [dockedWidth, setDockedWidth] = useState(420);
|
|
37
41
|
const dragRef = useRef<{ startX: number; startY: number; startPosX: number; startPosY: number } | null>(null);
|
|
38
42
|
const resizeRef = useRef<{ startX: number; startY: number; startW: number; startH: number } | null>(null);
|
|
39
43
|
const dockResizeRef = useRef<{ startX: number; startW: number } | null>(null);
|
|
44
|
+
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
45
|
+
const prevNavVersionRef = useRef(navVersion);
|
|
40
46
|
|
|
41
47
|
// Sync URL input with current URL
|
|
42
48
|
useEffect(() => {
|
|
@@ -48,16 +54,43 @@ export function DocBrowser() {
|
|
|
48
54
|
}
|
|
49
55
|
}, [currentUrl]);
|
|
50
56
|
|
|
57
|
+
// When currentUrl changes without navVersion bump (goBack/goForward),
|
|
58
|
+
// use postMessage to SPA-navigate inside the iframe instead of remounting
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (navVersion !== prevNavVersionRef.current) {
|
|
61
|
+
// navVersion changed — iframe will remount via key, no need to postMessage
|
|
62
|
+
prevNavVersionRef.current = navVersion;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// Same navVersion — means goBack/goForward triggered
|
|
66
|
+
if (iframeRef.current?.contentWindow) {
|
|
67
|
+
try {
|
|
68
|
+
const path = new URL(currentUrl).pathname;
|
|
69
|
+
iframeRef.current.contentWindow.postMessage({ type: 'docs-navigate', path }, '*');
|
|
70
|
+
} catch { /* ignore */ }
|
|
71
|
+
}
|
|
72
|
+
}, [currentUrl, navVersion]);
|
|
73
|
+
|
|
74
|
+
// Reposition floating window near right edge when switching from docked
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (mode === 'floating') {
|
|
77
|
+
setFloatPos(prev => ({
|
|
78
|
+
x: Math.max(40, window.innerWidth - floatSize.w - 40),
|
|
79
|
+
y: prev.y,
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
}, [mode, floatSize.w]);
|
|
83
|
+
|
|
51
84
|
// Listen for route changes from the iframe via postMessage
|
|
52
85
|
useEffect(() => {
|
|
53
86
|
const handler = (e: MessageEvent) => {
|
|
54
87
|
if (e.data?.type === 'docs-route-change' && typeof e.data.url === 'string') {
|
|
55
|
-
|
|
88
|
+
syncUrl(e.data.url);
|
|
56
89
|
}
|
|
57
90
|
};
|
|
58
91
|
window.addEventListener('message', handler);
|
|
59
92
|
return () => window.removeEventListener('message', handler);
|
|
60
|
-
}, [
|
|
93
|
+
}, [syncUrl]);
|
|
61
94
|
|
|
62
95
|
const handleUrlSubmit = useCallback((e: React.FormEvent) => {
|
|
63
96
|
e.preventDefault();
|
|
@@ -105,10 +138,12 @@ export function DocBrowser() {
|
|
|
105
138
|
};
|
|
106
139
|
}, [isDragging]);
|
|
107
140
|
|
|
108
|
-
// --- Resize logic (floating mode
|
|
141
|
+
// --- Resize logic (floating mode) ---
|
|
109
142
|
const onResizeStart = useCallback((e: React.MouseEvent) => {
|
|
110
143
|
e.preventDefault();
|
|
111
144
|
e.stopPropagation();
|
|
145
|
+
setIsResizing(true);
|
|
146
|
+
const axis = (e.currentTarget as HTMLElement).dataset.axis; // 'x', 'y', or undefined (both)
|
|
112
147
|
resizeRef.current = {
|
|
113
148
|
startX: e.clientX,
|
|
114
149
|
startY: e.clientY,
|
|
@@ -117,12 +152,13 @@ export function DocBrowser() {
|
|
|
117
152
|
};
|
|
118
153
|
const onMove = (ev: MouseEvent) => {
|
|
119
154
|
if (!resizeRef.current) return;
|
|
120
|
-
setFloatSize({
|
|
121
|
-
w: Math.max(360, resizeRef.current
|
|
122
|
-
h: Math.max(400, resizeRef.current
|
|
123
|
-
});
|
|
155
|
+
setFloatSize(prev => ({
|
|
156
|
+
w: axis === 'y' ? prev.w : Math.max(360, resizeRef.current!.startW + (ev.clientX - resizeRef.current!.startX)),
|
|
157
|
+
h: axis === 'x' ? prev.h : Math.max(400, resizeRef.current!.startH + (ev.clientY - resizeRef.current!.startY)),
|
|
158
|
+
}));
|
|
124
159
|
};
|
|
125
160
|
const onUp = () => {
|
|
161
|
+
setIsResizing(false);
|
|
126
162
|
resizeRef.current = null;
|
|
127
163
|
window.removeEventListener('mousemove', onMove);
|
|
128
164
|
window.removeEventListener('mouseup', onUp);
|
|
@@ -135,14 +171,15 @@ export function DocBrowser() {
|
|
|
135
171
|
const onDockResizeStart = useCallback((e: React.MouseEvent) => {
|
|
136
172
|
e.preventDefault();
|
|
137
173
|
e.stopPropagation();
|
|
174
|
+
setIsResizing(true);
|
|
138
175
|
dockResizeRef.current = { startX: e.clientX, startW: dockedWidth };
|
|
139
176
|
const onMove = (ev: MouseEvent) => {
|
|
140
177
|
if (!dockResizeRef.current) return;
|
|
141
|
-
// Dragging left should increase width (since resize handle is on the left edge)
|
|
142
178
|
const delta = dockResizeRef.current.startX - ev.clientX;
|
|
143
179
|
setDockedWidth(Math.max(320, Math.min(800, dockResizeRef.current.startW + delta)));
|
|
144
180
|
};
|
|
145
181
|
const onUp = () => {
|
|
182
|
+
setIsResizing(false);
|
|
146
183
|
dockResizeRef.current = null;
|
|
147
184
|
window.removeEventListener('mousemove', onMove);
|
|
148
185
|
window.removeEventListener('mouseup', onUp);
|
|
@@ -151,6 +188,29 @@ export function DocBrowser() {
|
|
|
151
188
|
window.addEventListener('mouseup', onUp);
|
|
152
189
|
}, [dockedWidth]);
|
|
153
190
|
|
|
191
|
+
// --- Left-edge resize logic (floating mode — adjusts both position and width) ---
|
|
192
|
+
const onLeftResizeStart = useCallback((e: React.MouseEvent) => {
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
e.stopPropagation();
|
|
195
|
+
setIsResizing(true);
|
|
196
|
+
const startX = e.clientX;
|
|
197
|
+
const startW = floatSize.w;
|
|
198
|
+
const startPosX = floatPos.x;
|
|
199
|
+
const onMove = (ev: MouseEvent) => {
|
|
200
|
+
const delta = startX - ev.clientX;
|
|
201
|
+
const newW = Math.max(360, startW + delta);
|
|
202
|
+
setFloatSize(prev => ({ ...prev, w: newW }));
|
|
203
|
+
setFloatPos(prev => ({ ...prev, x: startPosX - (newW - startW) }));
|
|
204
|
+
};
|
|
205
|
+
const onUp = () => {
|
|
206
|
+
setIsResizing(false);
|
|
207
|
+
window.removeEventListener('mousemove', onMove);
|
|
208
|
+
window.removeEventListener('mouseup', onUp);
|
|
209
|
+
};
|
|
210
|
+
window.addEventListener('mousemove', onMove);
|
|
211
|
+
window.addEventListener('mouseup', onUp);
|
|
212
|
+
}, [floatSize.w, floatPos.x]);
|
|
213
|
+
|
|
154
214
|
if (!isOpen) return null;
|
|
155
215
|
|
|
156
216
|
const isDocked = mode === 'docked';
|
|
@@ -246,13 +306,18 @@ export function DocBrowser() {
|
|
|
246
306
|
{/* Iframe Content */}
|
|
247
307
|
<div className="flex-1 relative overflow-hidden">
|
|
248
308
|
<iframe
|
|
249
|
-
|
|
309
|
+
ref={iframeRef}
|
|
310
|
+
key={navVersion}
|
|
250
311
|
src={currentUrl}
|
|
251
312
|
className="absolute inset-0 w-full h-full border-0"
|
|
252
313
|
title="NextClaw Documentation"
|
|
253
314
|
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
|
|
254
315
|
allow="clipboard-read; clipboard-write"
|
|
255
316
|
/>
|
|
317
|
+
{/* Transparent overlay during resize to prevent iframe from stealing mouse events */}
|
|
318
|
+
{(isResizing || isDragging) && (
|
|
319
|
+
<div className="absolute inset-0 z-10" />
|
|
320
|
+
)}
|
|
256
321
|
</div>
|
|
257
322
|
|
|
258
323
|
{/* Footer */}
|
|
@@ -261,6 +326,7 @@ export function DocBrowser() {
|
|
|
261
326
|
href={currentUrl}
|
|
262
327
|
target="_blank"
|
|
263
328
|
rel="noopener noreferrer"
|
|
329
|
+
data-doc-external
|
|
264
330
|
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary-hover font-medium transition-colors"
|
|
265
331
|
>
|
|
266
332
|
{t('docBrowserOpenExternal')}
|
|
@@ -268,14 +334,23 @@ export function DocBrowser() {
|
|
|
268
334
|
</a>
|
|
269
335
|
</div>
|
|
270
336
|
|
|
271
|
-
{/* Resize
|
|
337
|
+
{/* Resize Handles (floating only) */}
|
|
272
338
|
{!isDocked && (
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
onMouseDown={
|
|
276
|
-
|
|
277
|
-
<
|
|
278
|
-
|
|
339
|
+
<>
|
|
340
|
+
{/* Left edge */}
|
|
341
|
+
<div className="absolute top-0 left-0 w-1.5 h-full cursor-ew-resize z-20 hover:bg-primary/10 transition-colors" onMouseDown={onLeftResizeStart} />
|
|
342
|
+
{/* Right edge */}
|
|
343
|
+
<div className="absolute top-0 right-0 w-1.5 h-full cursor-ew-resize z-20 hover:bg-primary/10 transition-colors" onMouseDown={onResizeStart} data-axis="x" />
|
|
344
|
+
{/* Bottom edge */}
|
|
345
|
+
<div className="absolute bottom-0 left-0 h-1.5 w-full cursor-ns-resize z-20 hover:bg-primary/10 transition-colors" onMouseDown={onResizeStart} data-axis="y" />
|
|
346
|
+
{/* Bottom-right corner */}
|
|
347
|
+
<div
|
|
348
|
+
className="absolute bottom-0 right-0 w-4 h-4 cursor-se-resize z-30 flex items-center justify-center text-gray-300 hover:text-gray-500 transition-colors"
|
|
349
|
+
onMouseDown={onResizeStart}
|
|
350
|
+
>
|
|
351
|
+
<GripVertical className="w-3 h-3 rotate-[-45deg]" />
|
|
352
|
+
</div>
|
|
353
|
+
</>
|
|
279
354
|
)}
|
|
280
355
|
</div>
|
|
281
356
|
);
|
|
@@ -1,24 +1,31 @@
|
|
|
1
1
|
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react';
|
|
2
2
|
|
|
3
3
|
const DOCS_PRIMARY_DOMAIN = 'docs.nextclaw.io';
|
|
4
|
-
const
|
|
4
|
+
const DOCS_PAGES_DEV = 'nextclaw-docs.pages.dev';
|
|
5
5
|
const DOCS_HOSTS = new Set([
|
|
6
6
|
DOCS_PRIMARY_DOMAIN,
|
|
7
7
|
`www.${DOCS_PRIMARY_DOMAIN}`,
|
|
8
|
-
|
|
9
|
-
`www.${
|
|
8
|
+
DOCS_PAGES_DEV,
|
|
9
|
+
`www.${DOCS_PAGES_DEV}`,
|
|
10
10
|
]);
|
|
11
11
|
|
|
12
|
-
export const DOCS_DEFAULT_BASE_URL = `https://${
|
|
12
|
+
export const DOCS_DEFAULT_BASE_URL = `https://${DOCS_PRIMARY_DOMAIN}`;
|
|
13
13
|
|
|
14
14
|
export type DocBrowserMode = 'floating' | 'docked';
|
|
15
15
|
|
|
16
|
+
/** Normalize URL for comparison: strip .html and trailing slash */
|
|
17
|
+
function normalizeDocUrl(u: string): string {
|
|
18
|
+
try { return new URL(u).pathname.replace(/\.html$/, '').replace(/\/$/, ''); } catch { return u; }
|
|
19
|
+
}
|
|
20
|
+
|
|
16
21
|
interface DocBrowserState {
|
|
17
22
|
isOpen: boolean;
|
|
18
23
|
mode: DocBrowserMode;
|
|
19
24
|
currentUrl: string;
|
|
20
25
|
history: string[];
|
|
21
26
|
historyIndex: number;
|
|
27
|
+
/** Increments on parent-initiated navigation (navigate/goBack/goForward) */
|
|
28
|
+
navVersion: number;
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
interface DocBrowserActions {
|
|
@@ -26,7 +33,10 @@ interface DocBrowserActions {
|
|
|
26
33
|
close: () => void;
|
|
27
34
|
toggleMode: () => void;
|
|
28
35
|
setMode: (mode: DocBrowserMode) => void;
|
|
36
|
+
/** Parent-initiated navigation — will cause iframe to reload to this URL */
|
|
29
37
|
navigate: (url: string) => void;
|
|
38
|
+
/** Iframe-initiated sync — records URL to history without reloading iframe */
|
|
39
|
+
syncUrl: (url: string) => void;
|
|
30
40
|
goBack: () => void;
|
|
31
41
|
goForward: () => void;
|
|
32
42
|
canGoBack: boolean;
|
|
@@ -60,6 +70,7 @@ export function DocBrowserProvider({ children }: { children: ReactNode }) {
|
|
|
60
70
|
currentUrl: `${DOCS_DEFAULT_BASE_URL}/guide/getting-started`,
|
|
61
71
|
history: [`${DOCS_DEFAULT_BASE_URL}/guide/getting-started`],
|
|
62
72
|
historyIndex: 0,
|
|
73
|
+
navVersion: 0,
|
|
63
74
|
});
|
|
64
75
|
|
|
65
76
|
const open = useCallback((url?: string) => {
|
|
@@ -70,6 +81,7 @@ export function DocBrowserProvider({ children }: { children: ReactNode }) {
|
|
|
70
81
|
currentUrl: targetUrl,
|
|
71
82
|
history: [...prev.history.slice(0, prev.historyIndex + 1), targetUrl],
|
|
72
83
|
historyIndex: prev.historyIndex + 1,
|
|
84
|
+
navVersion: prev.navVersion + 1,
|
|
73
85
|
}));
|
|
74
86
|
}, [state.currentUrl]);
|
|
75
87
|
|
|
@@ -85,14 +97,24 @@ export function DocBrowserProvider({ children }: { children: ReactNode }) {
|
|
|
85
97
|
setState(prev => ({ ...prev, mode }));
|
|
86
98
|
}, []);
|
|
87
99
|
|
|
100
|
+
/** Parent-initiated: push to history AND bump navVersion so iframe reloads */
|
|
88
101
|
const navigate = useCallback((url: string) => {
|
|
89
102
|
setState(prev => {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
103
|
+
if (normalizeDocUrl(url) === normalizeDocUrl(prev.currentUrl)) return prev;
|
|
104
|
+
return {
|
|
105
|
+
...prev,
|
|
106
|
+
currentUrl: url,
|
|
107
|
+
history: [...prev.history.slice(0, prev.historyIndex + 1), url],
|
|
108
|
+
historyIndex: prev.historyIndex + 1,
|
|
109
|
+
navVersion: prev.navVersion + 1,
|
|
93
110
|
};
|
|
94
|
-
|
|
95
|
-
|
|
111
|
+
});
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
/** Iframe-initiated: push to history but do NOT bump navVersion (no iframe reload) */
|
|
115
|
+
const syncUrl = useCallback((url: string) => {
|
|
116
|
+
setState(prev => {
|
|
117
|
+
if (normalizeDocUrl(url) === normalizeDocUrl(prev.currentUrl)) return prev;
|
|
96
118
|
return {
|
|
97
119
|
...prev,
|
|
98
120
|
currentUrl: url,
|
|
@@ -128,11 +150,12 @@ export function DocBrowserProvider({ children }: { children: ReactNode }) {
|
|
|
128
150
|
toggleMode,
|
|
129
151
|
setMode,
|
|
130
152
|
navigate,
|
|
153
|
+
syncUrl,
|
|
131
154
|
goBack,
|
|
132
155
|
goForward,
|
|
133
156
|
canGoBack,
|
|
134
157
|
canGoForward,
|
|
135
|
-
}), [state, open, close, toggleMode, setMode, navigate, goBack, goForward, canGoBack, canGoForward]);
|
|
158
|
+
}), [state, open, close, toggleMode, setMode, navigate, syncUrl, goBack, goForward, canGoBack, canGoForward]);
|
|
136
159
|
|
|
137
160
|
return (
|
|
138
161
|
<DocBrowserContext.Provider value={value}>
|