@nextclaw/ui 0.3.15 → 0.3.17

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.
@@ -0,0 +1,408 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import { Download, PackageSearch, Sparkles, Store } from 'lucide-react';
3
+ import { cn } from '@/lib/utils';
4
+ import { Tabs } from '@/components/ui/tabs-custom';
5
+ import { useInstallMarketplaceItem, useMarketplaceInstalled, useMarketplaceItems, useMarketplaceRecommendations } from '@/hooks/useMarketplace';
6
+ import type { MarketplaceItemSummary, MarketplaceSort } from '@/api/types';
7
+
8
+ const PAGE_SIZE = 12;
9
+
10
+ type FilterType = 'all' | 'plugin' | 'skill';
11
+ type ScopeType = 'all' | 'installed';
12
+
13
+ type InstallState = {
14
+ isPending: boolean;
15
+ installingSpec?: string;
16
+ };
17
+
18
+ type InstalledSpecSets = {
19
+ plugin: Set<string>;
20
+ skill: Set<string>;
21
+ };
22
+
23
+ function buildInstalledSpecSets(records: { pluginSpecs: string[]; skillSpecs: string[] } | undefined): InstalledSpecSets {
24
+ return {
25
+ plugin: new Set(records?.pluginSpecs ?? []),
26
+ skill: new Set(records?.skillSpecs ?? [])
27
+ };
28
+ }
29
+
30
+ function isInstalled(item: MarketplaceItemSummary, sets: InstalledSpecSets): boolean {
31
+ return item.type === 'plugin'
32
+ ? sets.plugin.has(item.install.spec)
33
+ : sets.skill.has(item.install.spec);
34
+ }
35
+
36
+ function TypeBadge({ type }: { type: MarketplaceItemSummary['type'] }) {
37
+ return (
38
+ <span
39
+ className={cn(
40
+ 'text-[11px] uppercase px-2 py-1 rounded-full font-semibold',
41
+ type === 'plugin' ? 'bg-blue-50 text-blue-600' : 'bg-emerald-50 text-emerald-600'
42
+ )}
43
+ >
44
+ {type}
45
+ </span>
46
+ );
47
+ }
48
+
49
+ function InstalledBadge() {
50
+ return <span className="text-[11px] px-2 py-1 rounded-full font-semibold bg-indigo-50 text-indigo-600">Installed</span>;
51
+ }
52
+
53
+ function FilterPanel(props: {
54
+ searchText: string;
55
+ typeFilter: FilterType;
56
+ sort: MarketplaceSort;
57
+ onSearchTextChange: (value: string) => void;
58
+ onTypeFilterChange: (value: FilterType) => void;
59
+ onSortChange: (value: MarketplaceSort) => void;
60
+ }) {
61
+ return (
62
+ <div className="bg-white border border-gray-200 rounded-2xl p-4 mb-5">
63
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-3">
64
+ <div className="md:col-span-2 relative">
65
+ <PackageSearch className="h-4 w-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
66
+ <input
67
+ value={props.searchText}
68
+ onChange={(event) => props.onSearchTextChange(event.target.value)}
69
+ placeholder="Search by name, slug, tags..."
70
+ className="w-full h-10 border border-gray-200 rounded-lg pl-9 pr-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30"
71
+ />
72
+ </div>
73
+
74
+ <div className="flex gap-2">
75
+ <select
76
+ className="flex-1 h-10 border border-gray-200 rounded-lg px-3 text-sm bg-white"
77
+ value={props.typeFilter}
78
+ onChange={(event) => props.onTypeFilterChange(event.target.value as FilterType)}
79
+ >
80
+ <option value="all">All</option>
81
+ <option value="plugin">Plugins</option>
82
+ <option value="skill">Skills</option>
83
+ </select>
84
+ <select
85
+ className="flex-1 h-10 border border-gray-200 rounded-lg px-3 text-sm bg-white"
86
+ value={props.sort}
87
+ onChange={(event) => props.onSortChange(event.target.value as MarketplaceSort)}
88
+ >
89
+ <option value="relevance">Relevance</option>
90
+ <option value="updated">Recently Updated</option>
91
+ <option value="downloads">Downloads</option>
92
+ </select>
93
+ </div>
94
+ </div>
95
+ </div>
96
+ );
97
+ }
98
+
99
+ function InstallButton(props: {
100
+ item: MarketplaceItemSummary;
101
+ installState: InstallState;
102
+ installed: boolean;
103
+ onInstall: (item: MarketplaceItemSummary) => void;
104
+ }) {
105
+ const isInstalling = props.installState.isPending && props.installState.installingSpec === props.item.install.spec;
106
+
107
+ if (props.installed) {
108
+ return (
109
+ <button
110
+ disabled
111
+ className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg text-xs font-semibold bg-gray-100 text-gray-500 cursor-not-allowed"
112
+ >
113
+ Installed
114
+ </button>
115
+ );
116
+ }
117
+
118
+ return (
119
+ <button
120
+ onClick={() => props.onInstall(props.item)}
121
+ disabled={props.installState.isPending}
122
+ className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg text-xs font-semibold bg-gray-900 text-white hover:bg-black disabled:opacity-50"
123
+ >
124
+ <Download className="h-3.5 w-3.5" />
125
+ {isInstalling ? 'Installing...' : 'Install'}
126
+ </button>
127
+ );
128
+ }
129
+
130
+ function RecommendationSection(props: {
131
+ items: MarketplaceItemSummary[];
132
+ loading: boolean;
133
+ installState: InstallState;
134
+ installedSets: InstalledSpecSets;
135
+ onInstall: (item: MarketplaceItemSummary) => void;
136
+ }) {
137
+ return (
138
+ <section className="mb-6">
139
+ <div className="flex items-center gap-2 mb-3">
140
+ <Sparkles className="h-4 w-4 text-amber-500" />
141
+ <h3 className="text-[15px] font-bold text-gray-900">Recommended</h3>
142
+ </div>
143
+
144
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
145
+ {props.items.map((item) => {
146
+ const installed = isInstalled(item, props.installedSets);
147
+ return (
148
+ <div key={item.id} className="bg-white border border-gray-200 rounded-xl p-4">
149
+ <div className="flex items-start justify-between gap-3">
150
+ <div>
151
+ <div className="text-[14px] font-semibold text-gray-900">{item.name}</div>
152
+ <div className="text-[12px] text-gray-500 mt-0.5">{item.summary}</div>
153
+ </div>
154
+ <div className="flex items-center gap-2">
155
+ <TypeBadge type={item.type} />
156
+ {installed && <InstalledBadge />}
157
+ </div>
158
+ </div>
159
+ <div className="mt-3 flex items-center justify-between">
160
+ <code className="text-[11px] text-gray-500 bg-gray-100 rounded px-2 py-1">{item.install.spec}</code>
161
+ <InstallButton
162
+ item={item}
163
+ installed={installed}
164
+ installState={props.installState}
165
+ onInstall={props.onInstall}
166
+ />
167
+ </div>
168
+ </div>
169
+ );
170
+ })}
171
+
172
+ {props.loading && <div className="text-[13px] text-gray-500">Loading recommendations...</div>}
173
+ {!props.loading && props.items.length === 0 && <div className="text-[13px] text-gray-500">No recommendations yet.</div>}
174
+ </div>
175
+ </section>
176
+ );
177
+ }
178
+
179
+ function MarketplaceItemCard(props: {
180
+ item: MarketplaceItemSummary;
181
+ installState: InstallState;
182
+ installed: boolean;
183
+ onInstall: (item: MarketplaceItemSummary) => void;
184
+ }) {
185
+ const downloads = props.item.metrics?.downloads30d;
186
+
187
+ return (
188
+ <article className="bg-white border border-gray-200 rounded-xl p-4 hover:shadow-sm transition-shadow">
189
+ <div className="flex items-start justify-between gap-2">
190
+ <h4 className="text-[14px] font-semibold text-gray-900">{props.item.name}</h4>
191
+ <div className="flex items-center gap-2">
192
+ <TypeBadge type={props.item.type} />
193
+ {props.installed && <InstalledBadge />}
194
+ </div>
195
+ </div>
196
+
197
+ <p className="text-[12px] text-gray-500 mt-1 min-h-10">{props.item.summary}</p>
198
+
199
+ <div className="flex flex-wrap gap-1 mt-2">
200
+ {props.item.tags.slice(0, 3).map((tag) => (
201
+ <span key={`${props.item.id}-${tag}`} className="text-[11px] px-2 py-0.5 rounded-full bg-gray-100 text-gray-600">
202
+ {tag}
203
+ </span>
204
+ ))}
205
+ </div>
206
+
207
+ <div className="mt-3 text-[11px] text-gray-500">By {props.item.author}</div>
208
+ <div className="mt-1 text-[11px] text-gray-500">{downloads ? `${downloads} downloads / 30d` : 'No metrics'}</div>
209
+
210
+ <div className="mt-3 pt-3 border-t border-gray-100 flex items-center justify-between gap-2">
211
+ <code className="text-[11px] text-gray-500 bg-gray-100 rounded px-2 py-1 truncate">{props.item.install.spec}</code>
212
+ <InstallButton
213
+ item={props.item}
214
+ installed={props.installed}
215
+ installState={props.installState}
216
+ onInstall={props.onInstall}
217
+ />
218
+ </div>
219
+ </article>
220
+ );
221
+ }
222
+
223
+ function PaginationBar(props: {
224
+ page: number;
225
+ totalPages: number;
226
+ busy: boolean;
227
+ onPrev: () => void;
228
+ onNext: () => void;
229
+ }) {
230
+ return (
231
+ <div className="mt-5 flex items-center justify-end gap-2">
232
+ <button
233
+ className="h-8 px-3 rounded-lg border border-gray-200 text-sm text-gray-700 disabled:opacity-40"
234
+ onClick={props.onPrev}
235
+ disabled={props.page <= 1 || props.busy}
236
+ >
237
+ Prev
238
+ </button>
239
+ <div className="text-sm text-gray-600 min-w-20 text-center">
240
+ {props.totalPages === 0 ? '0 / 0' : `${props.page} / ${props.totalPages}`}
241
+ </div>
242
+ <button
243
+ className="h-8 px-3 rounded-lg border border-gray-200 text-sm text-gray-700 disabled:opacity-40"
244
+ onClick={props.onNext}
245
+ disabled={props.totalPages === 0 || props.page >= props.totalPages || props.busy}
246
+ >
247
+ Next
248
+ </button>
249
+ </div>
250
+ );
251
+ }
252
+
253
+ export function MarketplacePage() {
254
+ const [searchText, setSearchText] = useState('');
255
+ const [query, setQuery] = useState('');
256
+ const [scope, setScope] = useState<ScopeType>('all');
257
+ const [typeFilter, setTypeFilter] = useState<FilterType>('all');
258
+ const [sort, setSort] = useState<MarketplaceSort>('relevance');
259
+ const [page, setPage] = useState(1);
260
+
261
+ useEffect(() => {
262
+ const timer = setTimeout(() => {
263
+ setPage(1);
264
+ setQuery(searchText.trim());
265
+ }, 250);
266
+ return () => clearTimeout(timer);
267
+ }, [searchText]);
268
+
269
+ const installedQuery = useMarketplaceInstalled();
270
+ const requestPage = scope === 'installed' ? 1 : page;
271
+ const requestPageSize = scope === 'installed' ? 100 : PAGE_SIZE;
272
+
273
+ const itemsQuery = useMarketplaceItems({
274
+ q: query || undefined,
275
+ type: typeFilter === 'all' ? undefined : typeFilter,
276
+ sort,
277
+ page: requestPage,
278
+ pageSize: requestPageSize
279
+ });
280
+ const recommendationsQuery = useMarketplaceRecommendations({ scene: 'default', limit: 4 });
281
+ const installMutation = useInstallMarketplaceItem();
282
+
283
+ const installedSets = buildInstalledSpecSets(installedQuery.data);
284
+ const allItems = itemsQuery.data?.items ?? [];
285
+ const items = scope === 'installed'
286
+ ? allItems.filter((item) => isInstalled(item, installedSets))
287
+ : allItems;
288
+
289
+ const recommendations = recommendationsQuery.data?.items ?? [];
290
+ const total = scope === 'installed'
291
+ ? items.length
292
+ : (itemsQuery.data?.total ?? 0);
293
+ const totalPages = scope === 'installed' ? 1 : (itemsQuery.data?.totalPages ?? 0);
294
+
295
+ const listSummary = useMemo(() => {
296
+ if (!itemsQuery.data) {
297
+ return 'Loading...';
298
+ }
299
+ if (items.length === 0) {
300
+ return scope === 'installed' ? 'No installed items on this page' : 'No results';
301
+ }
302
+ return `Showing ${items.length} / ${total}`;
303
+ }, [items.length, itemsQuery.data, scope, total]);
304
+
305
+ const installState: InstallState = {
306
+ isPending: installMutation.isPending,
307
+ installingSpec: installMutation.variables?.spec
308
+ };
309
+
310
+ const tabs = [
311
+ { id: 'all', label: 'Marketplace' },
312
+ { id: 'installed', label: 'Installed', count: installedQuery.data?.total ?? 0 }
313
+ ];
314
+
315
+ const handleInstall = (item: MarketplaceItemSummary) => {
316
+ if (installMutation.isPending) {
317
+ return;
318
+ }
319
+ installMutation.mutate({ type: item.type, spec: item.install.spec });
320
+ };
321
+
322
+ return (
323
+ <div className="animate-fade-in pb-20">
324
+ <div className="flex items-center justify-between mb-6">
325
+ <div>
326
+ <h2 className="text-2xl font-bold text-gray-900">Marketplace</h2>
327
+ <p className="text-[13px] text-gray-500 mt-1">Search, discover and install plugins/skills.</p>
328
+ </div>
329
+ <div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-indigo-50 text-indigo-600 text-xs font-semibold">
330
+ <Store className="h-3.5 w-3.5" />
331
+ Read-only Catalog
332
+ </div>
333
+ </div>
334
+
335
+ <Tabs
336
+ tabs={tabs}
337
+ activeTab={scope}
338
+ onChange={(value) => {
339
+ setScope(value as ScopeType);
340
+ setPage(1);
341
+ }}
342
+ className="mb-5"
343
+ />
344
+
345
+ <FilterPanel
346
+ searchText={searchText}
347
+ typeFilter={typeFilter}
348
+ sort={sort}
349
+ onSearchTextChange={setSearchText}
350
+ onTypeFilterChange={(value) => {
351
+ setPage(1);
352
+ setTypeFilter(value);
353
+ }}
354
+ onSortChange={(value) => {
355
+ setPage(1);
356
+ setSort(value);
357
+ }}
358
+ />
359
+
360
+ <RecommendationSection
361
+ items={recommendations}
362
+ loading={recommendationsQuery.isLoading}
363
+ installState={installState}
364
+ installedSets={installedSets}
365
+ onInstall={handleInstall}
366
+ />
367
+
368
+ <section>
369
+ <div className="flex items-center justify-between mb-3">
370
+ <h3 className="text-[15px] font-bold text-gray-900">{scope === 'installed' ? 'Installed Items' : 'All Items'}</h3>
371
+ <span className="text-[12px] text-gray-500">{listSummary}</span>
372
+ </div>
373
+
374
+ {itemsQuery.isError && (
375
+ <div className="p-4 rounded-xl bg-rose-50 border border-rose-200 text-rose-700 text-sm">
376
+ Failed to load marketplace data: {itemsQuery.error.message}
377
+ </div>
378
+ )}
379
+
380
+ <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
381
+ {items.map((item) => (
382
+ <MarketplaceItemCard
383
+ key={item.id}
384
+ item={item}
385
+ installed={isInstalled(item, installedSets)}
386
+ installState={installState}
387
+ onInstall={handleInstall}
388
+ />
389
+ ))}
390
+ </div>
391
+
392
+ {!itemsQuery.isLoading && !itemsQuery.isError && items.length === 0 && (
393
+ <div className="text-[13px] text-gray-500 py-8 text-center">No items found.</div>
394
+ )}
395
+ </section>
396
+
397
+ {scope === 'all' && (
398
+ <PaginationBar
399
+ page={page}
400
+ totalPages={totalPages}
401
+ busy={itemsQuery.isFetching}
402
+ onPrev={() => setPage((current) => Math.max(1, current - 1))}
403
+ onNext={() => setPage((current) => (totalPages > 0 ? Math.min(totalPages, current + 1) : current + 1))}
404
+ />
405
+ )}
406
+ </div>
407
+ );
408
+ }
@@ -0,0 +1,135 @@
1
+ import * as React from "react"
2
+ import * as SelectPrimitive from "@radix-ui/react-select"
3
+ import { Check, ChevronDown, ChevronUp } from "lucide-react"
4
+ import { cn } from "@/lib/utils"
5
+
6
+ const Select = SelectPrimitive.Root
7
+ const SelectGroup = SelectPrimitive.Group
8
+ const SelectValue = SelectPrimitive.Value
9
+
10
+ const SelectTrigger = React.forwardRef<
11
+ React.ElementRef<typeof SelectPrimitive.Trigger>,
12
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
13
+ >(({ className, children, ...props }, ref) => (
14
+ <SelectPrimitive.Trigger
15
+ ref={ref}
16
+ className={cn(
17
+ "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-gray-200 bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 bg-white",
18
+ className
19
+ )}
20
+ {...props}
21
+ >
22
+ {children}
23
+ <SelectPrimitive.Icon asChild>
24
+ <ChevronDown className="h-4 w-4 opacity-50" />
25
+ </SelectPrimitive.Icon>
26
+ </SelectPrimitive.Trigger>
27
+ ))
28
+ SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
29
+
30
+ const SelectScrollUpButton = React.forwardRef<
31
+ React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
32
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
33
+ >(({ className, ...props }, ref) => (
34
+ <SelectPrimitive.ScrollUpButton
35
+ ref={ref}
36
+ className={cn("flex cursor-default items-center justify-center py-1", className)}
37
+ {...props}
38
+ >
39
+ <ChevronUp className="h-4 w-4" />
40
+ </SelectPrimitive.ScrollUpButton>
41
+ ))
42
+ SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
43
+
44
+ const SelectScrollDownButton = React.forwardRef<
45
+ React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
46
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
47
+ >(({ className, ...props }, ref) => (
48
+ <SelectPrimitive.ScrollDownButton
49
+ ref={ref}
50
+ className={cn("flex cursor-default items-center justify-center py-1", className)}
51
+ {...props}
52
+ >
53
+ <ChevronDown className="h-4 w-4" />
54
+ </SelectPrimitive.ScrollDownButton>
55
+ ))
56
+ SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
57
+
58
+ const SelectContent = React.forwardRef<
59
+ React.ElementRef<typeof SelectPrimitive.Content>,
60
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
61
+ >(({ className, children, position = "popper", ...props }, ref) => (
62
+ <SelectPrimitive.Portal>
63
+ <SelectPrimitive.Content
64
+ ref={ref}
65
+ className={cn(
66
+ "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-white",
67
+ position === "popper" &&
68
+ "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
69
+ className
70
+ )}
71
+ position={position}
72
+ {...props}
73
+ >
74
+ <SelectScrollUpButton />
75
+ <SelectPrimitive.Viewport
76
+ className={cn("p-1", position === "popper" && "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]")}
77
+ >
78
+ {children}
79
+ </SelectPrimitive.Viewport>
80
+ <SelectScrollDownButton />
81
+ </SelectPrimitive.Content>
82
+ </SelectPrimitive.Portal>
83
+ ))
84
+ SelectContent.displayName = SelectPrimitive.Content.displayName
85
+
86
+ const SelectLabel = React.forwardRef<
87
+ React.ElementRef<typeof SelectPrimitive.Label>,
88
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
89
+ >(({ className, ...props }, ref) => (
90
+ <SelectPrimitive.Label ref={ref} className={cn("px-2 py-1.5 text-sm font-semibold", className)} {...props} />
91
+ ))
92
+ SelectLabel.displayName = SelectPrimitive.Label.displayName
93
+
94
+ const SelectItem = React.forwardRef<
95
+ React.ElementRef<typeof SelectPrimitive.Item>,
96
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
97
+ >(({ className, children, ...props }, ref) => (
98
+ <SelectPrimitive.Item
99
+ ref={ref}
100
+ className={cn(
101
+ "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-gray-100 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-gray-100",
102
+ className
103
+ )}
104
+ {...props}
105
+ >
106
+ <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
107
+ <SelectPrimitive.ItemIndicator>
108
+ <Check className="h-4 w-4" />
109
+ </SelectPrimitive.ItemIndicator>
110
+ </span>
111
+ <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
112
+ </SelectPrimitive.Item>
113
+ ))
114
+ SelectItem.displayName = SelectPrimitive.Item.displayName
115
+
116
+ const SelectSeparator = React.forwardRef<
117
+ React.ElementRef<typeof SelectPrimitive.Separator>,
118
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
119
+ >(({ className, ...props }, ref) => (
120
+ <SelectPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
121
+ ))
122
+ SelectSeparator.displayName = SelectPrimitive.Separator.displayName
123
+
124
+ export {
125
+ Select,
126
+ SelectGroup,
127
+ SelectValue,
128
+ SelectTrigger,
129
+ SelectContent,
130
+ SelectLabel,
131
+ SelectItem,
132
+ SelectSeparator,
133
+ SelectScrollUpButton,
134
+ SelectScrollDownButton,
135
+ }
@@ -0,0 +1,59 @@
1
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
2
+ import { toast } from 'sonner';
3
+ import {
4
+ fetchMarketplaceItem,
5
+ fetchMarketplaceInstalled,
6
+ fetchMarketplaceItems,
7
+ fetchMarketplaceRecommendations,
8
+ installMarketplaceItem,
9
+ type MarketplaceListParams
10
+ } from '@/api/marketplace';
11
+ import type { MarketplaceInstallRequest, MarketplaceItemType } from '@/api/types';
12
+
13
+ export function useMarketplaceItems(params: MarketplaceListParams) {
14
+ return useQuery({
15
+ queryKey: ['marketplace-items', params],
16
+ queryFn: () => fetchMarketplaceItems(params),
17
+ staleTime: 15_000
18
+ });
19
+ }
20
+
21
+ export function useMarketplaceRecommendations(params: { scene?: string; limit?: number }) {
22
+ return useQuery({
23
+ queryKey: ['marketplace-recommendations', params],
24
+ queryFn: () => fetchMarketplaceRecommendations(params),
25
+ staleTime: 30_000
26
+ });
27
+ }
28
+
29
+ export function useMarketplaceItem(slug: string | null, type?: MarketplaceItemType) {
30
+ return useQuery({
31
+ queryKey: ['marketplace-item', slug, type],
32
+ queryFn: () => fetchMarketplaceItem(slug as string, type),
33
+ enabled: Boolean(slug),
34
+ staleTime: 30_000
35
+ });
36
+ }
37
+
38
+ export function useMarketplaceInstalled() {
39
+ return useQuery({
40
+ queryKey: ['marketplace-installed'],
41
+ queryFn: fetchMarketplaceInstalled,
42
+ staleTime: 10_000
43
+ });
44
+ }
45
+
46
+ export function useInstallMarketplaceItem() {
47
+ const queryClient = useQueryClient();
48
+
49
+ return useMutation({
50
+ mutationFn: (request: MarketplaceInstallRequest) => installMarketplaceItem(request),
51
+ onSuccess: (result) => {
52
+ queryClient.invalidateQueries({ queryKey: ['marketplace-installed'] });
53
+ toast.success(result.message || `${result.type} installed`);
54
+ },
55
+ onError: (error: Error) => {
56
+ toast.error(error.message || 'Install failed');
57
+ }
58
+ });
59
+ }
package/src/index.css CHANGED
@@ -84,6 +84,7 @@
84
84
  }
