jamdesk 1.0.22 → 1.1.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/dist/__tests__/unit/package-config.test.js +1 -1
- package/dist/__tests__/unit/package-config.test.js.map +1 -1
- package/dist/lib/deps.js +4 -4
- package/dist/lib/deps.js.map +1 -1
- package/package.json +3 -4
- package/vendored/app/[[...slug]]/page.tsx +31 -16
- package/vendored/app/api/og/route.tsx +13 -23
- package/vendored/app/api/raw-content/[...slug]/route.ts +28 -0
- package/vendored/app/layout.tsx +56 -16
- package/vendored/components/AIActionsMenu.tsx +677 -0
- package/vendored/components/mdx/ApiEndpoint.tsx +2 -1
- package/vendored/components/mdx/ImagePriorityProvider.tsx +53 -0
- package/vendored/components/mdx/MDXComponents.tsx +15 -0
- package/vendored/components/mdx/ZoomableImage.tsx +35 -27
- package/vendored/lib/analytics-script.ts +0 -8
- package/vendored/lib/contextual-defaults.ts +16 -0
- package/vendored/lib/docs-types.ts +12 -3
- package/vendored/lib/enhance-navigation.ts +1 -1
- package/vendored/lib/isr-build-executor.ts +41 -0
- package/vendored/lib/preprocess-mdx.ts +5 -3
- package/vendored/lib/r2-content.ts +2 -0
- package/vendored/lib/redirect-matcher.ts +31 -0
- package/vendored/lib/revalidation-helpers.ts +10 -0
- package/vendored/lib/revalidation-trigger.ts +7 -3
- package/vendored/lib/seo.ts +10 -20
- package/vendored/lib/static-artifacts.ts +43 -0
- package/vendored/lib/static-file-route.ts +11 -1
- package/vendored/schema/docs-schema.json +36 -3
- package/vendored/scripts/copy-files.cjs +47 -4
- package/vendored/components/snippets/generated/CodeLink.tsx +0 -25
- package/vendored/components/snippets/generated/HeaderAPI.tsx +0 -44
- package/vendored/components/snippets/generated/PlansAvailable.tsx +0 -53
- package/vendored/components/snippets/generated/SnippetIntro.tsx +0 -43
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
4
|
+
import { usePathname } from 'next/navigation';
|
|
5
|
+
import { useLinkPrefix } from '@/lib/link-prefix-context';
|
|
6
|
+
import { getIconClass } from '@/lib/icon-utils';
|
|
7
|
+
import type { ContextualOption } from '@/lib/docs-types';
|
|
8
|
+
|
|
9
|
+
// =============================================================================
|
|
10
|
+
// CONSTANTS
|
|
11
|
+
// =============================================================================
|
|
12
|
+
|
|
13
|
+
const COPY_FEEDBACK_DELAY_MS = 600;
|
|
14
|
+
|
|
15
|
+
// =============================================================================
|
|
16
|
+
// TYPES
|
|
17
|
+
// =============================================================================
|
|
18
|
+
|
|
19
|
+
interface AIActionsMenuProps {
|
|
20
|
+
options: ContextualOption[];
|
|
21
|
+
projectName: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type FeedbackState = 'idle' | 'loading' | 'success' | 'error';
|
|
25
|
+
|
|
26
|
+
interface BuiltinOptionMeta {
|
|
27
|
+
title: string;
|
|
28
|
+
description: string;
|
|
29
|
+
icon: React.ReactNode;
|
|
30
|
+
isExternal: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// =============================================================================
|
|
34
|
+
// ICONS (inline SVG — same approach as Fumadocs)
|
|
35
|
+
// =============================================================================
|
|
36
|
+
|
|
37
|
+
function CopyIcon({ className }: { className?: string }) {
|
|
38
|
+
return (
|
|
39
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
|
40
|
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
|
41
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
42
|
+
</svg>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function CheckIcon({ className }: { className?: string }) {
|
|
47
|
+
return (
|
|
48
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
|
49
|
+
<polyline points="20 6 9 17 4 12" />
|
|
50
|
+
</svg>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function XIcon({ className }: { className?: string }) {
|
|
55
|
+
return (
|
|
56
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
|
57
|
+
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
|
58
|
+
</svg>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function ChevronDownIcon({ className }: { className?: string }) {
|
|
63
|
+
return (
|
|
64
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
|
65
|
+
<polyline points="6 9 12 15 18 9" />
|
|
66
|
+
</svg>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function FileTextIcon({ className }: { className?: string }) {
|
|
71
|
+
return (
|
|
72
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
|
73
|
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
74
|
+
<polyline points="14 2 14 8 20 8" />
|
|
75
|
+
<line x1="16" y1="13" x2="8" y2="13" />
|
|
76
|
+
<line x1="16" y1="17" x2="8" y2="17" />
|
|
77
|
+
</svg>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function PlugIcon({ className }: { className?: string }) {
|
|
82
|
+
return (
|
|
83
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
|
84
|
+
<path d="M12 22v-5" /><path d="M9 8V2" /><path d="M15 8V2" /><path d="M18 8v5a6 6 0 0 1-12 0V8z" />
|
|
85
|
+
</svg>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function ExternalLinkIcon({ className }: { className?: string }) {
|
|
90
|
+
return (
|
|
91
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
|
92
|
+
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
|
93
|
+
<polyline points="15 3 21 3 21 9" />
|
|
94
|
+
<line x1="10" y1="14" x2="21" y2="3" />
|
|
95
|
+
</svg>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Brand logos
|
|
100
|
+
function ChatGPTIcon({ className }: { className?: string }) {
|
|
101
|
+
return (
|
|
102
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className={className}>
|
|
103
|
+
<path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z" />
|
|
104
|
+
</svg>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function ClaudeIcon({ className }: { className?: string }) {
|
|
109
|
+
return (
|
|
110
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className={className}>
|
|
111
|
+
<path d="m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z" />
|
|
112
|
+
</svg>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function PerplexityIcon({ className }: { className?: string }) {
|
|
117
|
+
return (
|
|
118
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 34 38" fill="currentColor" className={className}>
|
|
119
|
+
<path fillRule="evenodd" clipRule="evenodd" d="M5.12114 0.0400391L15.919 9.98864V9.98636V0.062995H18.0209V10.0332L28.8671 0.0400391V11.3829H33.3202V27.744H28.8808V37.8442L18.0209 28.303V37.9538H15.919V28.4604L5.13338 37.96V27.744H0.680176V11.3829H5.12114V0.0400391ZM14.3344 13.4592H2.78208V25.6677H5.13074V21.8167L14.3344 13.4592ZM7.23518 22.7379V33.3271L15.919 25.6786V14.8506L7.23518 22.7379ZM18.0814 25.5775V14.8404L26.7677 22.7282V27.744H26.7789V33.219L18.0814 25.5775ZM28.8808 25.6677H31.2183V13.4592H19.752L28.8808 21.7302V25.6677ZM26.7652 11.3829V4.81584L19.6374 11.3829H26.7652ZM14.3507 11.3829H7.22306V4.81584L14.3507 11.3829Z" />
|
|
120
|
+
</svg>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function GeminiIcon({ className }: { className?: string }) {
|
|
125
|
+
return (
|
|
126
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className={className}>
|
|
127
|
+
<path d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81" />
|
|
128
|
+
</svg>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function CursorIcon({ className }: { className?: string }) {
|
|
133
|
+
return (
|
|
134
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className={className}>
|
|
135
|
+
<path d="M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23" />
|
|
136
|
+
</svg>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function VSCodeIcon({ className }: { className?: string }) {
|
|
141
|
+
return (
|
|
142
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="currentColor" className={className}>
|
|
143
|
+
<path fillRule="evenodd" clipRule="evenodd" d="M70.9119 99.3171C72.4869 99.9307 74.2828 99.8914 75.8725 99.1264L96.4608 89.2197C98.6242 88.1787 100 85.9892 100 83.5872V16.4133C100 14.0113 98.6243 11.8218 96.4609 10.7808L75.8725 0.873756C73.7862 -0.130129 71.3446 0.11576 69.5135 1.44695C69.252 1.63711 69.0028 1.84943 68.769 2.08341L29.3551 38.0415L12.1872 25.0096C10.589 23.7965 8.35363 23.8959 6.86933 25.2461L1.36303 30.2549C-0.452552 31.9064 -0.454633 34.7627 1.35853 36.417L16.2471 50.0001L1.35853 63.5832C-0.454633 65.2374 -0.452552 68.0938 1.36303 69.7453L6.86933 74.7541C8.35363 76.1043 10.589 76.2037 12.1872 74.9905L29.3551 61.9587L68.769 97.9167C69.3925 98.5406 70.1246 99.0104 70.9119 99.3171ZM75.0152 27.2989L45.1091 50.0001L75.0152 72.7012V27.2989Z" />
|
|
144
|
+
</svg>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// =============================================================================
|
|
149
|
+
// BUILTIN OPTION METADATA
|
|
150
|
+
// =============================================================================
|
|
151
|
+
|
|
152
|
+
const BUILTIN_OPTIONS: Record<string, BuiltinOptionMeta> = {
|
|
153
|
+
copy: {
|
|
154
|
+
title: 'Copy page',
|
|
155
|
+
description: 'Copy page as Markdown for LLMs',
|
|
156
|
+
icon: <CopyIcon className="w-4 h-4" />,
|
|
157
|
+
isExternal: false,
|
|
158
|
+
},
|
|
159
|
+
view: {
|
|
160
|
+
title: 'View as Markdown',
|
|
161
|
+
description: 'Open the raw Markdown in a new tab',
|
|
162
|
+
icon: <FileTextIcon className="w-4 h-4" />,
|
|
163
|
+
isExternal: true,
|
|
164
|
+
},
|
|
165
|
+
chatgpt: {
|
|
166
|
+
title: 'Open in ChatGPT',
|
|
167
|
+
description: 'Ask ChatGPT about this page',
|
|
168
|
+
icon: <ChatGPTIcon className="w-4 h-4" />,
|
|
169
|
+
isExternal: true,
|
|
170
|
+
},
|
|
171
|
+
claude: {
|
|
172
|
+
title: 'Open in Claude',
|
|
173
|
+
description: 'Ask Claude about this page',
|
|
174
|
+
icon: <ClaudeIcon className="w-4 h-4" />,
|
|
175
|
+
isExternal: true,
|
|
176
|
+
},
|
|
177
|
+
gemini: {
|
|
178
|
+
title: 'Open in Gemini',
|
|
179
|
+
description: 'Ask Gemini about this page',
|
|
180
|
+
icon: <GeminiIcon className="w-4 h-4" />,
|
|
181
|
+
isExternal: true,
|
|
182
|
+
},
|
|
183
|
+
perplexity: {
|
|
184
|
+
title: 'Open in Perplexity',
|
|
185
|
+
description: 'Ask Perplexity about this page',
|
|
186
|
+
icon: <PerplexityIcon className="w-4 h-4" />,
|
|
187
|
+
isExternal: true,
|
|
188
|
+
},
|
|
189
|
+
mcp: {
|
|
190
|
+
title: 'Copy MCP config',
|
|
191
|
+
description: 'Copy MCP server configuration as JSON',
|
|
192
|
+
icon: <PlugIcon className="w-4 h-4" />,
|
|
193
|
+
isExternal: false,
|
|
194
|
+
},
|
|
195
|
+
cursor: {
|
|
196
|
+
title: 'Connect to Cursor',
|
|
197
|
+
description: 'Install MCP Server on Cursor',
|
|
198
|
+
icon: <CursorIcon className="w-4 h-4" />,
|
|
199
|
+
isExternal: true,
|
|
200
|
+
},
|
|
201
|
+
vscode: {
|
|
202
|
+
title: 'Connect to VS Code',
|
|
203
|
+
description: 'Install MCP Server on VS Code',
|
|
204
|
+
icon: <VSCodeIcon className="w-4 h-4" />,
|
|
205
|
+
isExternal: true,
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// =============================================================================
|
|
210
|
+
// MENU ITEM CONTENT (shared across all dropdown item variants)
|
|
211
|
+
// =============================================================================
|
|
212
|
+
|
|
213
|
+
function MenuItemContent({ icon, title, description, isExternal }: {
|
|
214
|
+
icon: React.ReactNode;
|
|
215
|
+
title: string;
|
|
216
|
+
description: string;
|
|
217
|
+
isExternal: boolean;
|
|
218
|
+
}) {
|
|
219
|
+
return (
|
|
220
|
+
<>
|
|
221
|
+
<span className="flex-shrink-0 text-[var(--color-text-muted)]">{icon}</span>
|
|
222
|
+
<div className="min-w-0 flex-1">
|
|
223
|
+
<div className="flex items-center gap-1.5">
|
|
224
|
+
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">{title}</span>
|
|
225
|
+
{isExternal && <ExternalLinkIcon className="w-3 h-3 text-[var(--color-text-muted)] flex-shrink-0 hidden sm:block" />}
|
|
226
|
+
</div>
|
|
227
|
+
<div className="text-xs text-[var(--color-text-muted)] mt-0.5 hidden sm:block">{description}</div>
|
|
228
|
+
</div>
|
|
229
|
+
</>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// =============================================================================
|
|
234
|
+
// URL HELPERS
|
|
235
|
+
// =============================================================================
|
|
236
|
+
|
|
237
|
+
function buildMcpServerName(projectName: string): string {
|
|
238
|
+
const slug = projectName.toLowerCase().replace(/\s+/g, '-');
|
|
239
|
+
// Avoid redundant suffixes: "acme-docs-docs" or "acme-documentation-docs"
|
|
240
|
+
if (slug.endsWith('-docs') || slug.endsWith('-documentation')) {
|
|
241
|
+
return slug.replace(/-documentation$/, '-docs');
|
|
242
|
+
}
|
|
243
|
+
return `${slug}-docs`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function buildMcpConfig(projectName: string, mcpUrl: string) {
|
|
247
|
+
return {
|
|
248
|
+
mcpServers: {
|
|
249
|
+
[buildMcpServerName(projectName)]: {
|
|
250
|
+
url: mcpUrl,
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Base URLs for AI chat services that support prompt pre-filling. */
|
|
257
|
+
const AI_CHAT_URLS: Record<string, string> = {
|
|
258
|
+
chatgpt: 'https://chatgpt.com/?hints=search&q=',
|
|
259
|
+
claude: 'https://claude.ai/new?q=',
|
|
260
|
+
gemini: 'https://gemini.google.com/app?q=',
|
|
261
|
+
perplexity: 'https://www.perplexity.ai/?q=',
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
/** Hook that resets a feedback state to 'idle' after 2 seconds. */
|
|
265
|
+
function useFeedbackTimer(state: FeedbackState, setState: (s: FeedbackState) => void) {
|
|
266
|
+
useEffect(() => {
|
|
267
|
+
if (state === 'success' || state === 'error') {
|
|
268
|
+
const timer = setTimeout(() => setState('idle'), 2000);
|
|
269
|
+
return () => clearTimeout(timer);
|
|
270
|
+
}
|
|
271
|
+
}, [state, setState]);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function buildCustomHref(href: string | { base: string; query?: Array<{ key: string; value: string }> }): string {
|
|
275
|
+
if (typeof href === 'string') {
|
|
276
|
+
return /^https?:\/\//i.test(href) ? href : '#';
|
|
277
|
+
}
|
|
278
|
+
const url = new URL(href.base);
|
|
279
|
+
if (href.query) {
|
|
280
|
+
for (const { key, value } of href.query) {
|
|
281
|
+
url.searchParams.set(key, value);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return url.toString();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// =============================================================================
|
|
288
|
+
// COMPONENT
|
|
289
|
+
// =============================================================================
|
|
290
|
+
|
|
291
|
+
export function AIActionsMenu({ options, projectName }: AIActionsMenuProps) {
|
|
292
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
293
|
+
const [copyFeedback, setCopyFeedback] = useState<FeedbackState>('idle');
|
|
294
|
+
const [mcpFeedback, setMcpFeedback] = useState<FeedbackState>('idle');
|
|
295
|
+
const copyLoadingRef = useRef(false);
|
|
296
|
+
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
297
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
298
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
299
|
+
const itemsRef = useRef<(HTMLButtonElement | HTMLAnchorElement | null)[]>([]);
|
|
300
|
+
const pathname = usePathname();
|
|
301
|
+
const linkPrefix = useLinkPrefix();
|
|
302
|
+
|
|
303
|
+
// Compute URLs client-side to survive SPA navigation
|
|
304
|
+
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
|
305
|
+
const mdRelativePath = `${pathname}.md`;
|
|
306
|
+
const mdAbsoluteUrl = `${origin}${pathname}.md`;
|
|
307
|
+
const mcpUrl = `${origin}${linkPrefix}/_mcp`;
|
|
308
|
+
|
|
309
|
+
// Close on navigation
|
|
310
|
+
useEffect(() => {
|
|
311
|
+
setIsOpen(false);
|
|
312
|
+
}, [pathname]);
|
|
313
|
+
|
|
314
|
+
// Clean up close timer on unmount
|
|
315
|
+
useEffect(() => () => {
|
|
316
|
+
if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
|
|
317
|
+
}, []);
|
|
318
|
+
|
|
319
|
+
// Close on click outside
|
|
320
|
+
useEffect(() => {
|
|
321
|
+
if (!isOpen) return;
|
|
322
|
+
function handleMouseDown(e: MouseEvent) {
|
|
323
|
+
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
|
324
|
+
setIsOpen(false);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
document.addEventListener('mousedown', handleMouseDown);
|
|
328
|
+
return () => document.removeEventListener('mousedown', handleMouseDown);
|
|
329
|
+
}, [isOpen]);
|
|
330
|
+
|
|
331
|
+
// Close on Escape
|
|
332
|
+
useEffect(() => {
|
|
333
|
+
if (!isOpen) return;
|
|
334
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
335
|
+
if (e.key === 'Escape') {
|
|
336
|
+
setIsOpen(false);
|
|
337
|
+
triggerRef.current?.focus();
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
341
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
342
|
+
}, [isOpen]);
|
|
343
|
+
|
|
344
|
+
// Reset feedback states after 2s
|
|
345
|
+
useFeedbackTimer(copyFeedback, setCopyFeedback);
|
|
346
|
+
useFeedbackTimer(mcpFeedback, setMcpFeedback);
|
|
347
|
+
|
|
348
|
+
const handleCopy = useCallback(async () => {
|
|
349
|
+
if (copyLoadingRef.current) return;
|
|
350
|
+
copyLoadingRef.current = true;
|
|
351
|
+
setCopyFeedback('loading');
|
|
352
|
+
try {
|
|
353
|
+
// Mobile Safari loses user gesture context after async fetch,
|
|
354
|
+
// causing clipboard.writeText() to fail with NotAllowedError.
|
|
355
|
+
// ClipboardItem with Promise<Blob> preserves the gesture because
|
|
356
|
+
// clipboard.write() is called synchronously in the click handler.
|
|
357
|
+
const supportsClipboardItem = typeof ClipboardItem !== 'undefined'
|
|
358
|
+
&& typeof navigator.clipboard?.write === 'function';
|
|
359
|
+
|
|
360
|
+
if (supportsClipboardItem) {
|
|
361
|
+
const blobPromise = fetch(mdRelativePath)
|
|
362
|
+
.then(res => {
|
|
363
|
+
if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`);
|
|
364
|
+
return res.text();
|
|
365
|
+
})
|
|
366
|
+
.then(text => new Blob([text], { type: 'text/plain' }));
|
|
367
|
+
|
|
368
|
+
await navigator.clipboard.write([
|
|
369
|
+
new ClipboardItem({ 'text/plain': blobPromise }),
|
|
370
|
+
]);
|
|
371
|
+
} else {
|
|
372
|
+
const res = await fetch(mdRelativePath);
|
|
373
|
+
if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`);
|
|
374
|
+
const text = await res.text();
|
|
375
|
+
await navigator.clipboard.writeText(text);
|
|
376
|
+
}
|
|
377
|
+
setCopyFeedback('success');
|
|
378
|
+
} catch {
|
|
379
|
+
setCopyFeedback('error');
|
|
380
|
+
} finally {
|
|
381
|
+
copyLoadingRef.current = false;
|
|
382
|
+
}
|
|
383
|
+
}, [mdRelativePath]);
|
|
384
|
+
|
|
385
|
+
const handleMcpCopy = useCallback(async () => {
|
|
386
|
+
const config = buildMcpConfig(projectName, mcpUrl);
|
|
387
|
+
try {
|
|
388
|
+
await navigator.clipboard.writeText(JSON.stringify(config, null, 2));
|
|
389
|
+
setMcpFeedback('success');
|
|
390
|
+
} catch {
|
|
391
|
+
setMcpFeedback('error');
|
|
392
|
+
}
|
|
393
|
+
}, [projectName, mcpUrl]);
|
|
394
|
+
|
|
395
|
+
const handleAction = useCallback((option: ContextualOption) => {
|
|
396
|
+
if (typeof option !== 'string') {
|
|
397
|
+
// Custom option — open href
|
|
398
|
+
window.open(buildCustomHref(option.href), '_blank', 'noopener,noreferrer');
|
|
399
|
+
setIsOpen(false);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
switch (option) {
|
|
404
|
+
case 'copy':
|
|
405
|
+
handleCopy();
|
|
406
|
+
break;
|
|
407
|
+
case 'view':
|
|
408
|
+
window.open(mdRelativePath, '_blank', 'noopener,noreferrer');
|
|
409
|
+
break;
|
|
410
|
+
case 'chatgpt':
|
|
411
|
+
case 'claude':
|
|
412
|
+
case 'gemini':
|
|
413
|
+
case 'perplexity': {
|
|
414
|
+
const baseUrl = AI_CHAT_URLS[option];
|
|
415
|
+
window.open(
|
|
416
|
+
`${baseUrl}${encodeURIComponent(`Read this page and answer my questions about it: ${mdAbsoluteUrl}`)}`,
|
|
417
|
+
'_blank', 'noopener,noreferrer'
|
|
418
|
+
);
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
case 'mcp':
|
|
422
|
+
handleMcpCopy();
|
|
423
|
+
break;
|
|
424
|
+
case 'cursor': {
|
|
425
|
+
const name = buildMcpServerName(projectName);
|
|
426
|
+
const cursorConfig = btoa(JSON.stringify({ url: mcpUrl }));
|
|
427
|
+
window.open(
|
|
428
|
+
`cursor://anysphere.cursor-deeplink/mcp/install?name=${encodeURIComponent(name)}&config=${cursorConfig}`,
|
|
429
|
+
'_self'
|
|
430
|
+
);
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
case 'vscode': {
|
|
434
|
+
const name = buildMcpServerName(projectName);
|
|
435
|
+
const vsConfig = { name, type: 'http', url: mcpUrl };
|
|
436
|
+
window.open(
|
|
437
|
+
`vscode:mcp/install?${encodeURIComponent(JSON.stringify(vsConfig))}`,
|
|
438
|
+
'_self'
|
|
439
|
+
);
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Close dropdown — copy actions close after brief delay so user sees feedback
|
|
445
|
+
if (option === 'copy' || option === 'mcp') {
|
|
446
|
+
if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
|
|
447
|
+
closeTimerRef.current = setTimeout(() => setIsOpen(false), COPY_FEEDBACK_DELAY_MS);
|
|
448
|
+
} else {
|
|
449
|
+
setIsOpen(false);
|
|
450
|
+
}
|
|
451
|
+
}, [handleCopy, handleMcpCopy, mdRelativePath, mdAbsoluteUrl, projectName, mcpUrl]);
|
|
452
|
+
|
|
453
|
+
// Keyboard navigation within dropdown
|
|
454
|
+
const handleMenuKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
455
|
+
const items = itemsRef.current.filter(Boolean) as HTMLElement[];
|
|
456
|
+
const currentIndex = items.indexOf(e.target as HTMLElement);
|
|
457
|
+
|
|
458
|
+
switch (e.key) {
|
|
459
|
+
case 'ArrowDown': {
|
|
460
|
+
e.preventDefault();
|
|
461
|
+
const next = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
|
|
462
|
+
items[next]?.focus();
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
case 'ArrowUp': {
|
|
466
|
+
e.preventDefault();
|
|
467
|
+
const prev = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
|
|
468
|
+
items[prev]?.focus();
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
case 'Home':
|
|
472
|
+
e.preventDefault();
|
|
473
|
+
items[0]?.focus();
|
|
474
|
+
break;
|
|
475
|
+
case 'End':
|
|
476
|
+
e.preventDefault();
|
|
477
|
+
items[items.length - 1]?.focus();
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
}, []);
|
|
481
|
+
|
|
482
|
+
// Filter valid options
|
|
483
|
+
const validOptions = useMemo(() => options.filter(opt =>
|
|
484
|
+
typeof opt === 'string' ? opt in BUILTIN_OPTIONS : (opt && typeof opt === 'object' && opt.title)
|
|
485
|
+
), [options]);
|
|
486
|
+
|
|
487
|
+
const hasCopy = validOptions.some(opt => opt === 'copy');
|
|
488
|
+
// Items to show in dropdown — copy is always first when present
|
|
489
|
+
const dropdownItems = validOptions;
|
|
490
|
+
const hasDropdown = dropdownItems.length > 0;
|
|
491
|
+
|
|
492
|
+
// Sync itemsRef length with dropdown item count
|
|
493
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
494
|
+
useEffect(() => {
|
|
495
|
+
itemsRef.current.length = dropdownItems.length;
|
|
496
|
+
}, [dropdownItems.length]);
|
|
497
|
+
|
|
498
|
+
if (validOptions.length === 0) return null;
|
|
499
|
+
|
|
500
|
+
// Single option, no dropdown
|
|
501
|
+
if (validOptions.length === 1) {
|
|
502
|
+
const opt = validOptions[0];
|
|
503
|
+
if (opt === 'copy') {
|
|
504
|
+
return (
|
|
505
|
+
<button
|
|
506
|
+
onClick={handleCopy}
|
|
507
|
+
aria-label="Copy page"
|
|
508
|
+
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md cursor-pointer
|
|
509
|
+
text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)]
|
|
510
|
+
bg-[var(--color-bg-secondary)]/50 hover:bg-[var(--color-bg-secondary)]
|
|
511
|
+
border border-[var(--color-border)]/50 hover:border-[var(--color-border)]
|
|
512
|
+
transition-colors"
|
|
513
|
+
>
|
|
514
|
+
{copyFeedback === 'success' ? <CheckIcon className="w-3.5 h-3.5 text-emerald-500" /> :
|
|
515
|
+
copyFeedback === 'error' ? <XIcon className="w-3.5 h-3.5 text-red-500" /> :
|
|
516
|
+
<CopyIcon className="w-3.5 h-3.5" />}
|
|
517
|
+
<span>{copyFeedback === 'success' ? 'Copied' : copyFeedback === 'error' ? 'Failed' : 'Copy page'}</span>
|
|
518
|
+
</button>
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
// Single non-copy option
|
|
522
|
+
const meta = typeof opt === 'string' ? BUILTIN_OPTIONS[opt] : null;
|
|
523
|
+
return (
|
|
524
|
+
<button
|
|
525
|
+
onClick={() => handleAction(opt)}
|
|
526
|
+
aria-label={meta?.title || (typeof opt !== 'string' ? opt.title : '')}
|
|
527
|
+
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md cursor-pointer
|
|
528
|
+
text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)]
|
|
529
|
+
bg-[var(--color-bg-secondary)]/50 hover:bg-[var(--color-bg-secondary)]
|
|
530
|
+
border border-[var(--color-border)]/50 hover:border-[var(--color-border)]
|
|
531
|
+
transition-colors"
|
|
532
|
+
>
|
|
533
|
+
{meta?.icon || null}
|
|
534
|
+
<span>{meta?.title || (typeof opt !== 'string' ? opt.title : '')}</span>
|
|
535
|
+
</button>
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Copy state icon for the primary button
|
|
540
|
+
const copyIcon = copyFeedback === 'success' ? <CheckIcon className="w-3.5 h-3.5 text-emerald-500" /> :
|
|
541
|
+
copyFeedback === 'error' ? <XIcon className="w-3.5 h-3.5 text-red-500" /> :
|
|
542
|
+
copyFeedback === 'loading' ? (
|
|
543
|
+
<svg className="w-3.5 h-3.5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
544
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
545
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
546
|
+
</svg>
|
|
547
|
+
) : <CopyIcon className="w-3.5 h-3.5" />;
|
|
548
|
+
|
|
549
|
+
const copyLabel = copyFeedback === 'success' ? 'Copied!' : copyFeedback === 'error' ? 'Failed' : 'Copy page';
|
|
550
|
+
|
|
551
|
+
return (
|
|
552
|
+
<div ref={menuRef} className="relative inline-flex">
|
|
553
|
+
{hasCopy ? (
|
|
554
|
+
/* Split button: Copy + dropdown chevron */
|
|
555
|
+
<div className="inline-flex rounded-md border border-[var(--color-border-hover)] transition-colors" style={{ borderWidth: '1px' }}>
|
|
556
|
+
<button
|
|
557
|
+
onClick={handleCopy}
|
|
558
|
+
aria-label="Copy page"
|
|
559
|
+
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium cursor-pointer
|
|
560
|
+
text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)]
|
|
561
|
+
bg-[var(--color-bg-secondary)]/50 hover:bg-[var(--color-bg-secondary)]
|
|
562
|
+
rounded-l-md transition-colors"
|
|
563
|
+
>
|
|
564
|
+
{copyIcon}
|
|
565
|
+
<span>{copyLabel}</span>
|
|
566
|
+
</button>
|
|
567
|
+
{hasDropdown && (
|
|
568
|
+
<button
|
|
569
|
+
ref={triggerRef}
|
|
570
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
571
|
+
aria-expanded={isOpen}
|
|
572
|
+
aria-haspopup="menu"
|
|
573
|
+
aria-label="More AI actions"
|
|
574
|
+
className="inline-flex items-center px-1.5 py-1 cursor-pointer
|
|
575
|
+
text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)]
|
|
576
|
+
bg-[var(--color-bg-secondary)]/50 hover:bg-[var(--color-bg-secondary)]
|
|
577
|
+
border-l border-[var(--color-border-hover)]
|
|
578
|
+
rounded-r-md transition-colors"
|
|
579
|
+
style={{ borderLeftWidth: '1px' }}
|
|
580
|
+
>
|
|
581
|
+
<ChevronDownIcon className={`w-3.5 h-3.5 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
|
582
|
+
</button>
|
|
583
|
+
)}
|
|
584
|
+
</div>
|
|
585
|
+
) : (
|
|
586
|
+
/* Single dropdown trigger: "AI Actions" */
|
|
587
|
+
<button
|
|
588
|
+
ref={triggerRef}
|
|
589
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
590
|
+
aria-expanded={isOpen}
|
|
591
|
+
aria-haspopup="menu"
|
|
592
|
+
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md cursor-pointer
|
|
593
|
+
text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)]
|
|
594
|
+
bg-[var(--color-bg-secondary)]/50 hover:bg-[var(--color-bg-secondary)]
|
|
595
|
+
border border-[var(--color-border)]/50 hover:border-[var(--color-border)]
|
|
596
|
+
transition-colors"
|
|
597
|
+
>
|
|
598
|
+
<span>AI Actions</span>
|
|
599
|
+
<ChevronDownIcon className={`w-3.5 h-3.5 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
|
600
|
+
</button>
|
|
601
|
+
)}
|
|
602
|
+
|
|
603
|
+
{/* Dropdown menu */}
|
|
604
|
+
{isOpen && hasDropdown && (
|
|
605
|
+
<div
|
|
606
|
+
role="menu"
|
|
607
|
+
onKeyDown={handleMenuKeyDown}
|
|
608
|
+
ref={dropdownRef}
|
|
609
|
+
className="absolute left-0 sm:left-auto sm:right-0 top-full mt-1.5 z-50
|
|
610
|
+
w-[240px] max-w-[calc(100vw-2rem)] py-1.5
|
|
611
|
+
bg-[var(--color-bg-primary)] border border-[var(--color-border-hover)]
|
|
612
|
+
rounded-lg shadow-lg
|
|
613
|
+
max-h-[calc(100vh-12rem)] overflow-y-auto overscroll-contain"
|
|
614
|
+
style={{ borderWidth: '1px' }}
|
|
615
|
+
>
|
|
616
|
+
{dropdownItems.map((option, index) => {
|
|
617
|
+
if (typeof option === 'string') {
|
|
618
|
+
const meta = BUILTIN_OPTIONS[option];
|
|
619
|
+
if (!meta) return null;
|
|
620
|
+
|
|
621
|
+
// Hide desktop-only options (MCP, Cursor, VS Code) on mobile
|
|
622
|
+
const isMobileHidden = option === 'mcp' || option === 'cursor' || option === 'vscode';
|
|
623
|
+
|
|
624
|
+
// Show feedback icon for copy/mcp actions
|
|
625
|
+
const feedbackIcon = option === 'copy'
|
|
626
|
+
? (copyFeedback === 'success' ? <CheckIcon className="w-4 h-4 text-emerald-500" /> :
|
|
627
|
+
copyFeedback === 'error' ? <XIcon className="w-4 h-4 text-red-500" /> : meta.icon)
|
|
628
|
+
: option === 'mcp'
|
|
629
|
+
? (mcpFeedback === 'success' ? <CheckIcon className="w-4 h-4 text-emerald-500" /> :
|
|
630
|
+
mcpFeedback === 'error' ? <XIcon className="w-4 h-4 text-red-500" /> : meta.icon)
|
|
631
|
+
: meta.icon;
|
|
632
|
+
|
|
633
|
+
return (
|
|
634
|
+
<button
|
|
635
|
+
key={option}
|
|
636
|
+
ref={el => { itemsRef.current[index] = el; }}
|
|
637
|
+
role="menuitem"
|
|
638
|
+
onClick={() => handleAction(option)}
|
|
639
|
+
className={`w-full flex items-center gap-2.5 px-3 py-2.5 text-left cursor-pointer
|
|
640
|
+
hover:bg-[var(--color-bg-secondary)] transition-colors${isMobileHidden ? ' hidden sm:flex' : ''}`}
|
|
641
|
+
>
|
|
642
|
+
<MenuItemContent icon={feedbackIcon} title={meta.title} description={meta.description} isExternal={meta.isExternal} />
|
|
643
|
+
</button>
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Custom option
|
|
648
|
+
const iconName = typeof option.icon === 'string' ? option.icon : option.icon?.name;
|
|
649
|
+
const customIcon = iconName
|
|
650
|
+
? <i className={`${getIconClass(iconName)} w-4 h-4`} />
|
|
651
|
+
: <ExternalLinkIcon className="w-4 h-4" />;
|
|
652
|
+
return (
|
|
653
|
+
<a
|
|
654
|
+
key={`custom-${index}`}
|
|
655
|
+
ref={el => { itemsRef.current[index] = el; }}
|
|
656
|
+
role="menuitem"
|
|
657
|
+
href={buildCustomHref(option.href)}
|
|
658
|
+
target="_blank"
|
|
659
|
+
rel="noopener noreferrer"
|
|
660
|
+
onClick={() => setIsOpen(false)}
|
|
661
|
+
className="flex items-center gap-2.5 px-3 py-2.5 cursor-pointer
|
|
662
|
+
hover:bg-[var(--color-bg-secondary)] transition-colors no-underline"
|
|
663
|
+
>
|
|
664
|
+
<MenuItemContent
|
|
665
|
+
icon={customIcon}
|
|
666
|
+
title={option.title}
|
|
667
|
+
description={option.description || ''}
|
|
668
|
+
isExternal
|
|
669
|
+
/>
|
|
670
|
+
</a>
|
|
671
|
+
);
|
|
672
|
+
})}
|
|
673
|
+
</div>
|
|
674
|
+
)}
|
|
675
|
+
</div>
|
|
676
|
+
);
|
|
677
|
+
}
|