@nextclaw/ui 0.5.13 → 0.5.15

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/assets/ChannelsList-DHBUkGsA.js +1 -0
  3. package/dist/assets/CronConfig-9YgJQTmP.js +1 -0
  4. package/dist/assets/DocBrowser-CBY75a92.js +1 -0
  5. package/dist/assets/MarketplacePage-Dw_0TLNd.js +1 -0
  6. package/dist/assets/ModelConfig-CBmc0ERp.js +1 -0
  7. package/dist/assets/ProvidersList-CZfmOr6_.js +1 -0
  8. package/dist/assets/RuntimeConfig-DX6nI_b5.js +1 -0
  9. package/dist/assets/SessionsConfig-wH-647_6.js +2 -0
  10. package/dist/assets/action-link-DNRpMv1A.js +1 -0
  11. package/dist/assets/card-KMt_4CgV.js +1 -0
  12. package/dist/assets/config-hints-CApS3K_7.js +1 -0
  13. package/dist/assets/dialog-CqY6jnQm.js +5 -0
  14. package/dist/assets/index-CVOQiX2_.js +2 -0
  15. package/dist/assets/index-DdpR1fdj.css +1 -0
  16. package/dist/assets/label-C7WCjHWk.js +1 -0
  17. package/dist/assets/page-layout-CMuYE4DA.js +1 -0
  18. package/dist/assets/switch-DQmUTN4L.js +1 -0
  19. package/dist/assets/tabs-custom-C5xYS25o.js +1 -0
  20. package/dist/assets/useConfig-Bv2DQ4BE.js +1 -0
  21. package/dist/assets/useConfirmDialog-BOcIsqiA.js +1 -0
  22. package/dist/assets/vendor-Dkx07DIh.js +342 -0
  23. package/dist/index.html +3 -2
  24. package/package.json +1 -1
  25. package/src/App.tsx +25 -14
  26. package/src/api/marketplace.ts +20 -20
  27. package/src/api/types.ts +2 -2
  28. package/src/components/layout/AppLayout.tsx +15 -3
  29. package/src/components/layout/Sidebar.tsx +30 -1
  30. package/src/components/marketplace/MarketplacePage.tsx +58 -38
  31. package/src/components/providers/ThemeProvider.tsx +51 -0
  32. package/src/hooks/useMarketplace.ts +9 -9
  33. package/src/lib/i18n.ts +3 -0
  34. package/src/lib/theme.ts +80 -0
  35. package/src/main.tsx +8 -5
  36. package/src/styles/design-system.css +73 -1
  37. package/vite.config.ts +2 -2
  38. package/dist/assets/index-BmFkJxYv.css +0 -1
  39. package/dist/assets/index-dTDIYw5O.js +0 -342
package/dist/index.html CHANGED
@@ -6,8 +6,9 @@
6
6
  <link rel="icon" type="image/svg+xml" href="/logo.svg" />
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8
8
  <title>NextClaw - 系统配置</title>
9
- <script type="module" crossorigin src="/assets/index-dTDIYw5O.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-BmFkJxYv.css">
9
+ <script type="module" crossorigin src="/assets/index-CVOQiX2_.js"></script>
10
+ <link rel="modulepreload" crossorigin href="/assets/vendor-Dkx07DIh.js">
11
+ <link rel="stylesheet" crossorigin href="/assets/index-DdpR1fdj.css">
11
12
  </head>
12
13
 
13
14
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextclaw/ui",
3
- "version": "0.5.13",
3
+ "version": "0.5.15",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/App.tsx CHANGED
@@ -1,12 +1,6 @@
1
+ import { lazy, Suspense } from 'react';
1
2
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
3
  import { AppLayout } from '@/components/layout/AppLayout';
3
- import { ModelConfig } from '@/components/config/ModelConfig';
4
- import { ProvidersList } from '@/components/config/ProvidersList';
5
- import { ChannelsList } from '@/components/config/ChannelsList';
6
- import { RuntimeConfig } from '@/components/config/RuntimeConfig';
7
- import { SessionsConfig } from '@/components/config/SessionsConfig';
8
- import { CronConfig } from '@/components/config/CronConfig';
9
- import { MarketplacePage } from '@/components/marketplace/MarketplacePage';
10
4
  import { useWebSocket } from '@/hooks/useWebSocket';
11
5
  import { Toaster } from 'sonner';
12
6
  import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