85
85
 
86
86
  @layer utilities {
87
+
87
88
  /* ========================================
88
89
  SCROLLBAR
89
90
  ======================================== */
@@ -126,11 +127,11 @@
126
127
  SHADOWS
127
128
  ======================================== */
128
129
  .shadow-card {
129
- box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.05), 0 1px 2px -1px rgb(0 0 0 / 0.05);
130
+ box-shadow: 0 2px 8px -2px rgba(0, 0, 0, 0.04), 0 1px 2px -1px rgba(0, 0, 0, 0.02);
130
131
  }
131
132
 
132
133
  .shadow-card-hover {
133
- box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.08), 0 4px 6px -4px rgb(0 0 0 / 0.05);
134
+ box-shadow: 0 12px 24px -4px rgba(0, 0, 0, 0.06), 0 4px 8px -4px rgba(0, 0, 0, 0.03);
134
135
  }
135
136
 
136
137
  .shadow-premium {
@@ -170,6 +171,7 @@
170
171
  opacity: 0;
171
172
  transform: translateY(12px);
172
173
  }
174
+
173
175
  to {
174
176
  opacity: 1;
175
177
  transform: translateY(0);
@@ -181,6 +183,7 @@
181
183
  opacity: 0;
182
184
  transform: translateX(-12px);
183
185
  }
