nitrostack 1.0.65 → 1.0.66

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 (55) hide show
  1. package/package.json +2 -1
  2. package/src/studio/README.md +140 -0
  3. package/src/studio/app/api/auth/fetch-metadata/route.ts +71 -0
  4. package/src/studio/app/api/auth/register-client/route.ts +67 -0
  5. package/src/studio/app/api/chat/route.ts +250 -0
  6. package/src/studio/app/api/health/checks/route.ts +42 -0
  7. package/src/studio/app/api/health/route.ts +13 -0
  8. package/src/studio/app/api/init/route.ts +109 -0
  9. package/src/studio/app/api/ping/route.ts +13 -0
  10. package/src/studio/app/api/prompts/[name]/route.ts +21 -0
  11. package/src/studio/app/api/prompts/route.ts +13 -0
  12. package/src/studio/app/api/resources/[...uri]/route.ts +18 -0
  13. package/src/studio/app/api/resources/route.ts +13 -0
  14. package/src/studio/app/api/roots/route.ts +13 -0
  15. package/src/studio/app/api/sampling/route.ts +14 -0
  16. package/src/studio/app/api/tools/[name]/call/route.ts +41 -0
  17. package/src/studio/app/api/tools/route.ts +23 -0
  18. package/src/studio/app/api/widget-examples/route.ts +44 -0
  19. package/src/studio/app/auth/callback/page.tsx +175 -0
  20. package/src/studio/app/auth/page.tsx +560 -0
  21. package/src/studio/app/chat/page.tsx +1133 -0
  22. package/src/studio/app/chat/page.tsx.backup +390 -0
  23. package/src/studio/app/globals.css +486 -0
  24. package/src/studio/app/health/page.tsx +179 -0
  25. package/src/studio/app/layout.tsx +68 -0
  26. package/src/studio/app/logs/page.tsx +279 -0
  27. package/src/studio/app/page.tsx +351 -0
  28. package/src/studio/app/page.tsx.backup +346 -0
  29. package/src/studio/app/ping/page.tsx +209 -0
  30. package/src/studio/app/prompts/page.tsx +230 -0
  31. package/src/studio/app/resources/page.tsx +315 -0
  32. package/src/studio/app/settings/page.tsx +199 -0
  33. package/src/studio/branding.md +807 -0
  34. package/src/studio/components/EnlargeModal.tsx +138 -0
  35. package/src/studio/components/LogMessage.tsx +153 -0
  36. package/src/studio/components/MarkdownRenderer.tsx +410 -0
  37. package/src/studio/components/Sidebar.tsx +295 -0
  38. package/src/studio/components/ToolCard.tsx +139 -0
  39. package/src/studio/components/WidgetRenderer.tsx +346 -0
  40. package/src/studio/lib/api.ts +207 -0
  41. package/src/studio/lib/http-client-transport.ts +222 -0
  42. package/src/studio/lib/llm-service.ts +480 -0
  43. package/src/studio/lib/log-manager.ts +76 -0
  44. package/src/studio/lib/mcp-client.ts +258 -0
  45. package/src/studio/lib/store.ts +192 -0
  46. package/src/studio/lib/theme-provider.tsx +50 -0
  47. package/src/studio/lib/types.ts +107 -0
  48. package/src/studio/lib/widget-loader.ts +90 -0
  49. package/src/studio/middleware.ts +27 -0
  50. package/src/studio/next.config.js +38 -0
  51. package/src/studio/package.json +35 -0
  52. package/src/studio/postcss.config.mjs +10 -0
  53. package/src/studio/public/nitrocloud.png +0 -0
  54. package/src/studio/tailwind.config.ts +67 -0
  55. package/src/studio/tsconfig.json +42 -0
