@nextclaw/ui 0.5.1 → 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 +6 -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 +51 -5
- 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-B6sMhZ9q.css +0 -1
- package/dist/assets/index-B_-o3-kG.js +0 -337
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,10 +23,10 @@ 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('');
|
|
@@ -41,6 +41,8 @@ export function DocBrowser() {
|
|
|
41
41
|
const dragRef = useRef<{ startX: number; startY: number; startPosX: number; startPosY: number } | null>(null);
|
|
42
42
|
const resizeRef = useRef<{ startX: number; startY: number; startW: number; startH: number } | null>(null);
|
|
43
43
|
const dockResizeRef = useRef<{ startX: number; startW: number } | null>(null);
|
|
44
|
+
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
45
|
+
const prevNavVersionRef = useRef(navVersion);
|
|
44
46
|
|
|
45
47
|
// Sync URL input with current URL
|
|
46
48
|
useEffect(() => {
|
|
@@ -52,6 +54,23 @@ export function DocBrowser() {
|
|
|
52
54
|
}
|
|
53
55
|
}, [currentUrl]);
|
|
54
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
|
+
|
|
55
74
|
// Reposition floating window near right edge when switching from docked
|
|
56
75
|
useEffect(() => {
|
|
57
76
|
if (mode === 'floating') {
|
|
@@ -66,12 +85,12 @@ export function DocBrowser() {
|
|
|
66
85
|
useEffect(() => {
|
|
67
86
|
const handler = (e: MessageEvent) => {
|
|
68
87
|
if (e.data?.type === 'docs-route-change' && typeof e.data.url === 'string') {
|
|
69
|
-
|
|
88
|
+
syncUrl(e.data.url);
|
|
70
89
|
}
|
|
71
90
|
};
|
|
72
91
|
window.addEventListener('message', handler);
|
|
73
92
|
return () => window.removeEventListener('message', handler);
|
|
74
|
-
}, [
|
|
93
|
+
}, [syncUrl]);
|
|
75
94
|
|
|
76
95
|
const handleUrlSubmit = useCallback((e: React.FormEvent) => {
|
|
77
96
|
e.preventDefault();
|
|
@@ -169,6 +188,29 @@ export function DocBrowser() {
|
|
|
169
188
|
window.addEventListener('mouseup', onUp);
|
|
170
189
|
}, [dockedWidth]);
|
|
171
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
|
+
|
|
172
214
|
if (!isOpen) return null;
|
|
173
215
|
|
|
174
216
|
const isDocked = mode === 'docked';
|
|
@@ -264,7 +306,8 @@ export function DocBrowser() {
|
|
|
264
306
|
{/* Iframe Content */}
|
|
265
307
|
<div className="flex-1 relative overflow-hidden">
|
|
266
308
|
<iframe
|
|
267
|
-
|
|
309
|
+
ref={iframeRef}
|
|
310
|
+
key={navVersion}
|
|
268
311
|
src={currentUrl}
|
|
269
312
|
className="absolute inset-0 w-full h-full border-0"
|
|
270
313
|
title="NextClaw Documentation"
|
|
@@ -283,6 +326,7 @@ export function DocBrowser() {
|
|
|
283
326
|
href={currentUrl}
|
|
284
327
|
target="_blank"
|
|
285
328
|
rel="noopener noreferrer"
|
|
329
|
+
data-doc-external
|
|
286
330
|
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary-hover font-medium transition-colors"
|
|
287
331
|
>
|
|
288
332
|
{t('docBrowserOpenExternal')}
|
|
@@ -293,6 +337,8 @@ export function DocBrowser() {
|
|
|
293
337
|
{/* Resize Handles (floating only) */}
|
|
294
338
|
{!isDocked && (
|
|
295
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} />
|
|
296
342
|
{/* Right edge */}
|
|
297
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" />
|
|
298
344
|
{/* Bottom edge */}
|
|
@@ -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}>
|