186
+
184
187
  to {
185
188
  opacity: 1;
186
189
  transform: translateX(0);
@@ -192,6 +195,7 @@
192
195
  opacity: 0;
193
196
  transform: scale(0.97);
194
197
  }
198
+
195
199
  to {
196
200
  opacity: 1;
197
201
  transform: scale(1);
@@ -199,9 +203,12 @@
199
203
  }
200
204
 
201
205
  @keyframes pulse-soft {
202
- 0%, 100% {
206
+
207
+ 0%,
208
+ 100% {
203
209
  opacity: 1;
204
210
  }
211
+
205
212
  50% {
206
213
  opacity: 0.8;
207
214
  }
@@ -221,4 +228,4 @@
221
228
 
222
229
  .animate-pulse-soft {
223
230
  animation: pulse-soft 3s ease-in-out infinite;
224
- }
231
+ }
package/src/lib/i18n.ts CHANGED
@@ -159,7 +159,16 @@ export const LABELS: Record<string, { zh: string; en: string }> = {
159
159
  feishuVerifyFailed: { zh: '验证失败', en: 'Verification failed' },
160
160
  enterTag: { zh: '输入后按回车...', en: 'Type and press Enter...' },
161
161
  headerName: { zh: 'Header 名称', en: 'Header Name' },
162
- headerValue: { zh: 'Header 值', en: 'Header Value' }
162
+ headerValue: { zh: 'Header 值', en: 'Header Value' },
163
+
164
+ // Doc Browser
165
+ docBrowserTitle: { zh: '帮助文档', en: 'Help Docs' },
166
+ docBrowserSearchPlaceholder: { zh: '搜索,也可以输入文档地址直接打开', en: 'Search, or enter a doc URL to open' },
167
+ docBrowserOpenExternal: { zh: '文档中心打开', en: 'Open in Docs' },
168
+ docBrowserFloatMode: { zh: '悬浮窗口', en: 'Float Window' },
169
+ docBrowserDockMode: { zh: '固定到侧栏', en: 'Dock to Sidebar' },
170
+ docBrowserClose: { zh: '关闭', en: 'Close' },
171
+ docBrowserHelp: { zh: '帮助文档', en: 'Help Docs' },
163
172
  };
164
173
 
165
174
  export function t(key: string, lang: 'zh' | 'en' = 'en'): string {