@@ -0,0 +1,230 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { useStudioStore } from '@/lib/store';
5
+ import { api } from '@/lib/api';
6
+ import type { Prompt } from '@/lib/types';
7
+ import { FileText, RefreshCw, X, Play, AlertCircle } from 'lucide-react';
8
+
9
+ export default function PromptsPage() {
10
+ const { prompts, setPrompts, loading, setLoading } = useStudioStore();
11
+ const [searchQuery, setSearchQuery] = useState('');
12
+ const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null);
13
+ const [promptArgs, setPromptArgs] = useState<Record<string, string>>({});
14
+ const [promptResult, setPromptResult] = useState<any>(null);
15
+ const [executing, setExecuting] = useState(false);
16
+
17
+ useEffect(() => {
18
+ loadPrompts();
19
+ }, []);
20
+
21
+ const loadPrompts = async () => {
22
+ setLoading('prompts', true);
23
+ try {
24
+ const data = await api.getPrompts();
25
+ setPrompts(data.prompts || []);
26
+ } catch (error) {
27
+ console.error('Failed to load prompts:', error);
28
+ } finally {
29
+ setLoading('prompts', false);
30
+ }
31
+ };
32
+
33
+ const handleExecutePrompt = async (e: React.FormEvent) => {
34
+ e.preventDefault();
35
+ if (!selectedPrompt) return;
36
+
37
+ setExecuting(true);
38
+ setPromptResult(null);
39
+
40
+ try {
41
+ const result = await api.executePrompt(selectedPrompt.name, promptArgs);
42
+ setPromptResult(result);
43
+ } catch (error) {
44
+ console.error('Prompt execution failed:', error);
45
+ setPromptResult({ error: 'Execution failed' });
46
+ } finally {
47
+ setExecuting(false);
48
+ }
49
+ };
50
+
51
+ const filteredPrompts = prompts.filter((prompt) =>
52
+ prompt.name.toLowerCase().includes(searchQuery.toLowerCase())
53
+ );
54
+
55
+ return (
56
+ <div className="fixed inset-0 flex flex-col bg-background" style={{ left: 'var(--sidebar-width, 15rem)' }}>
57
+ {/* Sticky Header */}
58
+ <div className="sticky top-0 z-10 border-b border-border/50 px-6 py-3 flex items-center justify-between bg-card/80 backdrop-blur-md shadow-sm">
59
+ <div className="flex items-center gap-3">
60
+ <div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-500 flex items-center justify-center shadow-md">
61
+ <FileText className="w-5 h-5 text-white" strokeWidth={2.5} />
62
+ </div>
63
+ <div>
64
+ <h1 className="text-lg font-bold text-foreground">Prompts</h1>
65
+ </div>
66
+ </div>
67
+ <button onClick={loadPrompts} className="btn btn-primary text-sm px-4 py-2 gap-2">
68
+ <RefreshCw className="w-4 h-4" />
69
+ Refresh
70
+ </button>
71
+ </div>
72
+
73
+ {/* Content - ONLY this scrolls */}
74
+ <div className="flex-1 overflow-y-auto overflow-x-hidden">
75
+ <div className="max-w-7xl mx-auto px-6 py-6">
76
+ <input
77
+ type="text"
78
+ placeholder="Search prompts..."
79
+ value={searchQuery}
80
+ onChange={(e) => setSearchQuery(e.target.value)}
81
+ className="input mb-6"
82
+ />
83
+
84
+ {/* Prompts Grid */}
85
+ {loading.prompts ? (
86
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
87
+ {[1, 2, 3].map((i) => (
88
+ <div key={i} className="card skeleton h-40"></div>
89
+ ))}
90
+ </div>
91
+ ) : filteredPrompts.length === 0 ? (
92
+ <div className="empty-state">
93
+ <AlertCircle className="empty-state-icon" />
94
+ <p className="empty-state-title">
95
+ {searchQuery ? 'No prompts found' : 'No prompts available'}
96
+ </p>
97
+ <p className="empty-state-description">
98
+ {searchQuery ? 'Try a different search term' : 'No prompts have been registered'}
99
+ </p>
100
+ </div>
101
+ ) : (
102
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
103
+ {filteredPrompts.map((prompt) => (
104
+ <div
105
+ key={prompt.name}
106
+ className="card card-hover p-6 cursor-pointer animate-fade-in"
107
+ onClick={() => {
108
+ setSelectedPrompt(prompt);
109
+ setPromptArgs({});
110
+ setPromptResult(null);
111
+ }}
112
+ >
113
+ <div className="flex items-center gap-3 mb-3">
114
+ <div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
115
+ <FileText className="w-5 h-5 text-blue-500" />
116
+ </div>
117
+ <h3 className="font-semibold text-foreground">{prompt.name}</h3>
118
+ </div>
119
+
120
+ <p className="text-sm text-muted-foreground line-clamp-2 mb-3">
121
+ {prompt.description || 'No description'}
122
+ </p>
123
+
124
+ {prompt.arguments && prompt.arguments.length > 0 && (
125
+ <div className="flex items-center gap-1 text-xs text-muted-foreground">
126
+ <span className="badge badge-secondary">
127
+ {prompt.arguments.length} argument{prompt.arguments.length !== 1 ? 's' : ''}
128
+ </span>
129
+ </div>
130
+ )}
131
+ </div>
132
+ ))}
133
+ </div>
134
+ )}
135
+ </div>
136
+ </div>
137
+
138
+ {/* Prompt Executor Modal */}
139
+ {selectedPrompt && (
140
+ <div
141
+ className="fixed inset-0 z-50 flex items-center justify-center animate-fade-in"
142
+ style={{ backgroundColor: 'rgba(0, 0, 0, 0.85)' }}
143
+ onClick={() => setSelectedPrompt(null)}
144
+ >
145
+ <div
146
+ className="bg-card rounded-2xl p-6 w-[600px] max-h-[80vh] overflow-auto border border-border shadow-2xl animate-scale-in"
147
+ onClick={(e) => e.stopPropagation()}
148
+ >
149
+ <div className="flex items-center justify-between mb-4">
150
+ <div className="flex items-center gap-3">
151
+ <div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
152
+ <FileText className="w-5 h-5 text-blue-500" />
153
+ </div>
154
+ <h2 className="text-xl font-bold text-foreground">{selectedPrompt.name}</h2>
155
+ </div>
156
+ <button
157
+ onClick={() => setSelectedPrompt(null)}
158
+ className="btn btn-ghost w-10 h-10 p-0"
159
+ >
160
+ <X className="w-5 h-5" />
161
+ </button>
162
+ </div>
163
+
164
+ <p className="text-sm text-muted-foreground mb-6">
165
+ {selectedPrompt.description || 'No description'}
166
+ </p>
167
+
168
+ <form onSubmit={handleExecutePrompt}>
169
+ {selectedPrompt.arguments && selectedPrompt.arguments.length > 0 ? (
170
+ selectedPrompt.arguments.map((arg) => (
171
+ <div key={arg.name} className="mb-4">
172
+ <label className="block text-sm font-medium text-foreground mb-2">
173
+ {arg.name}
174
+ {arg.required && <span className="text-destructive ml-1">*</span>}
175
+ </label>
176
+ <input
177
+ type="text"
178
+ className="input"
179
+ value={promptArgs[arg.name] || ''}
180
+ onChange={(e) =>
181
+ setPromptArgs({ ...promptArgs, [arg.name]: e.target.value })
182
+ }
183
+ required={arg.required}
184
+ placeholder={arg.description || `Enter ${arg.name}`}
185
+ />
186
+ {arg.description && (
187
+ <p className="text-xs text-muted-foreground mt-1">{arg.description}</p>
188
+ )}
189
+ </div>
190
+ ))
191
+ ) : (
192
+ <div className="bg-muted/30 rounded-lg p-4 mb-4">
193
+ <p className="text-sm text-muted-foreground">No arguments required</p>
194
+ </div>
195
+ )}
196
+
197
+ <button type="submit" className="btn btn-primary w-full gap-2" disabled={executing}>
198
+ <Play className="w-4 h-4" />
199
+ {executing ? 'Executing...' : 'Execute Prompt'}
200
+ </button>
201
+ </form>
202
+
203
+ {promptResult && (
204
+ <div className="mt-6">
205
+ <h3 className="font-semibold text-foreground mb-3">Messages:</h3>
206
+ <div className="space-y-3">
207
+ {promptResult.messages?.map((msg: any, idx: number) => {
208
+ // Extract text from content (can be string or object with type/text)
209
+ const contentText = typeof msg.content === 'string'
210
+ ? msg.content
211
+ : msg.content?.text || JSON.stringify(msg.content);
212
+
213
+ return (
214
+ <div key={idx} className="bg-muted/30 border border-border p-4 rounded-lg">
215
+ <div className="text-xs text-muted-foreground mb-2 uppercase font-semibold">
216
+ {msg.role}
217
+ </div>
218
+ <div className="text-sm whitespace-pre-wrap text-foreground">{contentText}</div>
219
+ </div>
220
+ );
221
+ })}
222
+ </div>
223
+ </div>
224
+ )}
225
+ </div>
226
+ </div>
227
+ )}
228
+ </div>
229
+ );
230
+ }
@@ -0,0 +1,315 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState, useCallback } from 'react';
4
+ import { useStudioStore } from '@/lib/store';
5
+ import { api } from '@/lib/api';
6
+ import { WidgetRenderer } from '@/components/WidgetRenderer';
7
+ import type { Resource } from '@/lib/types';
8
+ import { Package, RefreshCw, Palette, Maximize2, Sparkles, AlertCircle } from 'lucide-react';
9
+
10
+ interface WidgetExample {
11
+ name: string;
12
+ description: string;
13
+ data: Record<string, any>;
14
+ }
15
+
16
+ interface WidgetMetadata {
17
+ uri: string;
18
+ name: string;
19
+ description: string;
20
+ examples: WidgetExample[];
21
+ tags?: string[];
22
+ }
23
+
24
+ export default function ResourcesPage() {
25
+ const { resources, setResources, loading, setLoading, openEnlargeModal } = useStudioStore();
26
+ const [searchQuery, setSearchQuery] = useState('');
27
+ const [widgets, setWidgets] = useState<WidgetMetadata[]>([]);
28
+ const [loadingWidgets, setLoadingWidgets] = useState(false);
29
+ const [selectedExamples, setSelectedExamples] = useState<Record<string, number>>({});
30
+
31
+ useEffect(() => {
32
+ loadResources();
33
+ loadWidgets();
34
+ }, []);
35
+
36
+ const loadResources = async () => {
37
+ setLoading('resources', true);
38
+ try {
39
+ const data = await api.getResources();
40
+ setResources(data.resources || []);
41
+ } catch (error) {
42
+ console.error('Failed to load resources:', error);
43
+ } finally {
44
+ setLoading('resources', false);
45
+ }
46
+ };
47
+
48
+ const loadWidgets = async () => {
49
+ setLoadingWidgets(true);
50
+ try {
51
+ console.log('📦 Loading widget examples from API...');
52
+ const data = await api.getWidgetExamples();
53
+ console.log('📦 Widget examples response:', data);
54
+ console.log('📦 Number of widgets loaded:', data.widgets?.length || 0);
55
+
56
+ if (data.widgets && data.widgets.length > 0) {
57
+ console.log('📦 First widget:', data.widgets[0]);
58
+ } else {
59
+ console.warn('⚠️ No widgets returned from API. Check MCP server logs.');
60
+ }
61
+
62
+ setWidgets(data.widgets || []);
63
+ } catch (error) {
64
+ console.error('❌ Failed to load widget examples:', error);
65
+ } finally {
66
+ setLoadingWidgets(false);
67
+ }
68
+ };
69
+
70
+ const isUIWidget = (resource: Resource) => {
71
+ return resource.mimeType === 'text/html' ||
72
+ resource.uri.startsWith('widget://') ||
73
+ resource._meta?.['ui/widget'] === true;
74
+ };
75
+
76
+ const filteredResources = resources.filter(
77
+ (resource) =>
78
+ !isUIWidget(resource) &&
79
+ (resource.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
80
+ resource.uri.toLowerCase().includes(searchQuery.toLowerCase()))
81
+ );
82
+
83
+ const filteredWidgets = widgets.filter((widget) =>
84
+ widget.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
85
+ widget.uri.toLowerCase().includes(searchQuery.toLowerCase()) ||
86
+ widget.description.toLowerCase().includes(searchQuery.toLowerCase())
87
+ );
88
+
89
+ const handleExampleChange = useCallback((widgetUri: string, exampleIndex: number) => {
90
+ setSelectedExamples(prev => ({ ...prev, [widgetUri]: exampleIndex }));
91
+ }, []);
92
+
93
+ const getSelectedExample = useCallback((widgetUri: string) => {
94
+ return selectedExamples[widgetUri] || 0;
95
+ }, [selectedExamples]);
96
+
97
+ const handleWidgetEnlarge = useCallback((e: React.MouseEvent, widget: WidgetMetadata) => {
98
+ e.preventDefault();
99
+ e.stopPropagation();
100
+
101
+ const exampleIndex = getSelectedExample(widget.uri);
102
+
103
+ setTimeout(() => {
104
+ openEnlargeModal('resource', {
105
+ uri: widget.uri,
106
+ name: widget.name,
107
+ description: widget.description,
108
+ responseData: widget.examples[exampleIndex]?.data
109
+ });
110
+ }, 0);
111
+ }, [selectedExamples, openEnlargeModal, getSelectedExample]);
112
+
113
+ return (
114
+ <div className="fixed inset-0 flex flex-col bg-background" style={{ left: 'var(--sidebar-width, 15rem)' }}>
115
+ {/* Sticky Header */}
116
+ <div className="sticky top-0 z-10 border-b border-border/50 px-6 py-3 flex items-center justify-between bg-card/80 backdrop-blur-md shadow-sm">
117
+ <div className="flex items-center gap-3">
118
+ <div className="w-8 h-8 rounded-lg bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center shadow-md">
119
+ <Package className="w-5 h-5 text-white" strokeWidth={2.5} />
120
+ </div>
121
+ <div>
122
+ <h1 className="text-lg font-bold text-foreground">Resources</h1>
123
+ </div>
124
+ </div>
125
+ <button onClick={loadResources} className="btn btn-primary text-sm px-4 py-2 gap-2">
126
+ <RefreshCw className="w-4 h-4" />
127
+ Refresh
128
+ </button>
129
+ </div>
130
+
131
+ {/* Content - ONLY this scrolls */}
132
+ <div className="flex-1 overflow-y-auto overflow-x-hidden">
133
+ <div className="max-w-7xl mx-auto px-6 py-6">
134
+ <input
135
+ type="text"
136
+ placeholder="Search resources..."
137
+ value={searchQuery}
138
+ onChange={(e) => setSearchQuery(e.target.value)}
139
+ className="input mb-6"
140
+ />
141
+
142
+ {/* UI Widgets Section */}
143
+ {(loadingWidgets || widgets.length > 0) && (
144
+ <div className="mb-12">
145
+ <h2 className="text-2xl font-bold text-foreground mb-6 flex items-center gap-2">
146
+ <Palette className="w-6 h-6 text-primary" />
147
+ UI Widgets {widgets.length > 0 && `(${filteredWidgets.length})`}
148
+ </h2>
149
+
150
+ {loadingWidgets ? (
151
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
152
+ {[1, 2].map((i) => (
153
+ <div key={i} className="card skeleton h-96"></div>
154
+ ))}
155
+ </div>
156
+ ) : filteredWidgets.length === 0 ? (
157
+ <div className="card p-8 text-center">
158
+ <AlertCircle className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
159
+ <p className="text-muted-foreground">
160
+ {searchQuery ? 'No widgets match your search' : 'No UI widgets configured'}
161
+ </p>
162
+ </div>
163
+ ) : (
164
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
165
+ {filteredWidgets.map((widget) => (
166
+ <div key={widget.uri} className="card card-hover p-6 animate-fade-in">
167
+ <div className="flex items-center gap-3 mb-4">
168
+ <div className="w-10 h-10 rounded-lg bg-purple-500/10 flex items-center justify-center">
169
+ <Palette className="w-5 h-5 text-purple-500" />
170
+ </div>
171
+ <div className="flex-1">
172
+ <h3 className="font-semibold text-foreground">{widget.name}</h3>
173
+ <span className="badge badge-success text-xs mt-1">
174
+ {widget.examples.length} example{widget.examples.length !== 1 ? 's' : ''}
175
+ </span>
176
+ </div>
177
+ </div>
178
+
179
+ <p className="text-sm text-muted-foreground mb-3 line-clamp-2">
180
+ {widget.description}
181
+ </p>
182
+
183
+ <p className="text-xs text-muted-foreground mb-4 font-mono truncate bg-muted/30 px-2 py-1 rounded">
184
+ {widget.uri}
185
+ </p>
186
+
187
+ {widget.tags && widget.tags.length > 0 && (
188
+ <div className="flex flex-wrap gap-2 mb-4">
189
+ {widget.tags.map((tag) => (
190
+ <span key={tag} className="badge badge-secondary text-xs">
191
+ #{tag}
192
+ </span>
193
+ ))}
194
+ </div>
195
+ )}
196
+
197
+ {/* Widget Preview with selected example */}
198
+ {widget.examples.length > 0 && (() => {
199
+ const selectedIdx = getSelectedExample(widget.uri);
200
+ const selectedExample = widget.examples[selectedIdx];
201
+ return (
202
+ <div className="relative mb-4 rounded-lg overflow-hidden border border-border bg-muted/20">
203
+ <div className="absolute top-2 left-2 z-10 flex items-center gap-1 bg-primary/90 backdrop-blur-sm text-black px-2 py-1 rounded-md text-xs font-semibold shadow-lg">
204
+ <Sparkles className="w-3 h-3" />
205
+ {selectedExample.name}
206
+ </div>
207
+ <div className="h-64">
208
+ <WidgetRenderer
209
+ uri={widget.uri}
210
+ data={selectedExample.data}
211
+ />
212
+ </div>
213
+ </div>
214
+ );
215
+ })()}
216
+
217
+ {/* Example Selector (if more than 1) */}
218
+ {widget.examples.length > 1 && (
219
+ <select
220
+ className="input text-sm mb-4"
221
+ value={getSelectedExample(widget.uri)}
222
+ onChange={(e) => handleExampleChange(widget.uri, parseInt(e.target.value))}
223
+ >
224
+ {widget.examples.map((example, idx) => (
225
+ <option key={idx} value={idx}>
226
+ {example.name}
227
+ </option>
228
+ ))}
229
+ </select>
230
+ )}
231
+
232
+ <button
233
+ onClick={(e) => handleWidgetEnlarge(e, widget)}
234
+ className="btn btn-secondary w-full gap-2"
235
+ >
236
+ <Maximize2 className="w-4 h-4" />
237
+ Enlarge
238
+ </button>
239
+ </div>
240
+ ))}
241
+ </div>
242
+ )}
243
+ </div>
244
+ )}
245
+
246
+ {/* MCP Resources Section */}
247
+ <div>
248
+ <h2 className="text-2xl font-bold text-foreground mb-6 flex items-center gap-2">
249
+ <Package className="w-6 h-6 text-primary" />
250
+ MCP Resources {resources.length > 0 && `(${filteredResources.length})`}
251
+ </h2>
252
+
253
+ {/* Resources Grid */}
254
+ {loading.resources ? (
255
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
256
+ {[1, 2, 3].map((i) => (
257
+ <div key={i} className="card skeleton h-64"></div>
258
+ ))}
259
+ </div>
260
+ ) : filteredResources.length === 0 ? (
261
+ <div className="empty-state">
262
+ <Package className="empty-state-icon" />
263
+ <p className="empty-state-title">
264
+ {searchQuery ? 'No resources found' : 'No resources available'}
265
+ </p>
266
+ <p className="empty-state-description">
267
+ {searchQuery ? 'Try a different search term' : 'No MCP resources have been registered'}
268
+ </p>
269
+ </div>
270
+ ) : (
271
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
272
+ {filteredResources.map((resource) => (
273
+ <div key={resource.uri} className="card card-hover p-6 animate-fade-in">
274
+ <div className="flex items-start justify-between mb-4">
275
+ <div className="flex items-center gap-3">
276
+ <div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
277
+ <Package className="w-5 h-5 text-blue-500" />
278
+ </div>
279
+ <div>
280
+ <h3 className="font-semibold text-foreground">{resource.name}</h3>
281
+ <span className="badge badge-primary text-xs mt-1">
282
+ Resource
283
+ </span>
284
+ </div>
285
+ </div>
286
+ </div>
287
+
288
+ <p className="text-sm text-muted-foreground mb-4 line-clamp-2">
289
+ {resource.description || 'No description'}
290
+ </p>
291
+
292
+ <div className="bg-muted/30 rounded-lg p-3 border border-border">
293
+ <p className="text-xs text-muted-foreground mb-1 font-semibold uppercase tracking-wide">URI</p>
294
+ <p className="text-xs text-foreground font-mono truncate">
295
+ {resource.uri}
296
+ </p>
297
+ </div>
298
+
299
+ {resource.mimeType && (
300
+ <div className="mt-3">
301
+ <span className="badge badge-secondary text-xs">
302
+ {resource.mimeType}
303
+ </span>
304
+ </div>
305
+ )}
306
+ </div>
307
+ ))}
308
+ </div>
309
+ )}
310
+ </div>
311
+ </div>
312
+ </div>
313
+ </div>
314
+ );
315
+ }