Skip to content

Commit e1026dd

Browse files
pescnclaude
andcommitted
feat: improve build configuration and MDX components
- Update vite.config.ts for dual-mode builds (submodule/symlink) - Fix "Back to Dashboard" navigation to work with /docs/ base path - Add API key selector dropdown in documentation - Add copy-to-clipboard functionality for config code blocks - Update package.json build script for flexible output handling - Downgrade @types/node to v18 for compatibility - Add dist/ to .gitignore Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 83a5e23 commit e1026dd

12 files changed

Lines changed: 279 additions & 50 deletions

.gitignore

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,6 @@ node_modules
1717

1818
.source
1919

20-
# Build output (generated to backend/docs)
21-
../backend/docs/
20+
# Build output
21+
dist/
22+
../backend/docs/

bun.lock

Lines changed: 4 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55
"sideEffects": false,
66
"scripts": {
77
"dev": "vite dev",
8-
"build": "vite build && rm -rf ../backend/docs/server",
8+
"build": "vite build && rm -rf dist/server ../backend/docs/server 2>/dev/null; [ -d dist/client ] && (cp -r dist/client/. ../NexusGate/backend/docs/ 2>/dev/null || cp -r dist/client/. ../backend/docs/); [ -d ../backend/docs/client ] && cp -r ../backend/docs/client/. ../backend/docs/ && rm -rf ../backend/docs/client; true",
99
"start": "serve dist/client",
1010
"types:check": "fumadocs-mdx && tsc --noEmit",
11-
"postinstall": "fumadocs-mdx"
11+
"postinstall": "fumadocs-mdx 2>/dev/null || true"
1212
},
1313
"dependencies": {
1414
"@orama/orama": "^3.1.18",
@@ -28,7 +28,7 @@
2828
"devDependencies": {
2929
"@tailwindcss/vite": "^4.1.18",
3030
"@types/mdx": "^2.0.13",
31-
"@types/node": "^25.0.5",
31+
"@types/node": "^18.19.0",
3232
"@types/react": "^19.2.8",
3333
"@types/react-dom": "^19.2.3",
3434
"@vitejs/plugin-react": "^5.1.2",
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
'use client';
2+
3+
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
4+
5+
interface ApiKeyInfo {
6+
key: string;
7+
comment: string;
8+
revoked: boolean;
9+
}
10+
11+
interface ApiKeyContextType {
12+
apiKeys: ApiKeyInfo[];
13+
selectedApiKey: string | null;
14+
setSelectedApiKey: (key: string | null) => void;
15+
isLoading: boolean;
16+
error: string | null;
17+
}
18+
19+
const ApiKeyContext = createContext<ApiKeyContextType | null>(null);
20+
21+
export function ApiKeyProvider({ children }: { children: ReactNode }) {
22+
const [apiKeys, setApiKeys] = useState<ApiKeyInfo[]>([]);
23+
const [selectedApiKey, setSelectedApiKey] = useState<string | null>(null);
24+
const [isLoading, setIsLoading] = useState(true);
25+
const [error, setError] = useState<string | null>(null);
26+
27+
useEffect(() => {
28+
const fetchApiKeys = async () => {
29+
try {
30+
// Get admin secret from localStorage (same as frontend)
31+
const adminSecret = localStorage.getItem('admin-secret');
32+
if (!adminSecret) {
33+
setError('Not authenticated');
34+
setIsLoading(false);
35+
return;
36+
}
37+
38+
const response = await fetch('/api/admin/apiKey', {
39+
headers: {
40+
'Authorization': `Bearer ${JSON.parse(adminSecret)}`,
41+
},
42+
});
43+
44+
if (!response.ok) {
45+
throw new Error('Failed to fetch API keys');
46+
}
47+
48+
const data = await response.json();
49+
// Filter out revoked keys
50+
const activeKeys = data.filter((k: ApiKeyInfo) => !k.revoked);
51+
setApiKeys(activeKeys);
52+
53+
// Auto-select first key if available
54+
if (activeKeys.length > 0) {
55+
setSelectedApiKey(activeKeys[0].key);
56+
}
57+
} catch (err) {
58+
setError(err instanceof Error ? err.message : 'Unknown error');
59+
} finally {
60+
setIsLoading(false);
61+
}
62+
};
63+
64+
if (typeof window !== 'undefined') {
65+
fetchApiKeys();
66+
}
67+
}, []);
68+
69+
return (
70+
<ApiKeyContext.Provider value={{ apiKeys, selectedApiKey, setSelectedApiKey, isLoading, error }}>
71+
{children}
72+
</ApiKeyContext.Provider>
73+
);
74+
}
75+
76+
export function useApiKey() {
77+
const context = useContext(ApiKeyContext);
78+
if (!context) {
79+
// Return default values if used outside provider
80+
return {
81+
apiKeys: [],
82+
selectedApiKey: null,
83+
setSelectedApiKey: () => {},
84+
isLoading: false,
85+
error: 'No provider',
86+
};
87+
}
88+
return context;
89+
}
Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
'use client';
22

33
import { useEffect, useState } from 'react';
4+
import { ChevronDown } from 'lucide-react';
5+
import { useApiKey } from './api-key-context';
46

57
interface ApiKeyLinkProps {
68
text?: string;
79
}
810

911
export function ApiKeyLink({ text }: ApiKeyLinkProps) {
1012
const [isZh, setIsZh] = useState(false);
13+
const [isOpen, setIsOpen] = useState(false);
14+
const { apiKeys, selectedApiKey, setSelectedApiKey, isLoading, error } = useApiKey();
1115

1216
useEffect(() => {
1317
if (typeof window !== 'undefined') {
@@ -18,20 +22,83 @@ export function ApiKeyLink({ text }: ApiKeyLinkProps) {
1822
const handleClick = () => {
1923
if (typeof window !== 'undefined') {
2024
const origin = window.location.origin.replace(/\/docs\/?$/, '');
21-
window.location.href = `${origin}/api-keys`;
25+
window.location.href = `${origin}/apps`;
2226
}
2327
};
2428

25-
const displayText = text || (isZh ? '点此查看 API Key' : 'Click to view API Key');
26-
const title = isZh ? '点击前往 API Keys 页面' : 'Click to go to API Keys page';
29+
// If loading, error, or no API keys, fallback to original behavior
30+
if (isLoading || error || apiKeys.length === 0) {
31+
const displayText = text || (isZh ? '点此查看 API Key' : 'Click to view API Key');
32+
const title = isZh ? '点击前往 API Keys 页面' : 'Click to go to API Keys page';
33+
34+
return (
35+
<button
36+
onClick={handleClick}
37+
className="fd-inline-code cursor-pointer underline decoration-dotted underline-offset-2 hover:text-fd-primary transition-colors"
38+
title={title}
39+
>
40+
{displayText}
41+
</button>
42+
);
43+
}
44+
45+
// Find the selected key info
46+
const selectedKeyInfo = apiKeys.find(k => k.key === selectedApiKey);
47+
const displayName = selectedKeyInfo?.comment || (isZh ? '选择 API Key' : 'Select API Key');
48+
49+
// Mask the API key for display
50+
const maskedKey = selectedApiKey
51+
? `${selectedApiKey.slice(0, 8)}...${selectedApiKey.slice(-4)}`
52+
: '';
2753

2854
return (
29-
<button
30-
onClick={handleClick}
31-
className="fd-inline-code cursor-pointer underline decoration-dotted underline-offset-2 hover:text-fd-primary transition-colors"
32-
title={title}
33-
>
34-
{displayText}
35-
</button>
55+
<span className="relative inline-block">
56+
<button
57+
onClick={() => setIsOpen(!isOpen)}
58+
className="fd-inline-code cursor-pointer inline-flex items-center gap-1 hover:text-fd-primary transition-colors"
59+
title={isZh ? '点击选择 API Key' : 'Click to select API Key'}
60+
>
61+
<span>{maskedKey || displayName}</span>
62+
<ChevronDown className="size-3" />
63+
</button>
64+
65+
{isOpen && (
66+
<>
67+
{/* Backdrop to close dropdown */}
68+
<div
69+
className="fixed inset-0 z-40"
70+
onClick={() => setIsOpen(false)}
71+
/>
72+
{/* Dropdown menu */}
73+
<div className="absolute left-0 top-full mt-1 z-50 min-w-[200px] rounded-md border bg-fd-popover p-1 shadow-md">
74+
{apiKeys.map((apiKey) => (
75+
<button
76+
key={apiKey.key}
77+
onClick={() => {
78+
setSelectedApiKey(apiKey.key);
79+
setIsOpen(false);
80+
}}
81+
className={`w-full text-left px-3 py-2 text-sm rounded-sm hover:bg-fd-accent hover:text-fd-accent-foreground transition-colors ${
82+
selectedApiKey === apiKey.key ? 'bg-fd-accent/50' : ''
83+
}`}
84+
>
85+
<div className="font-medium">{apiKey.comment || (isZh ? '未命名' : 'Unnamed')}</div>
86+
<div className="text-xs text-fd-muted-foreground font-mono">
87+
{apiKey.key.slice(0, 8)}...{apiKey.key.slice(-4)}
88+
</div>
89+
</button>
90+
))}
91+
<div className="border-t mt-1 pt-1">
92+
<button
93+
onClick={handleClick}
94+
className="w-full text-left px-3 py-2 text-sm rounded-sm hover:bg-fd-accent hover:text-fd-accent-foreground transition-colors text-fd-muted-foreground"
95+
>
96+
{isZh ? '管理 API Keys →' : 'Manage API Keys →'}
97+
</button>
98+
</div>
99+
</div>
100+
</>
101+
)}
102+
</span>
36103
);
37104
}

src/components/mdx/config-code.tsx

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
'use client';
22

3-
import { ReactNode } from 'react';
3+
import { ReactNode, useRef, useState } from 'react';
4+
import { Check, Copy } from 'lucide-react';
5+
import { useApiKey } from './api-key-context';
46

57
interface ConfigCodeProps {
68
children: ReactNode;
@@ -19,10 +21,56 @@ interface ConfigCodeProps {
1921
* </ConfigCode>
2022
*/
2123
export function ConfigCode({ children, lang }: ConfigCodeProps) {
24+
const codeRef = useRef<HTMLElement>(null);
25+
const [copied, setCopied] = useState(false);
26+
const { selectedApiKey } = useApiKey();
27+
28+
const handleCopy = async () => {
29+
if (!codeRef.current) return;
30+
31+
// Get the text content and replace API key placeholder
32+
let text = codeRef.current.innerText || codeRef.current.textContent || '';
33+
34+
// If an API key is selected, the text should already contain it
35+
// But we need to handle the case where the ApiKeyLink shows masked key
36+
// We'll replace any masked API key pattern with the full key
37+
if (selectedApiKey) {
38+
// Replace masked key pattern (e.g., "ng-xxxxx...xxxx") with full key
39+
const maskedPattern = new RegExp(
40+
`${selectedApiKey.slice(0, 8)}\\.\\.\\.${selectedApiKey.slice(-4)}`,
41+
'g'
42+
);
43+
text = text.replace(maskedPattern, selectedApiKey);
44+
45+
// Also replace the fallback text if present
46+
text = text.replace(/ API Key/g, selectedApiKey);
47+
text = text.replace(/Click to view API Key/g, selectedApiKey);
48+
}
49+
50+
try {
51+
await navigator.clipboard.writeText(text);
52+
setCopied(true);
53+
setTimeout(() => setCopied(false), 2000);
54+
} catch (err) {
55+
console.error('Failed to copy:', err);
56+
}
57+
};
58+
2259
return (
23-
<figure className="fd-codeblock not-prose relative my-4">
60+
<figure className="fd-codeblock not-prose relative my-4 group">
61+
<button
62+
onClick={handleCopy}
63+
className="absolute right-2 top-2 p-2 rounded-md bg-fd-secondary/80 hover:bg-fd-secondary border border-fd-border opacity-0 group-hover:opacity-100 transition-opacity"
64+
title={copied ? 'Copied!' : 'Copy to clipboard'}
65+
>
66+
{copied ? (
67+
<Check className="size-4 text-green-500" />
68+
) : (
69+
<Copy className="size-4 text-fd-muted-foreground" />
70+
)}
71+
</button>
2472
<pre className="p-4 overflow-x-auto rounded-lg border bg-fd-secondary/50 text-sm">
25-
<code className={lang ? `language-${lang}` : undefined}>
73+
<code ref={codeRef} className={lang ? `language-${lang}` : undefined}>
2674
{children}
2775
</code>
2876
</pre>

src/components/mdx/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { ServerUrl } from './server-url';
22
export { ApiKeyLink } from './api-key-link';
3+
export { ApiKeyProvider } from './api-key-context';
34
export { InlineCode } from './inline-code';
45
export { ConfigCode, CodeKeyword, CodeString, CodeComment, CodeLine } from './config-code';

src/components/nav-title.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,18 @@ interface NavTitleProps {
99
export function NavTitle({ locale }: NavTitleProps) {
1010
const isZh = locale === 'zh';
1111

12-
// Use relative path to go up from /docs/ to root /
12+
const handleClick = () => {
13+
// Navigate to root, bypassing the /docs/ base path
14+
window.location.href = window.location.origin + '/';
15+
};
16+
1317
return (
14-
<a
15-
href="/"
18+
<button
19+
onClick={handleClick}
1620
className="flex items-center gap-2 text-sm font-medium text-fd-foreground transition-colors hover:text-fd-foreground/80"
1721
>
1822
<ArrowLeft className="size-4" />
1923
{isZh ? '返回控制台' : 'Back to Dashboard'}
20-
</a>
24+
</button>
2125
);
2226
}

src/components/sidebar-banner.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,19 @@ interface SidebarBannerProps {
99
export function SidebarBanner({ locale }: SidebarBannerProps) {
1010
const isZh = locale === 'zh';
1111

12+
const handleClick = () => {
13+
window.location.href = window.location.origin + '/';
14+
};
15+
1216
return (
1317
<div className="px-4 pb-2">
14-
<a
15-
href="/"
16-
className="flex items-center justify-center gap-2 rounded-md border border-fd-border px-3 py-2 text-sm font-medium text-fd-foreground transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground"
18+
<button
19+
onClick={handleClick}
20+
className="flex w-full items-center justify-center gap-2 rounded-md border border-fd-border px-3 py-2 text-sm font-medium text-fd-foreground transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground"
1721
>
1822
<ArrowLeft className="size-4" />
1923
{isZh ? '返回控制台' : 'Back to Dashboard'}
20-
</a>
24+
</button>
2125
</div>
2226
);
2327
}

0 commit comments

Comments
 (0)