@@ -20,6 +14,22 @@ const queryClient = new QueryClient({
20
14
  }
21
15
  });
22
16
 
17
+ const ModelConfigPage = lazy(async () => ({ default: (await import('@/components/config/ModelConfig')).ModelConfig }));
18
+ const ProvidersListPage = lazy(async () => ({ default: (await import('@/components/config/ProvidersList')).ProvidersList }));
19
+ const ChannelsListPage = lazy(async () => ({ default: (await import('@/components/config/ChannelsList')).ChannelsList }));
20
+ const RuntimeConfigPage = lazy(async () => ({ default: (await import('@/components/config/RuntimeConfig')).RuntimeConfig }));
21
+ const SessionsConfigPage = lazy(async () => ({ default: (await import('@/components/config/SessionsConfig')).SessionsConfig }));
22
+ const CronConfigPage = lazy(async () => ({ default: (await import('@/components/config/CronConfig')).CronConfig }));
23
+ const MarketplacePage = lazy(async () => ({ default: (await import('@/components/marketplace/MarketplacePage')).MarketplacePage }));
24
+
25
+ function RouteFallback() {
26
+ return <div className="h-full w-full animate-pulse rounded-2xl border border-border/40 bg-card/40" />;
27
+ }
28
+
29
+ function LazyRoute({ children }: { children: JSX.Element }) {
30
+ return <Suspense fallback={<RouteFallback />}>{children}</Suspense>;
31
+ }
32
+
23
33
  function AppContent() {
24
34
  useWebSocket(queryClient); // Initialize WebSocket connection
25
35
  const location = useLocation();
@@ -29,13 +39,14 @@ function AppContent() {
29
39
  <AppLayout>
30
40
  <div key={location.pathname} className="animate-fade-in w-full h-full">
31
41
  <Routes>
32
- <Route path="/model" element={<ModelConfig />} />
33
- <Route path="/providers" element={<ProvidersList />} />
34
- <Route path="/channels" element={<ChannelsList />} />
35
- <Route path="/runtime" element={<RuntimeConfig />} />
36
- <Route path="/sessions" element={<SessionsConfig />} />
37
- <Route path="/cron" element={<CronConfig />} />
38
- <Route path="/marketplace" element={<MarketplacePage />} />
42
+ <Route path="/model" element={<LazyRoute><ModelConfigPage /></LazyRoute>} />
43
+ <Route path="/providers" element={<LazyRoute><ProvidersListPage /></LazyRoute>} />
44
+ <Route path="/channels" element={<LazyRoute><ChannelsListPage /></LazyRoute>} />
45
+ <Route path="/runtime" element={<LazyRoute><RuntimeConfigPage /></LazyRoute>} />
46
+ <Route path="/sessions" element={<LazyRoute><SessionsConfigPage /></LazyRoute>} />
47
+ <Route path="/cron" element={<LazyRoute><CronConfigPage /></LazyRoute>} />
48
+ <Route path="/marketplace" element={<Navigate to="/marketplace/plugins" replace />} />
49
+ <Route path="/marketplace/:type" element={<LazyRoute><MarketplacePage /></LazyRoute>} />
39
50
  <Route path="/" element={<Navigate to="/model" replace />} />
40
51
  <Route path="*" element={<Navigate to="/model" replace />} />
41
52
  </Routes>
@@ -13,23 +13,25 @@ import type {
13
13
  } from './types';
14
14
 
15
15
  export type MarketplaceListParams = {
16
+ type: MarketplaceItemType;
16
17
  q?: string;
17
- type?: MarketplaceItemType;
18
18
  tag?: string;
19
19
  sort?: MarketplaceSort;
20
20
  page?: number;
21
21
  pageSize?: number;
22
22
  };
23
23
 
24
- export async function fetchMarketplaceItems(params: MarketplaceListParams = {}): Promise<MarketplaceListView> {
24
+ function toMarketplaceTypeSegment(type: MarketplaceItemType): 'plugins' | 'skills' {
25
+ return type === 'plugin' ? 'plugins' : 'skills';
26
+ }
27
+
28
+ export async function fetchMarketplaceItems(params: MarketplaceListParams): Promise<MarketplaceListView> {
25
29
  const query = new URLSearchParams();
30
+ const segment = toMarketplaceTypeSegment(params.type);
26
31
 
27
32
  if (params.q?.trim()) {
28
33
  query.set('q', params.q.trim());
29
34
  }
30
- if (params.type) {
31
- query.set('type', params.type);
32
- }
33
35
  if (params.tag?.trim()) {
34
36
  query.set('tag', params.tag.trim());
35
37
  }
@@ -44,7 +46,9 @@ export async function fetchMarketplaceItems(params: MarketplaceListParams = {}):
44
46
  }
45
47
 
46
48
  const suffix = query.toString();
47
- const response = await api.get<MarketplaceListView>(suffix ? `/api/marketplace/items?${suffix}` : '/api/marketplace/items');
49
+ const response = await api.get<MarketplaceListView>(
50
+ suffix ? `/api/marketplace/${segment}/items?${suffix}` : `/api/marketplace/${segment}/items`
51
+ );
48
52
  if (!response.ok) {
49
53
  throw new Error(response.error.message);
50
54
  }
@@ -52,17 +56,10 @@ export async function fetchMarketplaceItems(params: MarketplaceListParams = {}):
52
56
  return response.data;
53
57
  }
54
58
 
55
- export async function fetchMarketplaceItem(slug: string, type?: MarketplaceItemType): Promise<MarketplaceItemView> {
56
- const query = new URLSearchParams();
57
- if (type) {
58
- query.set('type', type);
59
- }
60
-
61
- const suffix = query.toString();
59
+ export async function fetchMarketplaceItem(slug: string, type: MarketplaceItemType): Promise<MarketplaceItemView> {
60
+ const segment = toMarketplaceTypeSegment(type);
62
61
  const response = await api.get<MarketplaceItemView>(
63
- suffix
64
- ? `/api/marketplace/items/${encodeURIComponent(slug)}?${suffix}`
65
- : `/api/marketplace/items/${encodeURIComponent(slug)}`
62
+ `/api/marketplace/${segment}/items/${encodeURIComponent(slug)}`
66
63
  );
67
64
  if (!response.ok) {
68
65
  throw new Error(response.error.message);
@@ -95,7 +92,8 @@ export async function fetchMarketplaceRecommendations(params: {
95
92
  }
96
93
 
97
94
  export async function installMarketplaceItem(request: MarketplaceInstallRequest): Promise<MarketplaceInstallResult> {
98
- const response = await api.post<MarketplaceInstallResult>('/api/marketplace/install', request);
95
+ const segment = toMarketplaceTypeSegment(request.type);
96
+ const response = await api.post<MarketplaceInstallResult>(`/api/marketplace/${segment}/install`, request);
99
97
  if (!response.ok) {
100
98
  throw new Error(response.error.message);
101
99
  }
@@ -103,8 +101,9 @@ export async function installMarketplaceItem(request: MarketplaceInstallRequest)
103
101
  return response.data;
104
102
  }
105
103
 
106
- export async function fetchMarketplaceInstalled(): Promise<MarketplaceInstalledView> {
107
- const response = await api.get<MarketplaceInstalledView>('/api/marketplace/installed');
104
+ export async function fetchMarketplaceInstalled(type: MarketplaceItemType): Promise<MarketplaceInstalledView> {
105
+ const segment = toMarketplaceTypeSegment(type);
106
+ const response = await api.get<MarketplaceInstalledView>(`/api/marketplace/${segment}/installed`);
108
107
  if (!response.ok) {
109
108
  throw new Error(response.error.message);
110
109
  }
@@ -112,7 +111,8 @@ export async function fetchMarketplaceInstalled(): Promise<MarketplaceInstalledV
112
111
  }
113
112
 
114
113
  export async function manageMarketplaceItem(request: MarketplaceManageRequest): Promise<MarketplaceManageResult> {
115
- const response = await api.post<MarketplaceManageResult>('/api/marketplace/manage', request);
114
+ const segment = toMarketplaceTypeSegment(request.type);
115
+ const response = await api.post<MarketplaceManageResult>(`/api/marketplace/${segment}/manage`, request);
116
116
  if (!response.ok) {
117
117
  throw new Error(response.error.message);
118
118
  }
package/src/api/types.ts CHANGED
@@ -355,9 +355,9 @@ export type MarketplaceInstalledRecord = {
355
355
  };
356
356
 
357
357
  export type MarketplaceInstalledView = {
358
+ type: MarketplaceItemType;
358
359
  total: number;
359
- pluginSpecs: string[];
360
- skillSpecs: string[];
360
+ specs: string[];
361
361
  records: MarketplaceInstalledRecord[];
362
362
  };
363
363
 
@@ -1,5 +1,9 @@
1
+ import { lazy, Suspense } from 'react';
1
2
  import { Sidebar } from './Sidebar';
2
- import { DocBrowserProvider, DocBrowser, useDocBrowser, useDocLinkInterceptor } from '@/components/doc-browser';
3
+ import { DocBrowserProvider, useDocBrowser } from '@/components/doc-browser/DocBrowserContext';
4
+ import { useDocLinkInterceptor } from '@/components/doc-browser/useDocLinkInterceptor';
5
+
6
+ const DocBrowser = lazy(async () => ({ default: (await import('@/components/doc-browser/DocBrowser')).DocBrowser }));
3
7
 
4
8
  interface AppLayoutProps {
5
9
  children: React.ReactNode;
@@ -21,9 +25,17 @@ function AppLayoutInner({ children }: AppLayoutProps) {
21
25
  </main>
22
26
  </div>
23
27
  {/* Doc Browser: docked mode renders inline, floating mode renders as overlay */}
24
- {isOpen && mode === 'docked' && <DocBrowser />}
28
+ {isOpen && mode === 'docked' && (
29
+ <Suspense fallback={null}>
30
+ <DocBrowser />
31
+ </Suspense>
32
+ )}
25
33
  </div>
26
- {isOpen && mode === 'floating' && <DocBrowser />}
34
+ {isOpen && mode === 'floating' && (
35
+ <Suspense fallback={null}>
36
+ <DocBrowser />
37
+ </Suspense>
38
+ )}
27
39
  </div>
28
40
  );
29
41
  }
@@ -1,15 +1,19 @@
1
1
  import { cn } from '@/lib/utils';
2
2
  import { LANGUAGE_OPTIONS, t, type I18nLanguage } from '@/lib/i18n';
3
- import { Cpu, GitBranch, History, MessageSquare, Sparkles, BookOpen, Store, AlarmClock, Languages } from 'lucide-react';
3
+ import { THEME_OPTIONS, type UiTheme } from '@/lib/theme';
4
+ import { Cpu, GitBranch, History, MessageSquare, Sparkles, BookOpen, Store, AlarmClock, Languages, Palette } from 'lucide-react';
4
5
  import { NavLink } from 'react-router-dom';
5
6
  import { useDocBrowser } from '@/components/doc-browser';
6
7
  import { useI18n } from '@/components/providers/I18nProvider';
8
+ import { useTheme } from '@/components/providers/ThemeProvider';
7
9
  import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
8
10
 
9
11
  export function Sidebar() {
10
12
  const docBrowser = useDocBrowser();
11
13
  const { language, setLanguage } = useI18n();
14
+ const { theme, setTheme } = useTheme();
12
15
  const currentLanguageLabel = LANGUAGE_OPTIONS.find((option) => option.value === language)?.label ?? language;
16
+ const currentThemeLabel = t(THEME_OPTIONS.find((option) => option.value === theme)?.labelKey ?? 'themeWarm');
13
17
 
14
18
  const handleLanguageSwitch = (nextLanguage: I18nLanguage) => {
15
19
  if (language === nextLanguage) {
@@ -19,6 +23,13 @@ export function Sidebar() {
19
23
  window.location.reload();
20
24
  };
21
25
 
26
+ const handleThemeSwitch = (nextTheme: UiTheme) => {
27
+ if (theme === nextTheme) {
28
+ return;
29
+ }
30
+ setTheme(nextTheme);
31
+ };
32
+
22
33
  const navItems = [
23
34
  {
24
35
  target: '/model',
@@ -104,6 +115,24 @@ export function Sidebar() {
104
115
 
105
116
  {/* Help Button */}
106
117
  <div className="pt-3 border-t border-[#dde0ea] mt-3">
118
+ <div className="mb-2">
119
+ <Select value={theme} onValueChange={(value) => handleThemeSwitch(value as UiTheme)}>
120
+ <SelectTrigger className="w-full h-auto rounded-xl border-0 bg-transparent shadow-none px-3 py-2.5 text-[14px] font-medium text-gray-600 hover:bg-[#e4e7ef] focus:ring-0">
121
+ <div className="flex items-center gap-3 min-w-0">
122
+ <Palette className="h-[17px] w-[17px] text-gray-400" />
123
+ <span className="text-left">{t('theme')}</span>
124
+ </div>
125
+ <span className="ml-auto text-xs text-gray-500">{currentThemeLabel}</span>
126
+ </SelectTrigger>
127
+ <SelectContent>
128
+ {THEME_OPTIONS.map((option) => (
129
+ <SelectItem key={option.value} value={option.value} className="text-xs">
130
+ {t(option.labelKey)}
131
+ </SelectItem>
132
+ ))}
133
+ </SelectContent>
134
+ </Select>
135
+ </div>
107
136
  <div className="mb-2">
108
137
  <Select value={language} onValueChange={(value) => handleLanguageSwitch(value as I18nLanguage)}>
109
138
  <SelectTrigger className="w-full h-auto rounded-xl border-0 bg-transparent shadow-none px-3 py-2.5 text-[14px] font-medium text-gray-600 hover:bg-[#e4e7ef] focus:ring-0">
@@ -1,5 +1,11 @@
1
1
  /* eslint-disable max-lines-per-function */
2
- import type { MarketplaceInstalledRecord, MarketplaceItemSummary, MarketplaceManageAction, MarketplaceSort } from '@/api/types';
2
+ import type {
3
+ MarketplaceInstalledRecord,
4
+ MarketplaceItemSummary,
5
+ MarketplaceManageAction,
6
+ MarketplaceSort,
7
+ MarketplaceItemType
8
+ } from '@/api/types';
3
9
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
4
10
  import { Tabs } from '@/components/ui/tabs-custom';
5
11
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
@@ -15,10 +21,10 @@ import { PageLayout, PageHeader } from '@/components/layout/page-layout';
15
21
  import { cn } from '@/lib/utils';
16
22
  import { PackageSearch } from 'lucide-react';
17
23
  import { useEffect, useMemo, useState } from 'react';
24
+ import { useNavigate, useParams } from 'react-router-dom';
18
25
 
19
26
  const PAGE_SIZE = 12;
20
27
 
21
- type FilterType = 'all' | 'plugin' | 'skill';
22
28
  type ScopeType = 'all' | 'installed';
23
29
 
24
30
  type InstallState = {
@@ -38,6 +44,8 @@ type InstalledRenderEntry = {
38
44
  item?: MarketplaceItemSummary;
39
45
  };
40
46
 
47
+ type MarketplaceRouteType = 'plugins' | 'skills';
48
+
41
49
  function normalizeMarketplaceKey(value: string | undefined): string {
42
50
  return (value ?? '').trim().toLowerCase();
43
51
  }
@@ -168,10 +176,8 @@ function ItemIcon({ name, fallback }: { name?: string; fallback: string }) {
168
176
  function FilterPanel(props: {
169
177
  scope: ScopeType;
170
178
  searchText: string;
171
- typeFilter: FilterType;
172
179
  sort: MarketplaceSort;
173
180
  onSearchTextChange: (value: string) => void;
174
- onTypeFilterChange: (value: FilterType) => void;
175
181
  onSortChange: (value: MarketplaceSort) => void;
176
182
  }) {
177
183
  return (
@@ -187,28 +193,6 @@ function FilterPanel(props: {
187
193
  />
188
194
  </div>
189
195
 
190
- <div className="inline-flex h-9 rounded-xl bg-gray-100/80 p-1 shrink-0">
191
- {([
192
- { value: 'all', label: t('marketplaceFilterAll') },
193
- { value: 'plugin', label: t('marketplaceFilterPlugins') },
194
- { value: 'skill', label: t('marketplaceFilterSkills') },
195
- ] as const).map((opt) => (
196
- <button
197
- key={opt.value}
198
- type="button"
199
- onClick={() => props.onTypeFilterChange(opt.value)}
200
- className={cn(
201
- 'px-3 rounded-lg text-sm font-medium transition-all whitespace-nowrap',
202
- props.typeFilter === opt.value
203
- ? 'bg-white text-gray-900 shadow-sm'
204
- : 'text-gray-500 hover:text-gray-700'
205
- )}
206
- >
207
- {opt.label}
208
- </button>
209
- ))}
210
- </div>
211
-
212
196
  {props.scope === 'all' && (
213
197
  <Select value={props.sort} onValueChange={(v) => props.onSortChange(v as MarketplaceSort)}>
214
198
  <SelectTrigger className="h-9 w-[150px] shrink-0 rounded-lg">
@@ -367,10 +351,27 @@ function PaginationBar(props: {
367
351
  }
368
352
 
369
353
  export function MarketplacePage() {
354
+ const navigate = useNavigate();
355
+ const params = useParams<{ type?: string }>();
356
+
357
+ const routeType: MarketplaceRouteType | null = useMemo(() => {
358
+ if (params.type === 'plugins' || params.type === 'skills') {
359
+ return params.type;
360
+ }
361
+ return null;
362
+ }, [params.type]);
363
+
364
+ useEffect(() => {
365
+ if (!routeType) {
366
+ navigate('/marketplace/plugins', { replace: true });
367
+ }
368
+ }, [routeType, navigate]);
369
+
370
+ const typeFilter: MarketplaceItemType = routeType === 'skills' ? 'skill' : 'plugin';
371
+
370
372
  const [searchText, setSearchText] = useState('');
371
373
  const [query, setQuery] = useState('');
372
374
  const [scope, setScope] = useState<ScopeType>('all');
373
- const [typeFilter, setTypeFilter] = useState<FilterType>('all');
374
375
  const [sort, setSort] = useState<MarketplaceSort>('relevance');
375
376
  const [page, setPage] = useState(1);
376
377
 
@@ -382,11 +383,15 @@ export function MarketplacePage() {
382
383
  return () => clearTimeout(timer);
383
384
  }, [searchText]);
384
385
 
385
- const installedQuery = useMarketplaceInstalled();
386
+ useEffect(() => {
387
+ setPage(1);
388
+ }, [typeFilter]);
389
+
390
+ const installedQuery = useMarketplaceInstalled(typeFilter);
386
391
 
387
392
  const itemsQuery = useMarketplaceItems({
388
393
  q: query || undefined,
389
- type: typeFilter === 'all' ? undefined : typeFilter,
394
+ type: typeFilter,
390
395
  sort,
391
396
  page,
392
397
  pageSize: PAGE_SIZE
@@ -418,7 +423,7 @@ export function MarketplacePage() {
418
423
 
419
424
  const installedEntries = useMemo<InstalledRenderEntry[]>(() => {
420
425
  const entries = installedRecords
421
- .filter((record) => (typeFilter === 'all' ? true : record.type === typeFilter))
426
+ .filter((record) => record.type === typeFilter)
422
427
  .map((record) => ({
423
428
  key: `${record.type}:${record.spec}:${record.id ?? ''}`,
424
429
  record,
@@ -471,10 +476,14 @@ export function MarketplacePage() {
471
476
  action: manageMutation.variables?.action
472
477
  };
473
478
 
474
- const tabs = [
479
+ const scopeTabs = [
475
480
  { id: 'all', label: t('marketplaceTabMarketplace') },
476
481
  { id: 'installed', label: t('marketplaceTabInstalled'), count: installedQuery.data?.total ?? 0 }
477
482
  ];
483
+ const typeTabs = [
484
+ { id: 'plugins', label: t('marketplaceFilterPlugins') },
485
+ { id: 'skills', label: t('marketplaceFilterSkills') }
486
+ ];
478
487
 
479
488
  const handleInstall = (item: MarketplaceItemSummary) => {
480
489
  if (installMutation.isPending) {
@@ -518,7 +527,19 @@ export function MarketplacePage() {
518
527
  <PageHeader title={t('marketplacePageTitle')} description={t('marketplacePageDescription')} />
519
528
 
520
529
  <Tabs
521
- tabs={tabs}
530
+ tabs={typeTabs}
531
+ activeTab={routeType ?? 'plugins'}
532
+ onChange={(value) => {
533
+ const routeValue = value === 'skills' ? 'skills' : 'plugins';
534
+ if (routeType === routeValue) {
535
+ return;
536
+ }
537
+ navigate(`/marketplace/${routeValue}`);
538
+ }}
539
+ className="mb-4"
540
+ />
541
+ <Tabs
542
+ tabs={scopeTabs}
522
543
  activeTab={scope}
523
544
  onChange={(value) => {
524
545
  setScope(value as ScopeType);
@@ -530,13 +551,8 @@ export function MarketplacePage() {
530
551
  <FilterPanel
531
552
  scope={scope}
532
553
  searchText={searchText}
533
- typeFilter={typeFilter}
534
554
  sort={sort}
535
555
  onSearchTextChange={setSearchText}
536
- onTypeFilterChange={(value) => {
537
- setPage(1);
538
- setTypeFilter(value);
539
- }}
540
556
  onSortChange={(value) => {
541
557
  setPage(1);
542
558
  setSort(value);
@@ -545,7 +561,11 @@ export function MarketplacePage() {
545
561
 
546
562
  <section>
547
563
  <div className="flex items-center justify-between mb-3">
548
- <h3 className="text-[14px] font-semibold text-gray-900">{scope === 'installed' ? t('marketplaceSectionInstalled') : t('marketplaceSectionExtensions')}</h3>
564
+ <h3 className="text-[14px] font-semibold text-gray-900">
565
+ {scope === 'installed' ? t('marketplaceSectionInstalled') : t('marketplaceSectionExtensions')}
566
+ {' · '}
567
+ {typeFilter === 'plugin' ? t('marketplaceFilterPlugins') : t('marketplaceFilterSkills')}
568
+ </h3>
549
569
  <span className="text-[12px] text-gray-500">{listSummary}</span>
550
570
  </div>
551
571
 
@@ -0,0 +1,51 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useState,
8
+ type ReactNode,
9
+ } from 'react';
10
+ import {
11
+ getTheme,
12
+ initializeTheme,
13
+ setTheme as applyTheme,
14
+ subscribeThemeChange,
15
+ type UiTheme,
16
+ } from '@/lib/theme';
17
+
18
+ type ThemeContextValue = {
19
+ theme: UiTheme;
20
+ setTheme: (theme: UiTheme) => void;
21
+ };
22
+
23
+ const ThemeContext = createContext<ThemeContextValue | null>(null);
24
+
25
+ export function ThemeProvider({ children }: { children: ReactNode }) {
26
+ const [theme, setThemeState] = useState<UiTheme>(() => initializeTheme());
27
+
28
+ useEffect(() => {
29
+ const unsubscribe = subscribeThemeChange((nextTheme) => {
30
+ setThemeState(nextTheme);
31
+ });
32
+ return unsubscribe;
33
+ }, []);
34
+
35
+ const setTheme = useCallback((nextTheme: UiTheme) => {
36
+ applyTheme(nextTheme);
37
+ setThemeState(getTheme());
38
+ }, []);
39
+
40
+ const value = useMemo(() => ({ theme, setTheme }), [theme, setTheme]);
41
+
42
+ return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
43
+ }
44
+
45
+ export function useTheme(): ThemeContextValue {
46
+ const ctx = useContext(ThemeContext);
47
+ if (!ctx) {
48
+ throw new Error('useTheme must be used within ThemeProvider');
49
+ }
50
+ return ctx;
51
+ }
@@ -31,16 +31,16 @@ export function useMarketplaceRecommendations(params: { scene?: string; limit?:
31
31
  export function useMarketplaceItem(slug: string | null, type?: MarketplaceItemType) {
32
32
  return useQuery({
33
33
  queryKey: ['marketplace-item', slug, type],
34
- queryFn: () => fetchMarketplaceItem(slug as string, type),
35
- enabled: Boolean(slug),
34
+ queryFn: () => fetchMarketplaceItem(slug as string, type as MarketplaceItemType),
35
+ enabled: Boolean(slug && type),
36
36
  staleTime: 30_000
37
37
  });
38
38
  }
39
39
 
40
- export function useMarketplaceInstalled() {
40
+ export function useMarketplaceInstalled(type: MarketplaceItemType) {
41
41
  return useQuery({
42
- queryKey: ['marketplace-installed'],
43
- queryFn: fetchMarketplaceInstalled,
42
+ queryKey: ['marketplace-installed', type],
43
+ queryFn: () => fetchMarketplaceInstalled(type),
44
44
  staleTime: 10_000
45
45
  });
46
46
  }
@@ -51,8 +51,8 @@ export function useInstallMarketplaceItem() {
51
51
  return useMutation({
52
52
  mutationFn: (request: MarketplaceInstallRequest) => installMarketplaceItem(request),
53
53
  onSuccess: (result) => {
54
- queryClient.invalidateQueries({ queryKey: ['marketplace-installed'] });
55
- queryClient.refetchQueries({ queryKey: ['marketplace-installed'], type: 'active' });
54
+ queryClient.invalidateQueries({ queryKey: ['marketplace-installed', result.type] });
55
+ queryClient.refetchQueries({ queryKey: ['marketplace-installed', result.type], type: 'active' });
56
56
  queryClient.refetchQueries({ queryKey: ['marketplace-items'], type: 'active' });
57
57
  toast.success(result.message || `${result.type} ${t('marketplaceInstalledCountSuffix')}`);
58
58
  },
@@ -68,9 +68,9 @@ export function useManageMarketplaceItem() {
68
68
  return useMutation({
69
69
  mutationFn: (request: MarketplaceManageRequest) => manageMarketplaceItem(request),
70
70
  onSuccess: (result) => {
71
- queryClient.invalidateQueries({ queryKey: ['marketplace-installed'] });
71
+ queryClient.invalidateQueries({ queryKey: ['marketplace-installed', result.type] });
72
72
  queryClient.invalidateQueries({ queryKey: ['marketplace-items'] });
73
- queryClient.refetchQueries({ queryKey: ['marketplace-installed'], type: 'active' });
73
+ queryClient.refetchQueries({ queryKey: ['marketplace-installed', result.type], type: 'active' });
74
74
  queryClient.refetchQueries({ queryKey: ['marketplace-items'], type: 'active' });
75
75
  toast.success(result.message || `${result.action} success`);
76
76
  },
package/src/lib/i18n.ts CHANGED
@@ -149,6 +149,9 @@ export const LABELS: Record<string, { zh: string; en: string }> = {
149
149
  prev: { zh: '上一页', en: 'Prev' },
150
150
  next: { zh: '下一页', en: 'Next' },
151
151
  language: { zh: '语言', en: 'Language' },
152
+ theme: { zh: '主题', en: 'Theme' },
153
+ themeWarm: { zh: '暖色', en: 'Warm' },
154
+ themeCool: { zh: '冷色', en: 'Cool' },
152
155
 
153
156
  // Model
154
157
  modelPageTitle: { zh: '模型配置', en: 'Model Configuration' },
@@ -0,0 +1,80 @@
1
+ export type UiTheme = 'warm' | 'cool';
2
+
3
+ const THEME_STORAGE_KEY = 'nextclaw.ui.theme';
4
+
5
+ export const THEME_OPTIONS: Array<{ value: UiTheme; labelKey: string }> = [
6
+ { value: 'warm', labelKey: 'themeWarm' },
7
+ { value: 'cool', labelKey: 'themeCool' }
8
+ ];
9
+
10
+ let activeTheme: UiTheme = 'warm';
11
+ let initialized = false;
12
+ const listeners = new Set<(theme: UiTheme) => void>();
13
+
14
+ function isTheme(value: unknown): value is UiTheme {
15
+ return value === 'warm' || value === 'cool';
16
+ }
17
+
18
+ function applyThemeAttribute(theme: UiTheme): void {
19
+ if (typeof document === 'undefined') {
20
+ return;
21
+ }
22
+ document.documentElement.setAttribute('data-theme', theme);
23
+ }
24
+
25
+ export function resolveInitialTheme(): UiTheme {
26
+ if (typeof window === 'undefined') {
27
+ return 'warm';
28
+ }
29
+
30
+ try {
31
+ const saved = window.localStorage.getItem(THEME_STORAGE_KEY);
32
+ if (isTheme(saved)) {
33
+ return saved;
34
+ }
35
+ } catch {
36
+ // ignore storage failures
37
+ }
38
+
39
+ return 'warm';
40
+ }
41
+
42
+ export function initializeTheme(): UiTheme {
43
+ if (!initialized) {
44
+ activeTheme = resolveInitialTheme();
45
+ applyThemeAttribute(activeTheme);
46
+ initialized = true;
47
+ }
48
+ return activeTheme;
49
+ }
50
+
51
+ export function getTheme(): UiTheme {
52
+ return initialized ? activeTheme : initializeTheme();
53
+ }
54
+
55
+ export function setTheme(theme: UiTheme): void {
56
+ initializeTheme();
57
+ if (theme === activeTheme) {
58
+ return;
59
+ }
60
+
61
+ activeTheme = theme;
62
+ applyThemeAttribute(activeTheme);
63
+
64
+ if (typeof window !== 'undefined') {
65
+ try {
66
+ window.localStorage.setItem(THEME_STORAGE_KEY, activeTheme);
67
+ } catch {
68
+ // ignore storage failures
69
+ }
70
+ }
71
+
72
+ listeners.forEach((listener) => listener(activeTheme));
73
+ }
74
+
75
+ export function subscribeThemeChange(listener: (theme: UiTheme) => void): () => void {
76
+ listeners.add(listener);
77
+ return () => {
78
+ listeners.delete(listener);
79
+ };
80
+ }