Skip to content

Commit 590d331

Browse files
committed
feat(api-keys): refine provider endpoint copy panel
1 parent 0c8f8ee commit 590d331

5 files changed

Lines changed: 200 additions & 16 deletions

File tree

crates/web/app/src/features/api-keys/keys-table.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import type { ReactNode } from 'react'
12
import { ActionDropdown, CheckIcon, CopyIcon, EmptyState, ErrorBanner, Panel, StatusBadge, formatTimestamp } from '../../ui/shared'
23
import type { PlatformApiKeyRecord } from '../../types'
34
import { formatQuota } from './utils'
45

56
export function ApiKeysPanel({
67
title,
78
description,
9+
actions,
810
keys,
911
editingId,
1012
editName,
@@ -27,6 +29,7 @@ export function ApiKeysPanel({
2729
}: {
2830
title: string
2931
description: string
32+
actions?: ReactNode
3033
keys: PlatformApiKeyRecord[]
3134
editingId: string | null
3235
editName: string
@@ -48,7 +51,7 @@ export function ApiKeysPanel({
4851
t: (key: string, values?: Record<string, string | number>) => string
4952
}) {
5053
return (
51-
<Panel title={title} description={description}>
54+
<Panel title={title} description={description} actions={actions}>
5255
{keys.length === 0 ? (
5356
<EmptyState title={t('api_keys.no_keys')} body={t('api_keys.no_keys_desc')} />
5457
) : (

crates/web/app/src/features/api-keys/page.tsx

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ErrorBanner, Spinner, useCopyFeedback } from '../../ui/shared'
77
import type { PlatformApiKeyRecord } from '../../types'
88
import { CreateKeyDrawer } from './create-key-drawer'
99
import { ApiKeysPanel } from './keys-table'
10+
import { ProxyEndpointsPanel } from './proxy-endpoints-panel'
1011

1112
export function ApiKeysPage() {
1213
const { token } = useSession()
@@ -82,20 +83,13 @@ export function ApiKeysPage() {
8283
setCreatedKey(null)
8384
}
8485

86+
const baseOrigin =
87+
typeof window === 'undefined'
88+
? ''
89+
: window.location.origin.trim().replace(/\/+$/, '')
90+
8591
return (
8692
<div className="page-grid">
87-
<section className="hero-strip">
88-
<div>
89-
<span className="eyebrow">{t('api_keys.eyebrow')}</span>
90-
<h2>{t('api_keys.title')}</h2>
91-
</div>
92-
<div className="hero-actions">
93-
<button type="button" className="primary-button" onClick={() => { setCreatedKey(null); setDrawerOpen(true) }}>
94-
+ {t('api_keys.create_btn')}
95-
</button>
96-
</div>
97-
</section>
98-
9993
<CreateKeyDrawer
10094
open={drawerOpen}
10195
onClose={closeDrawer}
@@ -116,9 +110,30 @@ export function ApiKeysPage() {
116110
t={t}
117111
/>
118112

113+
<ProxyEndpointsPanel
114+
baseOrigin={baseOrigin}
115+
copiedId={copiedId}
116+
onCopyEndpoint={(endpoint, copyId) => {
117+
void copy(endpoint, copyId)
118+
}}
119+
t={t}
120+
/>
121+
119122
<ApiKeysPanel
120123
title={t('api_keys.pool')}
121124
description={t('api_keys.count', { count: keysQuery.data?.length ?? 0 })}
125+
actions={(
126+
<button
127+
type="button"
128+
className="primary-button"
129+
onClick={() => {
130+
setCreatedKey(null)
131+
setDrawerOpen(true)
132+
}}
133+
>
134+
+ {t('api_keys.create_btn')}
135+
</button>
136+
)}
122137
keys={keysQuery.data ?? []}
123138
editingId={editingId}
124139
editName={editName}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { useState } from 'react'
2+
import type { ProviderId } from '../../types'
3+
import { CheckIcon, CopyIcon, CustomSelect, Panel, PROVIDER_IDS, ProviderTag, getProviderLabel } from '../../ui/shared'
4+
5+
function buildEndpoint(baseOrigin: string, provider: ProviderId) {
6+
return baseOrigin ? `${baseOrigin}/${provider}` : `/${provider}`
7+
}
8+
9+
function buildRouteEntries(baseOrigin: string, provider: ProviderId) {
10+
if (provider === 'jina') {
11+
const jinaBase = buildEndpoint(baseOrigin, provider)
12+
13+
return [
14+
{
15+
id: 'reader',
16+
label: 'Reader',
17+
endpoint: `${jinaBase}/r`,
18+
},
19+
{
20+
id: 'search',
21+
label: 'Search',
22+
endpoint: `${jinaBase}/s`,
23+
},
24+
]
25+
}
26+
27+
return [
28+
{
29+
id: provider,
30+
label: getProviderLabel(provider),
31+
endpoint: buildEndpoint(baseOrigin, provider),
32+
},
33+
]
34+
}
35+
36+
export function ProxyEndpointsPanel({
37+
baseOrigin,
38+
copiedId,
39+
onCopyEndpoint,
40+
t,
41+
}: {
42+
baseOrigin: string
43+
copiedId: string | null
44+
onCopyEndpoint: (endpoint: string, copyId: string) => void
45+
t: (key: string, values?: Record<string, string | number>) => string
46+
}) {
47+
const normalizedOrigin = baseOrigin.trim().replace(/\/+$/, '')
48+
const [selectedProvider, setSelectedProvider] = useState<ProviderId>('firecrawl')
49+
const description = normalizedOrigin
50+
? t('api_keys.endpoints_desc', { origin: normalizedOrigin })
51+
: t('api_keys.endpoints_desc_fallback')
52+
const routeEntries = buildRouteEntries(normalizedOrigin, selectedProvider)
53+
54+
return (
55+
<Panel title={t('api_keys.endpoints_title')} description={description}>
56+
<div className="api-endpoints-toolbar">
57+
<div className="api-endpoints-provider">
58+
<span className="api-key-hint">{t('api_keys.provider_label')}</span>
59+
<CustomSelect
60+
value={selectedProvider}
61+
onChange={(value) => setSelectedProvider(value as ProviderId)}
62+
ariaLabel={t('api_keys.provider_label')}
63+
options={PROVIDER_IDS.map((provider) => ({
64+
value: provider,
65+
label: getProviderLabel(provider),
66+
}))}
67+
/>
68+
</div>
69+
</div>
70+
<div className="mini-table api-endpoints-list">
71+
{routeEntries.map((entry) => {
72+
const copyId = `endpoint:${selectedProvider}:${entry.id}`
73+
74+
return (
75+
<div key={entry.id} className="mini-row api-endpoint-row">
76+
<div className="api-endpoint-meta">
77+
<ProviderTag provider={selectedProvider} />
78+
<strong>{entry.label}</strong>
79+
</div>
80+
<code className="api-endpoint-url">{entry.endpoint}</code>
81+
<button
82+
type="button"
83+
className={`copy-btn copy-btn-compact${copiedId === copyId ? ' is-copied' : ''}`}
84+
onClick={() => onCopyEndpoint(entry.endpoint, copyId)}
85+
>
86+
{copiedId === copyId ? (
87+
<>
88+
<CheckIcon />
89+
{t('common.copied')}
90+
</>
91+
) : (
92+
<>
93+
<CopyIcon />
94+
{t('common.copy')}
95+
</>
96+
)}
97+
</button>
98+
</div>
99+
)
100+
})}
101+
</div>
102+
<p className="api-key-hint">
103+
{selectedProvider === 'jina' ? t('api_keys.endpoints_hint_jina') : t('api_keys.endpoints_hint')}
104+
</p>
105+
</Panel>
106+
)
107+
}

crates/web/app/src/i18n/messages/operations.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,6 @@ export const operationsMessages = {
3939
'async.reconcile_result': { en: 'Reconcile Result', zh: '同步结果' },
4040
'async.reconcile_stats': { en: 'scanned: {scanned} · progressed: {progressed} · settled: {settled} · failed: {failed}', zh: '扫描: {scanned} · 更新: {progressed} · 完成: {settled} · 失败: {failed}' },
4141

42-
'api_keys.eyebrow': { en: 'Operations', zh: '管理' },
43-
'api_keys.title': { en: 'API Keys', zh: 'API 密钥' },
4442
'api_keys.create': { en: 'Create API Key', zh: '创建 API 密钥' },
4543
'api_keys.create_desc': { en: 'Create a new Platform API Key.', zh: '创建新的平台 API 密钥。' },
4644
'api_keys.quota_label': { en: 'Quota (0 = unlimited)', zh: '配额(0 = 无限制)' },
@@ -50,6 +48,12 @@ export const operationsMessages = {
5048
'api_keys.dismiss': { en: 'Dismiss', zh: '关闭' },
5149
'api_keys.pool': { en: 'API Keys', zh: 'API 密钥' },
5250
'api_keys.count': { en: '{count} keys', zh: '{count} 个密钥' },
51+
'api_keys.endpoints_title': { en: 'Proxy Endpoints', zh: '代理接口' },
52+
'api_keys.endpoints_desc': { en: 'Copy the full proxy URL directly. Current domain: {origin}', zh: '可直接复制完整代理地址。当前域名:{origin}' },
53+
'api_keys.endpoints_desc_fallback': { en: 'Copy the full proxy URL directly. The current domain will be detected in the browser.', zh: '可直接复制完整代理地址。当前域名会在浏览器中自动识别。' },
54+
'api_keys.provider_label': { en: 'Provider', zh: '提供商' },
55+
'api_keys.endpoints_hint': { en: 'Send requests to these URLs with your Platform API Key in the headers.', zh: '请求这些地址时,在请求头中携带平台 API Key。' },
56+
'api_keys.endpoints_hint_jina': { en: 'Jina must use /jina/r or /jina/s. Append the target URL after that prefix.', zh: 'Jina 必须使用 /jina/r 或 /jina/s,并在该前缀后继续拼接目标 URL。' },
5357
'api_keys.no_keys': { en: 'No API Keys', zh: '无 API 密钥' },
5458
'api_keys.no_keys_desc': { en: 'Click the button above to create the first Platform API Key.', zh: '点击右上角按钮创建第一个平台 API 密钥。' },
5559
} satisfies Record<string, { en: string; zh: string }>

crates/web/app/src/styles/features/api-keys.css

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,62 @@
117117
.api-key-hint {
118118
font-size: 12px;
119119
color: var(--text-tertiary);
120-
white-space: nowrap;
120+
white-space: normal;
121+
}
122+
123+
.api-endpoints-list {
124+
gap: 10px;
125+
}
126+
127+
.api-endpoints-toolbar {
128+
display: flex;
129+
justify-content: flex-start;
130+
}
131+
132+
.api-endpoints-provider {
133+
display: grid;
134+
gap: 6px;
135+
min-width: min(280px, 100%);
136+
}
137+
138+
.api-endpoint-row {
139+
grid-template-columns: auto minmax(0, 1fr) auto;
140+
}
141+
142+
.api-endpoint-meta {
143+
display: flex;
144+
align-items: center;
145+
gap: 8px;
146+
min-width: 0;
147+
}
148+
149+
.api-endpoint-meta strong {
150+
font-size: 13px;
151+
color: var(--text-heading);
152+
}
153+
154+
.api-endpoint-url {
155+
min-width: 0;
156+
overflow-wrap: anywhere;
157+
word-break: break-word;
158+
font-family: var(--mono);
159+
font-size: 12px;
160+
color: var(--text);
161+
}
162+
163+
@media (max-width: 640px) {
164+
.api-endpoint-row {
165+
grid-template-columns: minmax(0, 1fr);
166+
}
167+
168+
.api-endpoint-meta {
169+
flex-wrap: wrap;
170+
}
171+
172+
.api-endpoint-row .copy-btn {
173+
width: 100%;
174+
justify-content: center;
175+
}
121176
}
122177

123178
.api-keys-table .cell-stack {

0 commit comments

Comments
 (0)