@raingor/pi-web-switch 0.7.1 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/pi-reader.ts +77 -6
- package/src/components/providers/ProvidersModelsPage.tsx +266 -108
- package/src/components/settings/PackageBrowser.tsx +222 -0
- package/src/components/settings/SettingsPage.tsx +120 -60
- package/src/components/speedtest/ModelSpeedTestPage.tsx +138 -4
- package/src/components/subagents/SubagentsPage.tsx +30 -21
- package/src/data/changelog.ts +15 -0
- package/src/data/recommended-packages.ts +20 -0
- package/src/index.css +1 -2
- package/src/lib/translations/en.ts +52 -8
- package/src/lib/translations/ja.ts +52 -8
- package/src/lib/translations/zh-CN.ts +52 -8
- package/src/lib/translations/zh-TW.ts +52 -8
- package/src/store/config-store.ts +3 -1
- package/src/types/index.ts +14 -0
- package/vite.config.ts +11 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
|
+
import { Search, Plus, Check, Loader2, ExternalLink, Sparkles } from "lucide-react";
|
|
3
|
+
import { Modal } from "@/components/ui/Modal";
|
|
4
|
+
import { useTranslation } from "@/lib/i18n";
|
|
5
|
+
import { RECOMMENDED_PACKAGES } from "@/data/recommended-packages";
|
|
6
|
+
|
|
7
|
+
interface PackageSearchResult {
|
|
8
|
+
name: string;
|
|
9
|
+
description: string;
|
|
10
|
+
version: string;
|
|
11
|
+
downloads: number;
|
|
12
|
+
link: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type PackageFilter = "all" | "installed" | "available";
|
|
16
|
+
type Tab = "recommended" | "search";
|
|
17
|
+
|
|
18
|
+
interface PackageBrowserProps {
|
|
19
|
+
open: boolean;
|
|
20
|
+
onClose: () => void;
|
|
21
|
+
installed: Set<string>; // ids like "npm:pkg-name"
|
|
22
|
+
onInstall: (id: string) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One package row with an install / installed control. */
|
|
26
|
+
function PackageRow({
|
|
27
|
+
name,
|
|
28
|
+
description,
|
|
29
|
+
link,
|
|
30
|
+
installed,
|
|
31
|
+
onInstall,
|
|
32
|
+
}: {
|
|
33
|
+
name: string;
|
|
34
|
+
description: string;
|
|
35
|
+
link?: string;
|
|
36
|
+
installed: boolean;
|
|
37
|
+
onInstall: () => void;
|
|
38
|
+
}) {
|
|
39
|
+
const { t } = useTranslation();
|
|
40
|
+
return (
|
|
41
|
+
<div
|
|
42
|
+
className="flex items-center gap-3 rounded-lg border px-3 py-2"
|
|
43
|
+
style={{ borderColor: "var(--card-border)", backgroundColor: "var(--card-bg)" }}
|
|
44
|
+
>
|
|
45
|
+
<div className="min-w-0 flex-1">
|
|
46
|
+
<div className="flex items-center gap-2">
|
|
47
|
+
<span className="truncate text-sm font-medium" style={{ color: "var(--page-text)" }}>{name}</span>
|
|
48
|
+
<a
|
|
49
|
+
href={link ?? `https://www.npmjs.com/package/${name}`}
|
|
50
|
+
target="_blank"
|
|
51
|
+
rel="noreferrer"
|
|
52
|
+
className="shrink-0"
|
|
53
|
+
style={{ color: "var(--subtle-text)" }}
|
|
54
|
+
title={t("settings.view_on_npm")}
|
|
55
|
+
>
|
|
56
|
+
<ExternalLink className="h-3 w-3" />
|
|
57
|
+
</a>
|
|
58
|
+
</div>
|
|
59
|
+
{description && (
|
|
60
|
+
<p className="truncate text-xs" style={{ color: "var(--muted-text)" }}>{description}</p>
|
|
61
|
+
)}
|
|
62
|
+
</div>
|
|
63
|
+
{installed ? (
|
|
64
|
+
<span className="flex shrink-0 items-center gap-1 rounded-lg border px-3 py-1.5 text-xs font-medium"
|
|
65
|
+
style={{ borderColor: "rgba(16,185,129,0.4)", color: "#10b981" }}>
|
|
66
|
+
<Check className="h-3.5 w-3.5" />
|
|
67
|
+
{t("settings.installed")}
|
|
68
|
+
</span>
|
|
69
|
+
) : (
|
|
70
|
+
<button
|
|
71
|
+
onClick={onInstall}
|
|
72
|
+
className="flex shrink-0 items-center gap-1 rounded-lg border border-blue-600/50 bg-blue-600/10 px-3 py-1.5 text-xs font-medium text-blue-400 hover:bg-blue-600/20"
|
|
73
|
+
>
|
|
74
|
+
<Plus className="h-3.5 w-3.5" />
|
|
75
|
+
{t("settings.install")}
|
|
76
|
+
</button>
|
|
77
|
+
)}
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Package browser modal with two tabs:
|
|
84
|
+
* - Recommended: the curated list from src/data/recommended-packages.ts
|
|
85
|
+
* - Search: fuzzy npm-registry search for any pi package
|
|
86
|
+
* Installing writes "npm:<name>" into settings.packages.
|
|
87
|
+
*/
|
|
88
|
+
export function PackageBrowser({ open, onClose, installed, onInstall }: PackageBrowserProps) {
|
|
89
|
+
const { t } = useTranslation();
|
|
90
|
+
const [tab, setTab] = useState<Tab>("recommended");
|
|
91
|
+
const [query, setQuery] = useState("");
|
|
92
|
+
const [results, setResults] = useState<PackageSearchResult[]>([]);
|
|
93
|
+
const [loading, setLoading] = useState(false);
|
|
94
|
+
const [justAdded, setJustAdded] = useState<Set<string>>(new Set());
|
|
95
|
+
const [filter, setFilter] = useState<PackageFilter>("all");
|
|
96
|
+
const debounceRef = useRef<number | undefined>(undefined);
|
|
97
|
+
|
|
98
|
+
const runSearch = useCallback((q: string) => {
|
|
99
|
+
setLoading(true);
|
|
100
|
+
fetch(`/api/pi/packages/search?q=${encodeURIComponent(q)}`)
|
|
101
|
+
.then((r) => r.json())
|
|
102
|
+
.then((d: { results?: PackageSearchResult[] }) => setResults(d.results ?? []))
|
|
103
|
+
.catch(() => setResults([]))
|
|
104
|
+
.finally(() => setLoading(false));
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
107
|
+
// Load popular packages the first time the Search tab is opened; debounce typing.
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!open || tab !== "search") return;
|
|
110
|
+
window.clearTimeout(debounceRef.current);
|
|
111
|
+
debounceRef.current = window.setTimeout(() => runSearch(query), 350);
|
|
112
|
+
return () => window.clearTimeout(debounceRef.current);
|
|
113
|
+
}, [query, open, tab, runSearch]);
|
|
114
|
+
|
|
115
|
+
const isInstalledId = (name: string) => {
|
|
116
|
+
const id = `npm:${name}`;
|
|
117
|
+
return installed.has(id) || justAdded.has(id);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const handleInstall = (id: string) => {
|
|
121
|
+
onInstall(id);
|
|
122
|
+
setJustAdded((prev) => new Set(prev).add(id));
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Search-tab results after the installed/available filter.
|
|
126
|
+
const filtered = results.filter((pkg) => {
|
|
127
|
+
if (filter === "installed") return isInstalledId(pkg.name);
|
|
128
|
+
if (filter === "available") return !isInstalledId(pkg.name);
|
|
129
|
+
return true;
|
|
130
|
+
});
|
|
131
|
+
const installedCount = results.filter((p) => isInstalledId(p.name)).length;
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<Modal open={open} onClose={onClose} title={t("settings.browse_packages")} size="lg">
|
|
135
|
+
<div className="space-y-4">
|
|
136
|
+
{/* Tabs */}
|
|
137
|
+
<div className="flex gap-1 rounded-lg p-1" style={{ backgroundColor: "var(--card-bg)" }}>
|
|
138
|
+
{(["recommended", "search"] as Tab[]).map((tKey) => (
|
|
139
|
+
<button
|
|
140
|
+
key={tKey}
|
|
141
|
+
onClick={() => setTab(tKey)}
|
|
142
|
+
className={`flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${tab === tKey ? "bg-blue-600/15 text-blue-400" : "text-gray-400 hover:text-gray-200"}`}
|
|
143
|
+
>
|
|
144
|
+
{tKey === "recommended" ? <Sparkles className="h-3.5 w-3.5" /> : <Search className="h-3.5 w-3.5" />}
|
|
145
|
+
{t(`settings.tab_${tKey}`)}
|
|
146
|
+
</button>
|
|
147
|
+
))}
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
{tab === "recommended" ? (
|
|
151
|
+
<div className="max-h-[55vh] space-y-1.5 overflow-y-auto">
|
|
152
|
+
{RECOMMENDED_PACKAGES.map((pkg) => (
|
|
153
|
+
<PackageRow
|
|
154
|
+
key={pkg.id}
|
|
155
|
+
name={pkg.name}
|
|
156
|
+
description={t(pkg.descKey)}
|
|
157
|
+
installed={installed.has(pkg.id) || justAdded.has(pkg.id)}
|
|
158
|
+
onInstall={() => handleInstall(pkg.id)}
|
|
159
|
+
/>
|
|
160
|
+
))}
|
|
161
|
+
</div>
|
|
162
|
+
) : (
|
|
163
|
+
<>
|
|
164
|
+
{/* Search box */}
|
|
165
|
+
<div className="relative">
|
|
166
|
+
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" style={{ color: "var(--subtle-text)" }} />
|
|
167
|
+
<input
|
|
168
|
+
type="text"
|
|
169
|
+
autoFocus
|
|
170
|
+
value={query}
|
|
171
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
172
|
+
placeholder={t("settings.search_packages_placeholder")}
|
|
173
|
+
className="w-full rounded-lg border py-2 pl-9 pr-3 text-sm outline-none focus:ring-1 focus:ring-blue-500"
|
|
174
|
+
style={{ backgroundColor: "var(--card-bg)", borderColor: "var(--card-border)", color: "var(--page-text)" }}
|
|
175
|
+
/>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
{/* Installed / available filter */}
|
|
179
|
+
<div className="flex gap-1 rounded-lg p-1" style={{ backgroundColor: "var(--card-bg)" }}>
|
|
180
|
+
{(["all", "available", "installed"] as PackageFilter[]).map((f) => {
|
|
181
|
+
const count = f === "all" ? results.length : f === "installed" ? installedCount : results.length - installedCount;
|
|
182
|
+
return (
|
|
183
|
+
<button
|
|
184
|
+
key={f}
|
|
185
|
+
onClick={() => setFilter(f)}
|
|
186
|
+
className={`flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${filter === f ? "bg-blue-600/15 text-blue-400" : "text-gray-400 hover:text-gray-200"}`}
|
|
187
|
+
>
|
|
188
|
+
{t(`settings.filter_${f}`)} ({count})
|
|
189
|
+
</button>
|
|
190
|
+
);
|
|
191
|
+
})}
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
{/* Results */}
|
|
195
|
+
<div className="max-h-[42vh] space-y-1.5 overflow-y-auto">
|
|
196
|
+
{loading && results.length === 0 ? (
|
|
197
|
+
<div className="flex items-center justify-center py-10">
|
|
198
|
+
<Loader2 className="h-5 w-5 animate-spin" style={{ color: "var(--muted-text)" }} />
|
|
199
|
+
</div>
|
|
200
|
+
) : filtered.length === 0 ? (
|
|
201
|
+
<p className="py-10 text-center text-sm" style={{ color: "var(--muted-text)" }}>
|
|
202
|
+
{t("settings.no_packages_found")}
|
|
203
|
+
</p>
|
|
204
|
+
) : (
|
|
205
|
+
filtered.map((pkg) => (
|
|
206
|
+
<PackageRow
|
|
207
|
+
key={pkg.name}
|
|
208
|
+
name={pkg.name}
|
|
209
|
+
description={pkg.description}
|
|
210
|
+
link={pkg.link}
|
|
211
|
+
installed={isInstalledId(pkg.name)}
|
|
212
|
+
onInstall={() => handleInstall(`npm:${pkg.name}`)}
|
|
213
|
+
/>
|
|
214
|
+
))
|
|
215
|
+
)}
|
|
216
|
+
</div>
|
|
217
|
+
</>
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
</Modal>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
} from "@/lib/config";
|
|
12
12
|
import type { PiConfig, UpdateCheckResult } from "@/types";
|
|
13
13
|
import { cn } from "@/lib/utils";
|
|
14
|
+
import { RECOMMENDED_PACKAGES } from "@/data/recommended-packages";
|
|
15
|
+
import { PackageBrowser } from "./PackageBrowser";
|
|
14
16
|
import {
|
|
15
17
|
Download,
|
|
16
18
|
Upload,
|
|
@@ -89,7 +91,6 @@ export function SettingsPage() {
|
|
|
89
91
|
auth,
|
|
90
92
|
modelsJson,
|
|
91
93
|
allProviders,
|
|
92
|
-
allModels,
|
|
93
94
|
updateSettings,
|
|
94
95
|
setTheme,
|
|
95
96
|
addPackage,
|
|
@@ -102,6 +103,7 @@ export function SettingsPage() {
|
|
|
102
103
|
const [newPackage, setNewPackage] = useState("");
|
|
103
104
|
const [importError, setImportError] = useState("");
|
|
104
105
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
|
106
|
+
const [showPackageBrowser, setShowPackageBrowser] = useState(false);
|
|
105
107
|
const [fontSize, setFontSize] = useState(() => {
|
|
106
108
|
const saved = Number(localStorage.getItem(FONT_SIZE_KEY));
|
|
107
109
|
return saved >= 12 && saved <= 24 ? saved : 16;
|
|
@@ -132,10 +134,16 @@ export function SettingsPage() {
|
|
|
132
134
|
}
|
|
133
135
|
};
|
|
134
136
|
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
+
// Names of everything that can be updated: pi core (routed to `pi update`
|
|
138
|
+
// server-side) plus every outdated extension.
|
|
139
|
+
const updatableNames = [
|
|
140
|
+
...(updateResult?.pi?.hasUpdate ? [updateResult.pi.name] : []),
|
|
141
|
+
...(updateResult?.extensions ?? []).filter((e) => e.hasUpdate).map((e) => e.name),
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
// One-click update, then re-check so the list reflects the new installed versions.
|
|
137
145
|
const handleApplyUpdates = async () => {
|
|
138
|
-
const names =
|
|
146
|
+
const names = updatableNames;
|
|
139
147
|
if (names.length === 0) return;
|
|
140
148
|
setApplying(true);
|
|
141
149
|
setApplyMessage(null);
|
|
@@ -221,7 +229,18 @@ export function SettingsPage() {
|
|
|
221
229
|
// ── Default model select: composite `providerId/modelId` values, but the
|
|
222
230
|
// settings file stores the bare model id (that's what pi expects on disk).
|
|
223
231
|
const modelValue = (providerId: string, modelId: string) => `${providerId}/${modelId}`;
|
|
224
|
-
|
|
232
|
+
// Only providers that are actually usable belong in the defaults lists: a saved
|
|
233
|
+
// API key (auth.json / models.json override) or a custom provider. A stale
|
|
234
|
+
// saved value is kept so the selects don't render blank.
|
|
235
|
+
const providerOptions = allProviders.filter(
|
|
236
|
+
(p) =>
|
|
237
|
+
p.type === "custom" ||
|
|
238
|
+
p.hasAuth ||
|
|
239
|
+
!!p.apiKey ||
|
|
240
|
+
!!auth?.[p.id]?.key ||
|
|
241
|
+
p.id === settings?.defaultProvider
|
|
242
|
+
);
|
|
243
|
+
const modelProviders = providerOptions.filter((p) => p.models.length > 0);
|
|
225
244
|
const scopedId = settings?.defaultProvider;
|
|
226
245
|
const groupedModelOptions = scopedId
|
|
227
246
|
? [...modelProviders.filter((p) => p.id === scopedId), ...modelProviders.filter((p) => p.id !== scopedId)]
|
|
@@ -262,13 +281,6 @@ export function SettingsPage() {
|
|
|
262
281
|
updateSettings(clearModel ? { defaultProvider: v, defaultModel: undefined } : { defaultProvider: v || undefined });
|
|
263
282
|
};
|
|
264
283
|
|
|
265
|
-
const enabledModels = settings?.enabledModels ?? [];
|
|
266
|
-
const themeLabelKey: Record<string, string> = {
|
|
267
|
-
light: "settings.light",
|
|
268
|
-
dark: "settings.dark",
|
|
269
|
-
"light/dark": "settings.system",
|
|
270
|
-
};
|
|
271
|
-
|
|
272
284
|
const tabs: { key: SettingsTab; icon: typeof Palette; label: string }[] = [
|
|
273
285
|
{ key: "appearance", icon: Palette, label: t("settings.appearance") },
|
|
274
286
|
{ key: "models", icon: LayoutGrid, label: t("settings.tab_models") },
|
|
@@ -287,25 +299,6 @@ export function SettingsPage() {
|
|
|
287
299
|
</div>
|
|
288
300
|
<h1 className="mt-1 text-2xl font-bold text-white">{t("settings.title")}</h1>
|
|
289
301
|
<p className="mt-1 text-sm text-gray-400">{t("settings.subtitle")}</p>
|
|
290
|
-
<div className="mt-4 grid max-w-lg grid-cols-3 gap-3">
|
|
291
|
-
<div className="rounded-xl border border-gray-800 bg-gray-900/50 px-4 py-3">
|
|
292
|
-
<div className="text-xl font-bold text-white">{allProviders.length}</div>
|
|
293
|
-
<div className="text-xs text-gray-500">{t("settings.stat_providers")}</div>
|
|
294
|
-
</div>
|
|
295
|
-
<div className="rounded-xl border border-gray-800 bg-gray-900/50 px-4 py-3">
|
|
296
|
-
<div className="text-xl font-bold text-white">
|
|
297
|
-
{enabledModels.length}
|
|
298
|
-
<small className="ml-0.5 text-xs font-normal text-gray-500">/ {allModels.length}</small>
|
|
299
|
-
</div>
|
|
300
|
-
<div className="text-xs text-gray-500">{t("settings.stat_enabled")}</div>
|
|
301
|
-
</div>
|
|
302
|
-
<div className="rounded-xl border border-gray-800 bg-gray-900/50 px-4 py-3">
|
|
303
|
-
<div className="pt-1 text-sm font-bold text-white">
|
|
304
|
-
{t(themeLabelKey[settings?.theme ?? "light/dark"] ?? "settings.system")}
|
|
305
|
-
</div>
|
|
306
|
-
<div className="mt-0.5 text-xs text-gray-500">{t("settings.stat_theme")}</div>
|
|
307
|
-
</div>
|
|
308
|
-
</div>
|
|
309
302
|
</header>
|
|
310
303
|
|
|
311
304
|
{/* ── Tab nav ──────────────────────────────────────── */}
|
|
@@ -428,7 +421,7 @@ export function SettingsPage() {
|
|
|
428
421
|
className={cn(selectCls, "w-56")}
|
|
429
422
|
>
|
|
430
423
|
<option value="">{t("settings.none")}</option>
|
|
431
|
-
{
|
|
424
|
+
{providerOptions.map((p) => (
|
|
432
425
|
<option key={p.id} value={p.id}>{p.name}</option>
|
|
433
426
|
))}
|
|
434
427
|
</select>
|
|
@@ -490,7 +483,7 @@ export function SettingsPage() {
|
|
|
490
483
|
<RefreshCw className={cn("h-4 w-4", checkingUpdates && "animate-spin")} />
|
|
491
484
|
{t("settings.check_updates")}
|
|
492
485
|
</button>
|
|
493
|
-
{
|
|
486
|
+
{updatableNames.length > 0 && (
|
|
494
487
|
<button
|
|
495
488
|
onClick={handleApplyUpdates}
|
|
496
489
|
disabled={applying || checkingUpdates}
|
|
@@ -569,6 +562,15 @@ export function SettingsPage() {
|
|
|
569
562
|
</Card>
|
|
570
563
|
|
|
571
564
|
<Card icon={Package} title={t("settings.packages")}>
|
|
565
|
+
<div className="mb-4">
|
|
566
|
+
<button
|
|
567
|
+
onClick={() => setShowPackageBrowser(true)}
|
|
568
|
+
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500"
|
|
569
|
+
>
|
|
570
|
+
<Plus className="h-4 w-4" />
|
|
571
|
+
{t("settings.browse_packages")}
|
|
572
|
+
</button>
|
|
573
|
+
</div>
|
|
572
574
|
{(settings?.packages ?? []).length > 0 && (
|
|
573
575
|
<div className="mb-4 flex flex-wrap gap-1.5">
|
|
574
576
|
{(settings?.packages ?? []).map((pkg) => (
|
|
@@ -590,33 +592,84 @@ export function SettingsPage() {
|
|
|
590
592
|
{(settings?.packages ?? []).length === 0 && (
|
|
591
593
|
<p className="mb-4 text-sm text-gray-500">{t("settings.no_packages")}</p>
|
|
592
594
|
)}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
595
|
+
|
|
596
|
+
{/* Recommended packages — one-click install */}
|
|
597
|
+
{(() => {
|
|
598
|
+
const installed = new Set(settings?.packages ?? []);
|
|
599
|
+
const recommended = RECOMMENDED_PACKAGES.filter((p) => !installed.has(p.id));
|
|
600
|
+
if (recommended.length === 0) return null;
|
|
601
|
+
return (
|
|
602
|
+
<div className="mb-4">
|
|
603
|
+
<p className="mb-2 text-xs font-medium" style={{ color: "var(--muted-text)" }}>
|
|
604
|
+
{t("settings.recommended_packages")}
|
|
605
|
+
</p>
|
|
606
|
+
<div className="space-y-1.5">
|
|
607
|
+
{recommended.map((pkg) => (
|
|
608
|
+
<div
|
|
609
|
+
key={pkg.id}
|
|
610
|
+
className="flex items-center gap-3 rounded-lg border border-gray-700 bg-gray-800/50 px-3 py-2"
|
|
611
|
+
>
|
|
612
|
+
<div className="min-w-0 flex-1">
|
|
613
|
+
<p className="truncate text-sm text-gray-200">{pkg.name}</p>
|
|
614
|
+
<p className="truncate text-xs text-gray-500">{t(pkg.descKey)}</p>
|
|
615
|
+
</div>
|
|
616
|
+
<button
|
|
617
|
+
onClick={() => addPackage(pkg.id)}
|
|
618
|
+
className="flex shrink-0 items-center gap-1 rounded-lg border border-blue-600/50 bg-blue-600/10 px-3 py-1.5 text-xs font-medium text-blue-400 hover:bg-blue-600/20"
|
|
619
|
+
>
|
|
620
|
+
<Plus className="h-3.5 w-3.5" />
|
|
621
|
+
{t("settings.install")}
|
|
622
|
+
</button>
|
|
623
|
+
</div>
|
|
624
|
+
))}
|
|
625
|
+
</div>
|
|
626
|
+
<button
|
|
627
|
+
onClick={() => {
|
|
628
|
+
const list = settings?.packages ?? [];
|
|
629
|
+
const toAdd = recommended.map((p) => p.id);
|
|
630
|
+
updateSettings({ packages: [...list, ...toAdd] });
|
|
631
|
+
}}
|
|
632
|
+
className="mt-2 flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-xs text-gray-300 hover:bg-gray-700"
|
|
633
|
+
>
|
|
634
|
+
<Plus className="h-3.5 w-3.5" />
|
|
635
|
+
{t("settings.install_all_recommended")}
|
|
636
|
+
</button>
|
|
637
|
+
</div>
|
|
638
|
+
);
|
|
639
|
+
})()}
|
|
640
|
+
|
|
641
|
+
<details className="mb-1">
|
|
642
|
+
<summary className="cursor-pointer text-xs text-gray-500 hover:text-gray-300">
|
|
643
|
+
{t("settings.add_custom_package")}
|
|
644
|
+
</summary>
|
|
645
|
+
<div className="mt-2 flex max-w-md gap-2">
|
|
646
|
+
<input
|
|
647
|
+
type="text"
|
|
648
|
+
value={newPackage}
|
|
649
|
+
onChange={(e) => setNewPackage(e.target.value)}
|
|
650
|
+
placeholder={t("settings.package_placeholder")}
|
|
651
|
+
className="flex-1 rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-white placeholder-gray-500"
|
|
652
|
+
onKeyDown={(e) => {
|
|
653
|
+
if (e.key === "Enter" && newPackage.trim()) {
|
|
654
|
+
addPackage(newPackage.trim());
|
|
655
|
+
setNewPackage("");
|
|
656
|
+
}
|
|
657
|
+
}}
|
|
658
|
+
/>
|
|
659
|
+
<button
|
|
660
|
+
onClick={() => {
|
|
661
|
+
if (newPackage.trim()) {
|
|
662
|
+
addPackage(newPackage.trim());
|
|
663
|
+
setNewPackage("");
|
|
664
|
+
}
|
|
665
|
+
}}
|
|
666
|
+
className="flex items-center gap-1 rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-300 hover:bg-gray-700"
|
|
667
|
+
>
|
|
668
|
+
<Plus className="h-3.5 w-3.5" />
|
|
669
|
+
{t("settings.add")}
|
|
670
|
+
</button>
|
|
671
|
+
</div>
|
|
672
|
+
</details>
|
|
620
673
|
</Card>
|
|
621
674
|
|
|
622
675
|
<Card icon={Download} title={t("settings.import_export")}>
|
|
@@ -655,6 +708,13 @@ export function SettingsPage() {
|
|
|
655
708
|
</div>
|
|
656
709
|
)}
|
|
657
710
|
|
|
711
|
+
<PackageBrowser
|
|
712
|
+
open={showPackageBrowser}
|
|
713
|
+
onClose={() => setShowPackageBrowser(false)}
|
|
714
|
+
installed={new Set(settings?.packages ?? [])}
|
|
715
|
+
onInstall={(id) => addPackage(id)}
|
|
716
|
+
/>
|
|
717
|
+
|
|
658
718
|
{/* Reset Confirm */}
|
|
659
719
|
<Modal
|
|
660
720
|
open={showResetConfirm}
|