@nastechai/agent 0.16.0 → 0.17.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/eslint.config.js +23 -0
- package/index.html +24 -0
- package/package.json +54 -26
- package/package.json.bak +89 -0
- package/package.json.pub +88 -0
- package/src/App.tsx +1173 -0
- package/src/components/AuthWidget.tsx +150 -0
- package/src/components/AutoField.tsx +206 -0
- package/src/components/Backdrop.tsx +93 -0
- package/src/components/ChatSidebar.tsx +394 -0
- package/src/components/DeleteConfirmDialog.tsx +40 -0
- package/src/components/LanguageSwitcher.tsx +186 -0
- package/src/components/Markdown.tsx +383 -0
- package/src/components/ModelInfoCard.tsx +112 -0
- package/src/components/ModelPickerDialog.tsx +470 -0
- package/src/components/OAuthLoginModal.tsx +374 -0
- package/src/components/OAuthProvidersCard.tsx +287 -0
- package/src/components/PlatformsCard.tsx +97 -0
- package/src/components/ScheduleBuilder.tsx +273 -0
- package/src/components/SidebarFooter.tsx +42 -0
- package/src/components/SidebarStatusStrip.tsx +72 -0
- package/src/components/SlashPopover.tsx +171 -0
- package/src/components/ThemeSwitcher.tsx +243 -0
- package/src/components/ToolCall.tsx +228 -0
- package/src/components/ToolsetConfigDrawer.tsx +448 -0
- package/src/contexts/PageHeaderProvider.tsx +139 -0
- package/src/contexts/SystemActions.tsx +120 -0
- package/src/contexts/page-header-context.ts +12 -0
- package/src/contexts/system-actions-context.ts +18 -0
- package/src/contexts/usePageHeader.ts +10 -0
- package/src/contexts/useSystemActions.ts +15 -0
- package/src/hooks/useModalBehavior.ts +44 -0
- package/src/hooks/useSidebarStatus.ts +27 -0
- package/src/i18n/af.ts +702 -0
- package/src/i18n/context.tsx +123 -0
- package/src/i18n/de.ts +701 -0
- package/src/i18n/en.ts +708 -0
- package/src/i18n/es.ts +701 -0
- package/src/i18n/fr.ts +701 -0
- package/src/i18n/ga.ts +702 -0
- package/src/i18n/hu.ts +702 -0
- package/src/i18n/index.ts +2 -0
- package/src/i18n/it.ts +701 -0
- package/src/i18n/ja.ts +702 -0
- package/src/i18n/ko.ts +702 -0
- package/src/i18n/pt.ts +702 -0
- package/src/i18n/ru.ts +702 -0
- package/src/i18n/tr.ts +702 -0
- package/src/i18n/types.ts +710 -0
- package/src/i18n/uk.ts +702 -0
- package/src/i18n/zh-hant.ts +702 -0
- package/src/i18n/zh.ts +698 -0
- package/src/index.css +274 -0
- package/src/lib/api.ts +1585 -0
- package/src/lib/dashboard-flags.ts +15 -0
- package/src/lib/format.ts +9 -0
- package/src/lib/fuzzy.ts +192 -0
- package/src/lib/gatewayClient.ts +253 -0
- package/src/lib/nested.ts +23 -0
- package/src/lib/resolve-page-title.ts +41 -0
- package/src/lib/schedule.ts +382 -0
- package/src/lib/slashExec.ts +163 -0
- package/src/lib/utils.ts +35 -0
- package/src/main.tsx +25 -0
- package/src/pages/AnalyticsPage.tsx +601 -0
- package/src/pages/ChannelsPage.tsx +772 -0
- package/src/pages/ChatPage.tsx +889 -0
- package/src/pages/ConfigPage.tsx +660 -0
- package/src/pages/CronPage.tsx +524 -0
- package/src/pages/DocsPage.tsx +69 -0
- package/src/pages/EnvPage.tsx +918 -0
- package/src/pages/LogsPage.tsx +246 -0
- package/src/pages/McpPage.tsx +757 -0
- package/src/pages/ModelsPage.tsx +994 -0
- package/src/pages/PairingPage.tsx +276 -0
- package/src/pages/PluginsPage.tsx +580 -0
- package/src/pages/ProfilesPage.tsx +559 -0
- package/src/pages/SessionsPage.tsx +936 -0
- package/src/pages/SkillsPage.tsx +557 -0
- package/src/pages/SystemPage.tsx +1259 -0
- package/src/pages/WebhooksPage.tsx +483 -0
- package/src/plugins/PluginPage.tsx +64 -0
- package/src/plugins/index.ts +6 -0
- package/src/plugins/registry.ts +151 -0
- package/src/plugins/sdk.d.ts +160 -0
- package/src/plugins/slots.ts +199 -0
- package/src/plugins/types.ts +37 -0
- package/src/plugins/usePlugins.ts +133 -0
- package/src/themes/context.tsx +443 -0
- package/src/themes/fonts.ts +160 -0
- package/src/themes/index.ts +3 -0
- package/src/themes/presets.ts +477 -0
- package/src/themes/types.ts +187 -0
- package/tsconfig.app.json +34 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/vite.config.ts +124 -0
- package/vite.config.ts.timestamp-1780999102396-af6b77b30ebd8.mjs +105 -0
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Eye,
|
|
4
|
+
EyeOff,
|
|
5
|
+
ExternalLink,
|
|
6
|
+
KeyRound,
|
|
7
|
+
MessageSquare,
|
|
8
|
+
Pencil,
|
|
9
|
+
Save,
|
|
10
|
+
Settings,
|
|
11
|
+
Trash2,
|
|
12
|
+
X,
|
|
13
|
+
Zap,
|
|
14
|
+
ChevronDown,
|
|
15
|
+
ChevronRight,
|
|
16
|
+
} from "lucide-react";
|
|
17
|
+
import { api } from "@/lib/api";
|
|
18
|
+
import type { EnvVarInfo } from "@/lib/api";
|
|
19
|
+
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
|
20
|
+
import { Toast } from "@nastechai/ui/ui/components/toast";
|
|
21
|
+
import { useConfirmDelete } from "@nastechai/ui/hooks/use-confirm-delete";
|
|
22
|
+
import { useToast } from "@nastechai/ui/hooks/use-toast";
|
|
23
|
+
import { OAuthProvidersCard } from "@/components/OAuthProvidersCard";
|
|
24
|
+
import { Button } from "@nastechai/ui/ui/components/button";
|
|
25
|
+
import { ListItem } from "@nastechai/ui/ui/components/list-item";
|
|
26
|
+
import { Spinner } from "@nastechai/ui/ui/components/spinner";
|
|
27
|
+
import {
|
|
28
|
+
Card,
|
|
29
|
+
CardContent,
|
|
30
|
+
CardDescription,
|
|
31
|
+
CardHeader,
|
|
32
|
+
CardTitle,
|
|
33
|
+
} from "@nastechai/ui/ui/components/card";
|
|
34
|
+
import { Badge } from "@nastechai/ui/ui/components/badge";
|
|
35
|
+
import { Input } from "@nastechai/ui/ui/components/input";
|
|
36
|
+
import { Label } from "@nastechai/ui/ui/components/label";
|
|
37
|
+
import { useI18n } from "@/i18n";
|
|
38
|
+
import { usePageHeader } from "@/contexts/usePageHeader";
|
|
39
|
+
import { PluginSlot } from "@/plugins";
|
|
40
|
+
|
|
41
|
+
/* ------------------------------------------------------------------ */
|
|
42
|
+
/* Provider grouping */
|
|
43
|
+
/* ------------------------------------------------------------------ */
|
|
44
|
+
|
|
45
|
+
/** Map env-var key prefixes to a human-friendly provider name + ordering. */
|
|
46
|
+
const PROVIDER_GROUPS: { prefix: string; name: string; priority: number }[] = [
|
|
47
|
+
// NasTech Portal first
|
|
48
|
+
{ prefix: "NASTECH_", name: "NasTech Portal", priority: 0 },
|
|
49
|
+
// Then alphabetical by display name
|
|
50
|
+
{ prefix: "ANTHROPIC_", name: "Anthropic", priority: 1 },
|
|
51
|
+
{ prefix: "DASHSCOPE_", name: "DashScope (Qwen)", priority: 2 },
|
|
52
|
+
{ prefix: "NASTECH_QWEN_", name: "DashScope (Qwen)", priority: 2 },
|
|
53
|
+
{ prefix: "DEEPSEEK_", name: "DeepSeek", priority: 3 },
|
|
54
|
+
{ prefix: "GOOGLE_", name: "Gemini", priority: 4 },
|
|
55
|
+
{ prefix: "GEMINI_", name: "Gemini", priority: 4 },
|
|
56
|
+
{ prefix: "GLM_", name: "GLM / Z.AI", priority: 5 },
|
|
57
|
+
{ prefix: "ZAI_", name: "GLM / Z.AI", priority: 5 },
|
|
58
|
+
{ prefix: "Z_AI_", name: "GLM / Z.AI", priority: 5 },
|
|
59
|
+
{ prefix: "HF_", name: "Hugging Face", priority: 6 },
|
|
60
|
+
{ prefix: "KIMI_", name: "Kimi / Moonshot", priority: 7 },
|
|
61
|
+
{ prefix: "MINIMAX_CN_", name: "MiniMax (China)", priority: 9 },
|
|
62
|
+
{ prefix: "MINIMAX_", name: "MiniMax", priority: 8 },
|
|
63
|
+
{ prefix: "OPENCODE_GO_", name: "OpenCode Go", priority: 10 },
|
|
64
|
+
{ prefix: "OPENCODE_ZEN_", name: "OpenCode Zen", priority: 11 },
|
|
65
|
+
{ prefix: "OPENROUTER_", name: "OpenRouter", priority: 12 },
|
|
66
|
+
{ prefix: "XIAOMI_", name: "Xiaomi MiMo", priority: 13 },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
function getProviderGroup(key: string): string {
|
|
70
|
+
for (const g of PROVIDER_GROUPS) {
|
|
71
|
+
if (key.startsWith(g.prefix)) return g.name;
|
|
72
|
+
}
|
|
73
|
+
return "Other";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getProviderPriority(groupName: string): number {
|
|
77
|
+
const entry = PROVIDER_GROUPS.find((g) => g.name === groupName);
|
|
78
|
+
return entry?.priority ?? 99;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface ProviderGroup {
|
|
82
|
+
name: string;
|
|
83
|
+
priority: number;
|
|
84
|
+
entries: [string, EnvVarInfo][];
|
|
85
|
+
hasAnySet: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const CATEGORY_META_ICONS: Record<string, typeof KeyRound> = {
|
|
89
|
+
provider: Zap,
|
|
90
|
+
tool: KeyRound,
|
|
91
|
+
messaging: MessageSquare,
|
|
92
|
+
setting: Settings,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/* ------------------------------------------------------------------ */
|
|
96
|
+
/* EnvVarRow — single key edit row */
|
|
97
|
+
/* ------------------------------------------------------------------ */
|
|
98
|
+
|
|
99
|
+
function EnvVarRow({
|
|
100
|
+
varKey,
|
|
101
|
+
info,
|
|
102
|
+
edits,
|
|
103
|
+
setEdits,
|
|
104
|
+
revealed,
|
|
105
|
+
saving,
|
|
106
|
+
onSave,
|
|
107
|
+
onClear,
|
|
108
|
+
onReveal,
|
|
109
|
+
onCancelEdit,
|
|
110
|
+
clearDialogOpen = false,
|
|
111
|
+
compact = false,
|
|
112
|
+
}: {
|
|
113
|
+
varKey: string;
|
|
114
|
+
info: EnvVarInfo;
|
|
115
|
+
edits: Record<string, string>;
|
|
116
|
+
setEdits: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
|
117
|
+
revealed: Record<string, string>;
|
|
118
|
+
saving: string | null;
|
|
119
|
+
onSave: (key: string) => void;
|
|
120
|
+
onClear: (key: string) => void;
|
|
121
|
+
onReveal: (key: string) => void;
|
|
122
|
+
onCancelEdit: (key: string) => void;
|
|
123
|
+
clearDialogOpen?: boolean;
|
|
124
|
+
compact?: boolean;
|
|
125
|
+
}) {
|
|
126
|
+
const { t } = useI18n();
|
|
127
|
+
const isEditing = edits[varKey] !== undefined;
|
|
128
|
+
const isRevealed = !!revealed[varKey];
|
|
129
|
+
const displayValue = isRevealed
|
|
130
|
+
? revealed[varKey]
|
|
131
|
+
: (info.redacted_value ?? "---");
|
|
132
|
+
|
|
133
|
+
// Compact inline row for unset, non-editing keys (used inside provider groups)
|
|
134
|
+
if (compact && !info.is_set && !isEditing) {
|
|
135
|
+
return (
|
|
136
|
+
<div className="flex items-center justify-between gap-3 py-1.5 min-w-0 overflow-hidden text-text-secondary hover:text-foreground transition-colors">
|
|
137
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
138
|
+
<span className="font-mono-ui text-xs">
|
|
139
|
+
{varKey}
|
|
140
|
+
</span>
|
|
141
|
+
<span className="text-xs text-text-tertiary truncate hidden sm:block">
|
|
142
|
+
{info.description}
|
|
143
|
+
</span>
|
|
144
|
+
</div>
|
|
145
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
146
|
+
{info.url && (
|
|
147
|
+
<a
|
|
148
|
+
href={info.url}
|
|
149
|
+
target="_blank"
|
|
150
|
+
rel="noreferrer"
|
|
151
|
+
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
|
152
|
+
>
|
|
153
|
+
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
|
|
154
|
+
</a>
|
|
155
|
+
)}
|
|
156
|
+
<Button
|
|
157
|
+
size="sm"
|
|
158
|
+
outlined
|
|
159
|
+
prefix={<Pencil />}
|
|
160
|
+
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}
|
|
161
|
+
>
|
|
162
|
+
{t.common.set}
|
|
163
|
+
</Button>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Non-compact unset row
|
|
170
|
+
if (!info.is_set && !isEditing) {
|
|
171
|
+
return (
|
|
172
|
+
<div className="flex items-center justify-between gap-3 border border-border/50 px-4 py-2.5 min-w-0 overflow-hidden text-text-secondary hover:text-foreground transition-colors">
|
|
173
|
+
<div className="flex items-center gap-3 min-w-0">
|
|
174
|
+
<Label className="font-mono-ui text-xs">
|
|
175
|
+
{varKey}
|
|
176
|
+
</Label>
|
|
177
|
+
<span className="text-xs text-text-tertiary truncate hidden sm:block">
|
|
178
|
+
{info.description}
|
|
179
|
+
</span>
|
|
180
|
+
</div>
|
|
181
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
182
|
+
{info.url && (
|
|
183
|
+
<a
|
|
184
|
+
href={info.url}
|
|
185
|
+
target="_blank"
|
|
186
|
+
rel="noreferrer"
|
|
187
|
+
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
|
188
|
+
>
|
|
189
|
+
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
|
|
190
|
+
</a>
|
|
191
|
+
)}
|
|
192
|
+
<Button
|
|
193
|
+
size="sm"
|
|
194
|
+
outlined
|
|
195
|
+
prefix={<Pencil />}
|
|
196
|
+
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}
|
|
197
|
+
>
|
|
198
|
+
{t.common.set}
|
|
199
|
+
</Button>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Full expanded row for set keys or keys being edited
|
|
206
|
+
return (
|
|
207
|
+
<div className="grid gap-2 border border-border p-4 min-w-0 overflow-hidden">
|
|
208
|
+
<div className="flex items-center justify-between gap-2 flex-wrap">
|
|
209
|
+
<div className="flex items-center gap-2">
|
|
210
|
+
<Label className="font-mono-ui text-xs">{varKey}</Label>
|
|
211
|
+
<Badge tone={info.is_set ? "success" : "outline"}>
|
|
212
|
+
{info.is_set ? t.common.set : t.env.notSet}
|
|
213
|
+
</Badge>
|
|
214
|
+
</div>
|
|
215
|
+
{info.url && (
|
|
216
|
+
<a
|
|
217
|
+
href={info.url}
|
|
218
|
+
target="_blank"
|
|
219
|
+
rel="noreferrer"
|
|
220
|
+
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
|
221
|
+
>
|
|
222
|
+
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
|
|
223
|
+
</a>
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
<p className="text-xs text-muted-foreground">{info.description}</p>
|
|
228
|
+
|
|
229
|
+
{info.tools.length > 0 && (
|
|
230
|
+
<div className="flex flex-wrap gap-1">
|
|
231
|
+
{info.tools.map((tool) => (
|
|
232
|
+
<Badge
|
|
233
|
+
key={tool}
|
|
234
|
+
tone="secondary"
|
|
235
|
+
className="text-xs py-0 px-1.5"
|
|
236
|
+
>
|
|
237
|
+
{tool}
|
|
238
|
+
</Badge>
|
|
239
|
+
))}
|
|
240
|
+
</div>
|
|
241
|
+
)}
|
|
242
|
+
|
|
243
|
+
{!isEditing && (
|
|
244
|
+
<div className="flex items-center gap-2">
|
|
245
|
+
<div
|
|
246
|
+
className={`flex-1 border border-border px-3 py-2 font-mono-ui text-xs ${
|
|
247
|
+
isRevealed
|
|
248
|
+
? "bg-background text-foreground select-all"
|
|
249
|
+
: "bg-muted/30 text-muted-foreground"
|
|
250
|
+
}`}
|
|
251
|
+
>
|
|
252
|
+
{info.is_set ? displayValue : "---"}
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
{info.is_set && (
|
|
256
|
+
<Button
|
|
257
|
+
ghost
|
|
258
|
+
size="icon"
|
|
259
|
+
onClick={() => onReveal(varKey)}
|
|
260
|
+
title={isRevealed ? t.env.hideValue : t.env.showValue}
|
|
261
|
+
aria-label={isRevealed ? `Hide ${varKey}` : `Reveal ${varKey}`}
|
|
262
|
+
>
|
|
263
|
+
{isRevealed ? <EyeOff /> : <Eye />}
|
|
264
|
+
</Button>
|
|
265
|
+
)}
|
|
266
|
+
|
|
267
|
+
<Button
|
|
268
|
+
size="sm"
|
|
269
|
+
outlined
|
|
270
|
+
prefix={<Pencil />}
|
|
271
|
+
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}
|
|
272
|
+
>
|
|
273
|
+
{info.is_set ? t.common.replace : t.common.set}
|
|
274
|
+
</Button>
|
|
275
|
+
|
|
276
|
+
{info.is_set && (
|
|
277
|
+
<Button
|
|
278
|
+
size="sm"
|
|
279
|
+
outlined
|
|
280
|
+
destructive
|
|
281
|
+
prefix={<Trash2 />}
|
|
282
|
+
onClick={() => onClear(varKey)}
|
|
283
|
+
disabled={saving === varKey || clearDialogOpen}
|
|
284
|
+
>
|
|
285
|
+
{saving === varKey ? "..." : t.common.clear}
|
|
286
|
+
</Button>
|
|
287
|
+
)}
|
|
288
|
+
</div>
|
|
289
|
+
)}
|
|
290
|
+
|
|
291
|
+
{isEditing && (
|
|
292
|
+
<div className="flex items-center gap-2">
|
|
293
|
+
<Input
|
|
294
|
+
autoFocus
|
|
295
|
+
type="text"
|
|
296
|
+
value={edits[varKey]}
|
|
297
|
+
onChange={(e) =>
|
|
298
|
+
setEdits((prev) => ({ ...prev, [varKey]: e.target.value }))
|
|
299
|
+
}
|
|
300
|
+
placeholder={
|
|
301
|
+
info.is_set
|
|
302
|
+
? t.env.replaceCurrentValue.replace(
|
|
303
|
+
"{preview}",
|
|
304
|
+
info.redacted_value ?? "---",
|
|
305
|
+
)
|
|
306
|
+
: t.env.enterValue
|
|
307
|
+
}
|
|
308
|
+
className="flex-1 font-mono-ui text-xs"
|
|
309
|
+
/>
|
|
310
|
+
<Button
|
|
311
|
+
size="sm"
|
|
312
|
+
onClick={() => onSave(varKey)}
|
|
313
|
+
prefix={<Save />}
|
|
314
|
+
disabled={saving === varKey || !edits[varKey]}
|
|
315
|
+
>
|
|
316
|
+
{saving === varKey ? "..." : t.common.save}
|
|
317
|
+
</Button>
|
|
318
|
+
<Button
|
|
319
|
+
size="sm"
|
|
320
|
+
outlined
|
|
321
|
+
prefix={<X />}
|
|
322
|
+
onClick={() => onCancelEdit(varKey)}
|
|
323
|
+
>
|
|
324
|
+
{t.common.cancel}
|
|
325
|
+
</Button>
|
|
326
|
+
</div>
|
|
327
|
+
)}
|
|
328
|
+
</div>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/* ------------------------------------------------------------------ */
|
|
333
|
+
/* ProviderGroupCard — groups API key + base URL per provider */
|
|
334
|
+
/* ------------------------------------------------------------------ */
|
|
335
|
+
|
|
336
|
+
function ProviderGroupCard({
|
|
337
|
+
group,
|
|
338
|
+
edits,
|
|
339
|
+
setEdits,
|
|
340
|
+
revealed,
|
|
341
|
+
saving,
|
|
342
|
+
onSave,
|
|
343
|
+
onClear,
|
|
344
|
+
onReveal,
|
|
345
|
+
onCancelEdit,
|
|
346
|
+
clearDialogOpen = false,
|
|
347
|
+
}: {
|
|
348
|
+
group: ProviderGroup;
|
|
349
|
+
edits: Record<string, string>;
|
|
350
|
+
setEdits: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
|
351
|
+
revealed: Record<string, string>;
|
|
352
|
+
saving: string | null;
|
|
353
|
+
onSave: (key: string) => void;
|
|
354
|
+
onClear: (key: string) => void;
|
|
355
|
+
onReveal: (key: string) => void;
|
|
356
|
+
onCancelEdit: (key: string) => void;
|
|
357
|
+
clearDialogOpen?: boolean;
|
|
358
|
+
}) {
|
|
359
|
+
const [expanded, setExpanded] = useState(false);
|
|
360
|
+
const { t } = useI18n();
|
|
361
|
+
|
|
362
|
+
// Separate API keys from base URLs and other settings
|
|
363
|
+
const apiKeys = group.entries.filter(
|
|
364
|
+
([k]) => k.endsWith("_API_KEY") || k.endsWith("_TOKEN"),
|
|
365
|
+
);
|
|
366
|
+
const baseUrls = group.entries.filter(([k]) => k.endsWith("_BASE_URL"));
|
|
367
|
+
const other = group.entries.filter(
|
|
368
|
+
([k]) =>
|
|
369
|
+
!k.endsWith("_API_KEY") &&
|
|
370
|
+
!k.endsWith("_TOKEN") &&
|
|
371
|
+
!k.endsWith("_BASE_URL"),
|
|
372
|
+
);
|
|
373
|
+
const hasAnyConfigured = group.entries.some(([, info]) => info.is_set);
|
|
374
|
+
const configuredCount = group.entries.filter(
|
|
375
|
+
([, info]) => info.is_set,
|
|
376
|
+
).length;
|
|
377
|
+
|
|
378
|
+
// Get a representative URL for "Get key" link
|
|
379
|
+
const keyUrl = apiKeys.find(([, info]) => info.url)?.[1]?.url ?? null;
|
|
380
|
+
|
|
381
|
+
return (
|
|
382
|
+
<div className="border border-border">
|
|
383
|
+
{/* Header — always visible */}
|
|
384
|
+
<ListItem
|
|
385
|
+
onClick={() => setExpanded(!expanded)}
|
|
386
|
+
aria-expanded={expanded}
|
|
387
|
+
className="justify-between gap-3 px-4 py-3 hover:bg-primary/5"
|
|
388
|
+
>
|
|
389
|
+
<div className="flex items-center gap-3 min-w-0">
|
|
390
|
+
{expanded ? (
|
|
391
|
+
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
|
392
|
+
) : (
|
|
393
|
+
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
|
394
|
+
)}
|
|
395
|
+
<span className="font-semibold text-sm tracking-wide">
|
|
396
|
+
{group.name === "Other" ? t.common.other : group.name}
|
|
397
|
+
</span>
|
|
398
|
+
{hasAnyConfigured && (
|
|
399
|
+
<Badge tone="success" className="text-xs">
|
|
400
|
+
{configuredCount} {t.common.set.toLowerCase()}
|
|
401
|
+
</Badge>
|
|
402
|
+
)}
|
|
403
|
+
</div>
|
|
404
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
405
|
+
{keyUrl && (
|
|
406
|
+
<a
|
|
407
|
+
href={keyUrl}
|
|
408
|
+
target="_blank"
|
|
409
|
+
rel="noreferrer"
|
|
410
|
+
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
|
411
|
+
onClick={(e) => e.stopPropagation()}
|
|
412
|
+
>
|
|
413
|
+
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
|
|
414
|
+
</a>
|
|
415
|
+
)}
|
|
416
|
+
<span className="text-xs text-text-tertiary">
|
|
417
|
+
{t.env.keysCount
|
|
418
|
+
.replace("{count}", String(group.entries.length))
|
|
419
|
+
.replace("{s}", group.entries.length !== 1 ? "s" : "")}
|
|
420
|
+
</span>
|
|
421
|
+
</div>
|
|
422
|
+
</ListItem>
|
|
423
|
+
|
|
424
|
+
{expanded && (
|
|
425
|
+
<div className="border-t border-border px-4 py-3 grid gap-2">
|
|
426
|
+
{apiKeys.map(([key, info]) => (
|
|
427
|
+
<EnvVarRow
|
|
428
|
+
key={key}
|
|
429
|
+
varKey={key}
|
|
430
|
+
info={info}
|
|
431
|
+
compact
|
|
432
|
+
edits={edits}
|
|
433
|
+
setEdits={setEdits}
|
|
434
|
+
revealed={revealed}
|
|
435
|
+
saving={saving}
|
|
436
|
+
onSave={onSave}
|
|
437
|
+
onClear={onClear}
|
|
438
|
+
onReveal={onReveal}
|
|
439
|
+
onCancelEdit={onCancelEdit}
|
|
440
|
+
clearDialogOpen={clearDialogOpen}
|
|
441
|
+
/>
|
|
442
|
+
))}
|
|
443
|
+
|
|
444
|
+
{baseUrls.map(([key, info]) => (
|
|
445
|
+
<EnvVarRow
|
|
446
|
+
key={key}
|
|
447
|
+
varKey={key}
|
|
448
|
+
info={info}
|
|
449
|
+
compact
|
|
450
|
+
edits={edits}
|
|
451
|
+
setEdits={setEdits}
|
|
452
|
+
revealed={revealed}
|
|
453
|
+
saving={saving}
|
|
454
|
+
onSave={onSave}
|
|
455
|
+
onClear={onClear}
|
|
456
|
+
onReveal={onReveal}
|
|
457
|
+
onCancelEdit={onCancelEdit}
|
|
458
|
+
clearDialogOpen={clearDialogOpen}
|
|
459
|
+
/>
|
|
460
|
+
))}
|
|
461
|
+
|
|
462
|
+
{other.map(([key, info]) => (
|
|
463
|
+
<EnvVarRow
|
|
464
|
+
key={key}
|
|
465
|
+
varKey={key}
|
|
466
|
+
info={info}
|
|
467
|
+
compact
|
|
468
|
+
edits={edits}
|
|
469
|
+
setEdits={setEdits}
|
|
470
|
+
revealed={revealed}
|
|
471
|
+
saving={saving}
|
|
472
|
+
onSave={onSave}
|
|
473
|
+
onClear={onClear}
|
|
474
|
+
onReveal={onReveal}
|
|
475
|
+
onCancelEdit={onCancelEdit}
|
|
476
|
+
clearDialogOpen={clearDialogOpen}
|
|
477
|
+
/>
|
|
478
|
+
))}
|
|
479
|
+
</div>
|
|
480
|
+
)}
|
|
481
|
+
</div>
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/* ------------------------------------------------------------------ */
|
|
486
|
+
/* Main page */
|
|
487
|
+
/* ------------------------------------------------------------------ */
|
|
488
|
+
|
|
489
|
+
export default function EnvPage() {
|
|
490
|
+
const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null);
|
|
491
|
+
const [edits, setEdits] = useState<Record<string, string>>({});
|
|
492
|
+
const [revealed, setRevealed] = useState<Record<string, string>>({});
|
|
493
|
+
const [saving, setSaving] = useState<string | null>(null);
|
|
494
|
+
const [showAdvanced, setShowAdvanced] = useState(true); // Show all providers by default
|
|
495
|
+
const { toast, showToast } = useToast();
|
|
496
|
+
const { t } = useI18n();
|
|
497
|
+
const { setAfterTitle } = usePageHeader();
|
|
498
|
+
|
|
499
|
+
useEffect(() => {
|
|
500
|
+
api
|
|
501
|
+
.getEnvVars()
|
|
502
|
+
.then(setVars)
|
|
503
|
+
.catch(() => {});
|
|
504
|
+
}, []);
|
|
505
|
+
|
|
506
|
+
// Scroll-to sub-nav in the page header
|
|
507
|
+
const sections = useMemo(() => {
|
|
508
|
+
const items: { id: string; label: string }[] = [
|
|
509
|
+
{ id: "section-oauth", label: "OAuth" },
|
|
510
|
+
{ id: "section-providers", label: "Providers" },
|
|
511
|
+
];
|
|
512
|
+
if (vars) {
|
|
513
|
+
const categories = ["tool", "messaging", "setting"];
|
|
514
|
+
const CATEGORY_LABELS: Record<string, string> = {
|
|
515
|
+
tool: "Tools",
|
|
516
|
+
messaging: "Messaging",
|
|
517
|
+
setting: "Settings",
|
|
518
|
+
};
|
|
519
|
+
for (const cat of categories) {
|
|
520
|
+
const hasEntries = Object.values(vars).some(
|
|
521
|
+
(info) => info.category === cat,
|
|
522
|
+
);
|
|
523
|
+
if (hasEntries) {
|
|
524
|
+
items.push({ id: `section-${cat}`, label: CATEGORY_LABELS[cat] ?? cat });
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return items;
|
|
529
|
+
}, [vars]);
|
|
530
|
+
|
|
531
|
+
useLayoutEffect(() => {
|
|
532
|
+
if (!vars) {
|
|
533
|
+
setAfterTitle(null);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
const scrollTo = (id: string) => {
|
|
537
|
+
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
538
|
+
};
|
|
539
|
+
setAfterTitle(
|
|
540
|
+
<nav
|
|
541
|
+
className="flex shrink-0 flex-nowrap items-center gap-1"
|
|
542
|
+
aria-label="Jump to section"
|
|
543
|
+
>
|
|
544
|
+
{sections.map((s) => (
|
|
545
|
+
<button
|
|
546
|
+
key={s.id}
|
|
547
|
+
type="button"
|
|
548
|
+
onClick={() => scrollTo(s.id)}
|
|
549
|
+
className="shrink-0 cursor-pointer px-2 py-0.5 font-mondwest text-display text-xs tracking-wider text-text-secondary hover:text-foreground border border-border/50 hover:border-foreground/30 transition-colors"
|
|
550
|
+
>
|
|
551
|
+
{s.label}
|
|
552
|
+
</button>
|
|
553
|
+
))}
|
|
554
|
+
</nav>,
|
|
555
|
+
);
|
|
556
|
+
return () => {
|
|
557
|
+
setAfterTitle(null);
|
|
558
|
+
};
|
|
559
|
+
}, [vars, sections, setAfterTitle]);
|
|
560
|
+
|
|
561
|
+
const handleSave = async (key: string) => {
|
|
562
|
+
const value = edits[key];
|
|
563
|
+
if (!value) return;
|
|
564
|
+
setSaving(key);
|
|
565
|
+
try {
|
|
566
|
+
await api.setEnvVar(key, value);
|
|
567
|
+
setVars((prev) =>
|
|
568
|
+
prev
|
|
569
|
+
? {
|
|
570
|
+
...prev,
|
|
571
|
+
[key]: {
|
|
572
|
+
...prev[key],
|
|
573
|
+
is_set: true,
|
|
574
|
+
redacted_value: value.slice(0, 4) + "..." + value.slice(-4),
|
|
575
|
+
},
|
|
576
|
+
}
|
|
577
|
+
: prev,
|
|
578
|
+
);
|
|
579
|
+
setEdits((prev) => {
|
|
580
|
+
const n = { ...prev };
|
|
581
|
+
delete n[key];
|
|
582
|
+
return n;
|
|
583
|
+
});
|
|
584
|
+
setRevealed((prev) => {
|
|
585
|
+
const n = { ...prev };
|
|
586
|
+
delete n[key];
|
|
587
|
+
return n;
|
|
588
|
+
});
|
|
589
|
+
showToast(`${key} ${t.common.save.toLowerCase()}d`, "success");
|
|
590
|
+
} catch (e) {
|
|
591
|
+
showToast(`${t.config.failedToSave} ${key}: ${e}`, "error");
|
|
592
|
+
} finally {
|
|
593
|
+
setSaving(null);
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
const keyClear = useConfirmDelete({
|
|
598
|
+
onDelete: useCallback(
|
|
599
|
+
async (key: string) => {
|
|
600
|
+
setSaving(key);
|
|
601
|
+
try {
|
|
602
|
+
await api.deleteEnvVar(key);
|
|
603
|
+
setVars((prev) =>
|
|
604
|
+
prev
|
|
605
|
+
? {
|
|
606
|
+
...prev,
|
|
607
|
+
[key]: { ...prev[key], is_set: false, redacted_value: null },
|
|
608
|
+
}
|
|
609
|
+
: prev,
|
|
610
|
+
);
|
|
611
|
+
setEdits((prev) => {
|
|
612
|
+
const n = { ...prev };
|
|
613
|
+
delete n[key];
|
|
614
|
+
return n;
|
|
615
|
+
});
|
|
616
|
+
setRevealed((prev) => {
|
|
617
|
+
const n = { ...prev };
|
|
618
|
+
delete n[key];
|
|
619
|
+
return n;
|
|
620
|
+
});
|
|
621
|
+
showToast(`${key} ${t.common.removed}`, "success");
|
|
622
|
+
} catch (e) {
|
|
623
|
+
showToast(`${t.common.failedToRemove} ${key}: ${e}`, "error");
|
|
624
|
+
throw e;
|
|
625
|
+
} finally {
|
|
626
|
+
setSaving(null);
|
|
627
|
+
}
|
|
628
|
+
},
|
|
629
|
+
[showToast, t.common.removed, t.common.failedToRemove],
|
|
630
|
+
),
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
const handleReveal = async (key: string) => {
|
|
634
|
+
if (revealed[key]) {
|
|
635
|
+
setRevealed((prev) => {
|
|
636
|
+
const n = { ...prev };
|
|
637
|
+
delete n[key];
|
|
638
|
+
return n;
|
|
639
|
+
});
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
try {
|
|
643
|
+
const resp = await api.revealEnvVar(key);
|
|
644
|
+
setRevealed((prev) => ({ ...prev, [key]: resp.value }));
|
|
645
|
+
} catch {
|
|
646
|
+
showToast(`${t.common.failedToReveal} ${key}`, "error");
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
const cancelEdit = (key: string) => {
|
|
651
|
+
setEdits((prev) => {
|
|
652
|
+
const n = { ...prev };
|
|
653
|
+
delete n[key];
|
|
654
|
+
return n;
|
|
655
|
+
});
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
/* ---- Build provider groups ---- */
|
|
659
|
+
const { providerGroups, nonProviderGrouped } = useMemo(() => {
|
|
660
|
+
if (!vars) return { providerGroups: [], nonProviderGrouped: [] };
|
|
661
|
+
|
|
662
|
+
const providerEntries = Object.entries(vars).filter(
|
|
663
|
+
([, info]) =>
|
|
664
|
+
info.category === "provider" && (showAdvanced || !info.advanced),
|
|
665
|
+
);
|
|
666
|
+
|
|
667
|
+
// Group by provider
|
|
668
|
+
const groupMap = new Map<string, [string, EnvVarInfo][]>();
|
|
669
|
+
for (const entry of providerEntries) {
|
|
670
|
+
const groupName = getProviderGroup(entry[0]);
|
|
671
|
+
if (!groupMap.has(groupName)) groupMap.set(groupName, []);
|
|
672
|
+
groupMap.get(groupName)!.push(entry);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const groups: ProviderGroup[] = Array.from(groupMap.entries())
|
|
676
|
+
.map(([name, entries]) => ({
|
|
677
|
+
name,
|
|
678
|
+
priority: getProviderPriority(name),
|
|
679
|
+
entries,
|
|
680
|
+
hasAnySet: entries.some(([, info]) => info.is_set),
|
|
681
|
+
}))
|
|
682
|
+
.sort((a, b) => a.priority - b.priority);
|
|
683
|
+
|
|
684
|
+
// Non-provider categories — use translated labels
|
|
685
|
+
const CATEGORY_META_LABELS: Record<string, string> = {
|
|
686
|
+
tool: t.app.nav.keys,
|
|
687
|
+
messaging: t.common.messaging,
|
|
688
|
+
setting: t.app.nav.config,
|
|
689
|
+
};
|
|
690
|
+
const otherCategories = ["tool", "messaging", "setting"];
|
|
691
|
+
const nonProvider = otherCategories.map((cat) => {
|
|
692
|
+
const entries = Object.entries(vars).filter(
|
|
693
|
+
([, info]) => info.category === cat && (showAdvanced || !info.advanced),
|
|
694
|
+
);
|
|
695
|
+
const setEntries = entries.filter(([, info]) => info.is_set);
|
|
696
|
+
const unsetEntries = entries.filter(([, info]) => !info.is_set);
|
|
697
|
+
return {
|
|
698
|
+
label: CATEGORY_META_LABELS[cat] ?? cat,
|
|
699
|
+
icon: CATEGORY_META_ICONS[cat] ?? KeyRound,
|
|
700
|
+
category: cat,
|
|
701
|
+
setEntries,
|
|
702
|
+
unsetEntries,
|
|
703
|
+
totalEntries: entries.length,
|
|
704
|
+
};
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
return { providerGroups: groups, nonProviderGrouped: nonProvider };
|
|
708
|
+
}, [vars, showAdvanced, t]);
|
|
709
|
+
|
|
710
|
+
if (!vars) {
|
|
711
|
+
return (
|
|
712
|
+
<div className="flex items-center justify-center py-24">
|
|
713
|
+
<Spinner className="text-2xl text-primary" />
|
|
714
|
+
</div>
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const totalProviders = providerGroups.length;
|
|
719
|
+
const configuredProviders = providerGroups.filter((g) => g.hasAnySet).length;
|
|
720
|
+
|
|
721
|
+
const pendingClearKey = keyClear.pendingId;
|
|
722
|
+
const pendingKeyDescription =
|
|
723
|
+
pendingClearKey && vars ? vars[pendingClearKey]?.description : undefined;
|
|
724
|
+
|
|
725
|
+
return (
|
|
726
|
+
<div className="flex flex-col gap-6">
|
|
727
|
+
<PluginSlot name="env:top" />
|
|
728
|
+
<Toast toast={toast} />
|
|
729
|
+
|
|
730
|
+
<DeleteConfirmDialog
|
|
731
|
+
open={keyClear.isOpen}
|
|
732
|
+
onCancel={keyClear.cancel}
|
|
733
|
+
onConfirm={keyClear.confirm}
|
|
734
|
+
title={t.env.confirmClearTitle}
|
|
735
|
+
description={
|
|
736
|
+
pendingClearKey
|
|
737
|
+
? `${pendingClearKey}${pendingKeyDescription ? ` — ${pendingKeyDescription}` : ""}. ${t.env.confirmClearMessage}`
|
|
738
|
+
: t.env.confirmClearMessage
|
|
739
|
+
}
|
|
740
|
+
loading={keyClear.isDeleting}
|
|
741
|
+
/>
|
|
742
|
+
|
|
743
|
+
<div className="flex items-center justify-between">
|
|
744
|
+
<div className="flex flex-col gap-1">
|
|
745
|
+
<p className="text-sm text-muted-foreground">
|
|
746
|
+
{t.env.description} <code>~/.nastech/.env</code>
|
|
747
|
+
</p>
|
|
748
|
+
<p className="text-xs text-text-tertiary">
|
|
749
|
+
{t.env.changesNote}
|
|
750
|
+
</p>
|
|
751
|
+
</div>
|
|
752
|
+
<Button
|
|
753
|
+
size="sm"
|
|
754
|
+
outlined
|
|
755
|
+
onClick={() => setShowAdvanced(!showAdvanced)}
|
|
756
|
+
>
|
|
757
|
+
{showAdvanced ? t.env.hideAdvanced : t.env.showAdvanced}
|
|
758
|
+
</Button>
|
|
759
|
+
</div>
|
|
760
|
+
|
|
761
|
+
<div id="section-oauth">
|
|
762
|
+
<OAuthProvidersCard
|
|
763
|
+
onError={(msg) => showToast(msg, "error")}
|
|
764
|
+
onSuccess={(msg) => showToast(msg, "success")}
|
|
765
|
+
/>
|
|
766
|
+
</div>
|
|
767
|
+
|
|
768
|
+
<Card id="section-providers">
|
|
769
|
+
<CardHeader className="border-b border-border bg-card">
|
|
770
|
+
<div className="flex items-center gap-2">
|
|
771
|
+
<Zap className="h-5 w-5 text-muted-foreground" />
|
|
772
|
+
<CardTitle className="text-base">{t.env.llmProviders}</CardTitle>
|
|
773
|
+
</div>
|
|
774
|
+
<CardDescription>
|
|
775
|
+
{t.env.providersConfigured
|
|
776
|
+
.replace("{configured}", String(configuredProviders))
|
|
777
|
+
.replace("{total}", String(totalProviders))}
|
|
778
|
+
</CardDescription>
|
|
779
|
+
</CardHeader>
|
|
780
|
+
|
|
781
|
+
<CardContent className="grid gap-0 p-0">
|
|
782
|
+
{providerGroups.map((group) => (
|
|
783
|
+
<ProviderGroupCard
|
|
784
|
+
key={group.name}
|
|
785
|
+
group={group}
|
|
786
|
+
edits={edits}
|
|
787
|
+
setEdits={setEdits}
|
|
788
|
+
revealed={revealed}
|
|
789
|
+
saving={saving}
|
|
790
|
+
onSave={handleSave}
|
|
791
|
+
onClear={keyClear.requestDelete}
|
|
792
|
+
onReveal={handleReveal}
|
|
793
|
+
onCancelEdit={cancelEdit}
|
|
794
|
+
clearDialogOpen={keyClear.isOpen}
|
|
795
|
+
/>
|
|
796
|
+
))}
|
|
797
|
+
</CardContent>
|
|
798
|
+
</Card>
|
|
799
|
+
|
|
800
|
+
{nonProviderGrouped.map((section) => {
|
|
801
|
+
if (section.totalEntries === 0) return null;
|
|
802
|
+
|
|
803
|
+
return (
|
|
804
|
+
<EnvCategoryCard
|
|
805
|
+
key={section.category}
|
|
806
|
+
section={section}
|
|
807
|
+
edits={edits}
|
|
808
|
+
setEdits={setEdits}
|
|
809
|
+
revealed={revealed}
|
|
810
|
+
saving={saving}
|
|
811
|
+
onSave={handleSave}
|
|
812
|
+
onClear={keyClear.requestDelete}
|
|
813
|
+
onReveal={handleReveal}
|
|
814
|
+
onCancelEdit={cancelEdit}
|
|
815
|
+
clearDialogOpen={keyClear.isOpen}
|
|
816
|
+
/>
|
|
817
|
+
);
|
|
818
|
+
})}
|
|
819
|
+
<PluginSlot name="env:bottom" />
|
|
820
|
+
</div>
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/* ------------------------------------------------------------------ */
|
|
825
|
+
/* EnvCategoryCard — keys / messaging / settings sections */
|
|
826
|
+
/* ------------------------------------------------------------------ */
|
|
827
|
+
|
|
828
|
+
function EnvCategoryCard({
|
|
829
|
+
section,
|
|
830
|
+
edits,
|
|
831
|
+
setEdits,
|
|
832
|
+
revealed,
|
|
833
|
+
saving,
|
|
834
|
+
onSave,
|
|
835
|
+
onClear,
|
|
836
|
+
onReveal,
|
|
837
|
+
onCancelEdit,
|
|
838
|
+
clearDialogOpen = false,
|
|
839
|
+
}: {
|
|
840
|
+
section: {
|
|
841
|
+
category: string;
|
|
842
|
+
icon: React.ComponentType<{ className?: string }>;
|
|
843
|
+
label: string;
|
|
844
|
+
setEntries: [string, EnvVarInfo][];
|
|
845
|
+
totalEntries: number;
|
|
846
|
+
unsetEntries: [string, EnvVarInfo][];
|
|
847
|
+
};
|
|
848
|
+
edits: Record<string, string>;
|
|
849
|
+
setEdits: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
|
850
|
+
revealed: Record<string, string>;
|
|
851
|
+
saving: string | null;
|
|
852
|
+
onSave: (key: string) => void;
|
|
853
|
+
onClear: (key: string) => void;
|
|
854
|
+
onReveal: (key: string) => void;
|
|
855
|
+
onCancelEdit: (key: string) => void;
|
|
856
|
+
clearDialogOpen?: boolean;
|
|
857
|
+
}) {
|
|
858
|
+
const noneConfigured = section.setEntries.length === 0;
|
|
859
|
+
const [showAll, setShowAll] = useState(noneConfigured);
|
|
860
|
+
const { t } = useI18n();
|
|
861
|
+
const Icon = section.icon;
|
|
862
|
+
const hasContent = section.setEntries.length > 0 || showAll;
|
|
863
|
+
const rowProps = {
|
|
864
|
+
edits,
|
|
865
|
+
setEdits,
|
|
866
|
+
revealed,
|
|
867
|
+
saving,
|
|
868
|
+
onSave,
|
|
869
|
+
onClear,
|
|
870
|
+
onReveal,
|
|
871
|
+
onCancelEdit,
|
|
872
|
+
clearDialogOpen,
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
return (
|
|
876
|
+
<Card id={`section-${section.category}`}>
|
|
877
|
+
<CardHeader
|
|
878
|
+
className={`bg-card${hasContent ? " border-b border-border" : ""}`}
|
|
879
|
+
>
|
|
880
|
+
<div className="flex items-center justify-between gap-3">
|
|
881
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
882
|
+
<Icon className="h-5 w-5 shrink-0 text-muted-foreground" />
|
|
883
|
+
<CardTitle className="text-base">{section.label}</CardTitle>
|
|
884
|
+
</div>
|
|
885
|
+
|
|
886
|
+
{section.unsetEntries.length > 0 && (
|
|
887
|
+
<button
|
|
888
|
+
type="button"
|
|
889
|
+
onClick={() => setShowAll((open) => !open)}
|
|
890
|
+
aria-expanded={showAll}
|
|
891
|
+
className="shrink-0 cursor-pointer border-0 bg-transparent p-0 font-mondwest text-xs tracking-[0.08em] text-text-secondary transition-colors hover:text-foreground"
|
|
892
|
+
>
|
|
893
|
+
{showAll ? t.env.showLess : t.env.showMore}
|
|
894
|
+
</button>
|
|
895
|
+
)}
|
|
896
|
+
</div>
|
|
897
|
+
|
|
898
|
+
<CardDescription>
|
|
899
|
+
{section.setEntries.length} {t.common.of} {section.totalEntries}{" "}
|
|
900
|
+
{t.common.configured}
|
|
901
|
+
</CardDescription>
|
|
902
|
+
</CardHeader>
|
|
903
|
+
|
|
904
|
+
{hasContent && (
|
|
905
|
+
<CardContent className="grid gap-3 overflow-hidden pt-4">
|
|
906
|
+
{section.setEntries.map(([key, info]) => (
|
|
907
|
+
<EnvVarRow key={key} varKey={key} info={info} {...rowProps} />
|
|
908
|
+
))}
|
|
909
|
+
|
|
910
|
+
{showAll &&
|
|
911
|
+
section.unsetEntries.map(([key, info]) => (
|
|
912
|
+
<EnvVarRow key={key} varKey={key} info={info} {...rowProps} />
|
|
913
|
+
))}
|
|
914
|
+
</CardContent>
|
|
915
|
+
)}
|
|
916
|
+
</Card>
|
|
917
|
+
);
|
|
918
|
+
}
|