cesjavi commited on
Commit
da5f2da
·
1 Parent(s): 5470e6a

Phase 8: Role-Based Marketplace (Internal sharing, team assets filtering)

Browse files
frontend/src/components/AgentsView.tsx CHANGED
@@ -1,5 +1,5 @@
1
  import React, { useEffect, useMemo, useState } from 'react';
2
- import { Bot, CheckCircle2, PlusCircle, RefreshCw, X } from 'lucide-react';
3
  import { motion } from 'framer-motion';
4
  import { supabase } from '../services/supabase';
5
  import { useAuth } from '../context/useAuth';
@@ -30,6 +30,13 @@ const AgentsView: React.FC = () => {
30
  const [saving, setSaving] = useState(false);
31
  const [message, setMessage] = useState<string | null>(null);
32
  const [error, setError] = useState<string | null>(null);
 
 
 
 
 
 
 
33
 
34
  const providerModels = providerOptions.find((option) => option.id === provider)?.models ?? [];
35
  const isEditing = selectedAgentId !== null;
@@ -50,8 +57,18 @@ const AgentsView: React.FC = () => {
50
 
51
  useEffect(() => {
52
  loadAgents();
 
53
  }, []);
54
 
 
 
 
 
 
 
 
 
 
55
  const handleProviderChange = (value: SupportedProvider) => {
56
  setProvider(value);
57
  setModel(getDefaultModel(value));
@@ -112,6 +129,33 @@ const AgentsView: React.FC = () => {
112
  setSaving(false);
113
  };
114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  return (
116
  <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="agents-page animate-fade-in">
117
  <div className="dashboard-heading page-heading">
@@ -207,12 +251,104 @@ const AgentsView: React.FC = () => {
207
  <strong>{agent.name}</strong>
208
  <p>{agent.role || 'No role provided.'}</p>
209
  </div>
210
- <span>{agent.api_provider} / {agent.model}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  </button>
212
  ))}
213
  </div>
214
  </section>
215
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
  </motion.div>
217
  );
218
  };
 
1
  import React, { useEffect, useMemo, useState } from 'react';
2
+ import { Bot, CheckCircle2, PlusCircle, RefreshCw, X, ShoppingBag, Users, Globe } from 'lucide-react';
3
  import { motion } from 'framer-motion';
4
  import { supabase } from '../services/supabase';
5
  import { useAuth } from '../context/useAuth';
 
30
  const [saving, setSaving] = useState(false);
31
  const [message, setMessage] = useState<string | null>(null);
32
  const [error, setError] = useState<string | null>(null);
33
+ const [showShareModal, setShowShareModal] = useState(false);
34
+ const [sharingAgent, setSharingAgent] = useState<Agent | null>(null);
35
+ const [shareToTeam, setShareToTeam] = useState<string | null>(null);
36
+ const [shareDescription, setShareDescription] = useState('');
37
+ const [shareCategory, setShareCategory] = useState('General');
38
+ const [isPublicTemplate, setIsPublicTemplate] = useState(false);
39
+ const [teams, setTeams] = useState<{ id: string; name: string }[]>([]);
40
 
41
  const providerModels = providerOptions.find((option) => option.id === provider)?.models ?? [];
42
  const isEditing = selectedAgentId !== null;
 
57
 
58
  useEffect(() => {
59
  loadAgents();
60
+ fetchTeams();
61
  }, []);
62
 
63
+ const fetchTeams = async () => {
64
+ try {
65
+ const { data } = await supabase.from('teams').select('id, name');
66
+ setTeams(data || []);
67
+ } catch (err) {
68
+ console.error('Failed to fetch teams');
69
+ }
70
+ };
71
+
72
  const handleProviderChange = (value: SupportedProvider) => {
73
  setProvider(value);
74
  setModel(getDefaultModel(value));
 
129
  setSaving(false);
130
  };
131
 
132
+ const handleShareTemplate = async () => {
133
+ if (!sharingAgent || !user) return;
134
+ setSaving(true);
135
+ try {
136
+ const { error } = await supabase.from('agent_templates').insert({
137
+ name: sharingAgent.name,
138
+ role: sharingAgent.role,
139
+ description: shareDescription || `Custom agent: ${sharingAgent.name}`,
140
+ model: sharingAgent.model,
141
+ api_provider: sharingAgent.api_provider,
142
+ system_prompt: sharingAgent.system_prompt,
143
+ category: shareCategory,
144
+ author_id: user.id,
145
+ team_id: isPublicTemplate ? null : shareToTeam,
146
+ is_public: isPublicTemplate
147
+ });
148
+
149
+ if (error) throw error;
150
+ setMessage('Agent shared to marketplace!');
151
+ setShowShareModal(false);
152
+ } catch (err: any) {
153
+ setError(err.message);
154
+ } finally {
155
+ setSaving(false);
156
+ }
157
+ };
158
+
159
  return (
160
  <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="agents-page animate-fade-in">
161
  <div className="dashboard-heading page-heading">
 
251
  <strong>{agent.name}</strong>
252
  <p>{agent.role || 'No role provided.'}</p>
253
  </div>
254
+ <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-md)' }}>
255
+ <span>{agent.api_provider} / {agent.model}</span>
256
+ <button
257
+ className="btn btn-glass btn-sm"
258
+ onClick={(e) => {
259
+ e.stopPropagation();
260
+ setSharingAgent(agent);
261
+ setShowShareModal(true);
262
+ }}
263
+ title="Share to Marketplace"
264
+ >
265
+ <ShoppingBag size={14} />
266
+ </button>
267
+ </div>
268
  </button>
269
  ))}
270
  </div>
271
  </section>
272
  </div>
273
+
274
+ {/* Share Modal */}
275
+ {showShareModal && sharingAgent && (
276
+ <div className="modal-overlay" onClick={() => setShowShareModal(false)}>
277
+ <div className="glass-panel modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: '500px' }}>
278
+ <div className="panel-heading">
279
+ <ShoppingBag size={28} color="var(--accent)" />
280
+ <div>
281
+ <h3>Share as Template</h3>
282
+ <p style={{ fontSize: '0.85rem', color: 'var(--text-dim)' }}>Publish '{sharingAgent.name}' to the Marketplace.</p>
283
+ </div>
284
+ </div>
285
+
286
+ <div style={{ display: 'grid', gap: 'var(--space-md)', marginTop: 'var(--space-lg)' }}>
287
+ <label>
288
+ <span>Description</span>
289
+ <textarea
290
+ className="glass-input"
291
+ value={shareDescription}
292
+ onChange={e => setShareDescription(e.target.value)}
293
+ placeholder="What is this agent expert at?"
294
+ rows={3}
295
+ />
296
+ </label>
297
+
298
+ <div className="responsive-two-col">
299
+ <label>
300
+ <span>Category</span>
301
+ <select className="glass-input" value={shareCategory} onChange={e => setShareCategory(e.target.value)}>
302
+ <option value="General">General</option>
303
+ <option value="Marketing">Marketing</option>
304
+ <option value="Development">Development</option>
305
+ <option value="Legal">Legal</option>
306
+ <option value="Research">Research</option>
307
+ <option value="Finance">Finance</option>
308
+ </select>
309
+ </label>
310
+ <label>
311
+ <span>Visibility</span>
312
+ <select
313
+ className="glass-input"
314
+ value={isPublicTemplate ? 'public' : 'team'}
315
+ onChange={e => setIsPublicTemplate(e.target.value === 'public')}
316
+ >
317
+ <option value="team">Share to Team</option>
318
+ <option value="public">Make Public (Global)</option>
319
+ </select>
320
+ </label>
321
+ </div>
322
+
323
+ {!isPublicTemplate && (
324
+ <label>
325
+ <span>Target Team</span>
326
+ <select
327
+ className="glass-input"
328
+ value={shareToTeam || ''}
329
+ onChange={e => setShareToTeam(e.target.value || null)}
330
+ >
331
+ <option value="">Select a team...</option>
332
+ {teams.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
333
+ </select>
334
+ </label>
335
+ )}
336
+
337
+ <div className="button-row" style={{ marginTop: 'var(--space-lg)' }}>
338
+ <button className="btn btn-glass" onClick={() => setShowShareModal(false)}>Cancel</button>
339
+ <button
340
+ className="btn btn-primary"
341
+ onClick={handleShareTemplate}
342
+ disabled={saving || (!isPublicTemplate && !shareToTeam)}
343
+ >
344
+ <ShoppingBag size={18} />
345
+ {saving ? 'Sharing...' : 'Publish to Marketplace'}
346
+ </button>
347
+ </div>
348
+ </div>
349
+ </div>
350
+ </div>
351
+ )}
352
  </motion.div>
353
  );
354
  };
frontend/src/components/Marketplace.tsx CHANGED
@@ -1,6 +1,6 @@
1
  import React, { useState, useEffect } from 'react';
2
  import { supabase } from '../services/supabase';
3
- import { Star, Download, Search } from 'lucide-react';
4
  import { motion } from 'framer-motion';
5
 
6
  interface AgentTemplate {
@@ -13,29 +13,72 @@ interface AgentTemplate {
13
  category: string;
14
  description: string;
15
  is_featured: boolean;
 
 
 
16
  }
17
 
18
  const Marketplace: React.FC = () => {
19
  const [templates, setTemplates] = useState<AgentTemplate[]>([]);
20
  const [search, setSearch] = useState('');
 
 
 
 
 
21
 
22
  useEffect(() => {
23
  const fetchTemplates = async () => {
24
- const { data } = await supabase.from('agent_templates').select('*');
25
- if (data) setTemplates(data);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  };
27
  fetchTemplates();
28
  }, []);
29
 
30
  const handleDeploy = async (template: AgentTemplate) => {
 
 
 
 
31
  const { data: userData } = await supabase.auth.getUser();
32
  if (!userData.user) {
33
- alert('Please log in to deploy agents.');
 
34
  return;
35
  }
36
 
37
  try {
38
- const { error } = await supabase.from('agents').insert({
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  user_id: userData.user.id,
40
  name: template.name,
41
  role: template.role,
@@ -44,23 +87,23 @@ const Marketplace: React.FC = () => {
44
  system_prompt: template.system_prompt
45
  });
46
 
47
- if (error) throw error;
48
- alert(`${template.name} has been added to your agent fleet!`);
49
- } catch (e) {
50
- const message =
51
- e instanceof Error
52
- ? e.message
53
- : typeof e === 'object' && e !== null && 'message' in e
54
- ? String((e as { message?: unknown }).message)
55
- : 'Unknown error';
56
- alert(`Failed to deploy agent: ${message}`);
57
  }
58
  };
59
 
60
- const filteredTemplates = templates.filter(t =>
61
- t.name.toLowerCase().includes(search.toLowerCase()) ||
62
- t.category.toLowerCase().includes(search.toLowerCase())
63
- );
 
 
 
 
64
 
65
  return (
66
  <div className="animate-fade-in marketplace-page">
@@ -69,39 +112,84 @@ const Marketplace: React.FC = () => {
69
  <h2>Agent Marketplace</h2>
70
  <p style={{ color: 'var(--text-dim)' }}>Deploy pre-configured expert agents to your projects.</p>
71
  </div>
72
- <div className="marketplace-search">
73
- <Search size={18} style={{ position: 'absolute', left: '12px', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)' }} />
74
- <input
75
- type="text"
76
- placeholder="Search experts..."
77
- value={search}
78
- onChange={(e) => setSearch(e.target.value)}
79
- style={{
80
- width: '100%', padding: '0.8rem 1rem 0.8rem 2.5rem',
81
- background: 'rgba(255,255,255,0.05)', border: '1px solid var(--glass-border)',
82
- borderRadius: 'var(--radius-md)', color: 'white', outline: 'none'
83
- }}
84
- />
 
 
85
  </div>
86
  </div>
87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  <div className="marketplace-grid">
89
  {filteredTemplates.map((template, i) => (
90
  <motion.div
91
  key={template.id}
92
  initial={{ opacity: 0, y: 20 }}
93
  animate={{ opacity: 1, y: 0 }}
94
- transition={{ delay: i * 0.1 }}
95
  className="glass-panel hover-lift"
96
  style={{ padding: 'var(--space-lg)', display: 'flex', flexDirection: 'column' }}
97
  >
98
- <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 'var(--space-md)' }}>
99
- <span style={{
100
- padding: '0.25rem 0.75rem', background: 'rgba(255,255,255,0.1)',
101
- borderRadius: 'var(--radius-full)', fontSize: '0.75rem', fontWeight: 600, color: 'var(--accent)'
102
- }}>
103
- {template.category}
104
- </span>
 
 
 
 
 
 
 
 
 
 
 
 
105
  {template.is_featured && <Star size={16} fill="var(--accent)" color="var(--accent)" />}
106
  </div>
107
 
@@ -110,11 +198,16 @@ const Marketplace: React.FC = () => {
110
  {template.description}
111
  </p>
112
 
113
- <div className="marketplace-card-footer">
114
  <span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{template.model}</span>
115
- <button className="btn btn-glass" style={{ padding: '0.5rem 1rem' }} onClick={() => handleDeploy(template)}>
 
 
 
 
 
116
  <Download size={16} />
117
- Deploy
118
  </button>
119
  </div>
120
  </motion.div>
 
1
  import React, { useState, useEffect } from 'react';
2
  import { supabase } from '../services/supabase';
3
+ import { Star, Download, Search, Users } from 'lucide-react';
4
  import { motion } from 'framer-motion';
5
 
6
  interface AgentTemplate {
 
13
  category: string;
14
  description: string;
15
  is_featured: boolean;
16
+ team_id?: string;
17
+ is_public: boolean;
18
+ teams?: { name: string };
19
  }
20
 
21
  const Marketplace: React.FC = () => {
22
  const [templates, setTemplates] = useState<AgentTemplate[]>([]);
23
  const [search, setSearch] = useState('');
24
+ const [loading, setLoading] = useState(true);
25
+ const [deployingId, setDeployingId] = useState<string | null>(null);
26
+ const [message, setMessage] = useState<string | null>(null);
27
+ const [error, setError] = useState<string | null>(null);
28
+ const [activeFilter, setActiveFilter] = useState<'all' | 'public' | 'team'>('all');
29
 
30
  useEffect(() => {
31
  const fetchTemplates = async () => {
32
+ setLoading(true);
33
+ setError(null);
34
+ const { data, error: templateError } = await supabase
35
+ .from('agent_templates')
36
+ .select(`
37
+ *,
38
+ teams:team_id(name)
39
+ `)
40
+ .order('category', { ascending: true })
41
+ .order('name', { ascending: true });
42
+
43
+ if (templateError) {
44
+ setError(templateError.message);
45
+ } else {
46
+ setTemplates(data ?? []);
47
+ }
48
+ setLoading(false);
49
  };
50
  fetchTemplates();
51
  }, []);
52
 
53
  const handleDeploy = async (template: AgentTemplate) => {
54
+ setMessage(null);
55
+ setError(null);
56
+ setDeployingId(template.id);
57
+
58
  const { data: userData } = await supabase.auth.getUser();
59
  if (!userData.user) {
60
+ setError('Please log in to deploy agents.');
61
+ setDeployingId(null);
62
  return;
63
  }
64
 
65
  try {
66
+ const { data: existingAgent, error: lookupError } = await supabase
67
+ .from('agents')
68
+ .select('id')
69
+ .eq('user_id', userData.user.id)
70
+ .eq('name', template.name)
71
+ .eq('role', template.role)
72
+ .limit(1)
73
+ .maybeSingle();
74
+
75
+ if (lookupError) throw lookupError;
76
+ if (existingAgent) {
77
+ setMessage(`${template.name} is already in your agent fleet.`);
78
+ return;
79
+ }
80
+
81
+ const { error: insertError } = await supabase.from('agents').insert({
82
  user_id: userData.user.id,
83
  name: template.name,
84
  role: template.role,
 
87
  system_prompt: template.system_prompt
88
  });
89
 
90
+ if (insertError) throw insertError;
91
+ setMessage(`${template.name} has been added to your agent fleet.`);
92
+ } catch (e: any) {
93
+ setError(`Failed to deploy agent: ${e.message || 'Unknown error'}`);
94
+ } finally {
95
+ setDeployingId(null);
 
 
 
 
96
  }
97
  };
98
 
99
+ const filteredTemplates = templates.filter(t => {
100
+ const matchesSearch = t.name.toLowerCase().includes(search.toLowerCase()) ||
101
+ t.category.toLowerCase().includes(search.toLowerCase());
102
+
103
+ if (activeFilter === 'public') return matchesSearch && t.is_public;
104
+ if (activeFilter === 'team') return matchesSearch && t.team_id !== null;
105
+ return matchesSearch;
106
+ });
107
 
108
  return (
109
  <div className="animate-fade-in marketplace-page">
 
112
  <h2>Agent Marketplace</h2>
113
  <p style={{ color: 'var(--text-dim)' }}>Deploy pre-configured expert agents to your projects.</p>
114
  </div>
115
+ <div className="marketplace-search-container" style={{ display: 'flex', gap: 'var(--space-md)', flex: 1, justifyContent: 'flex-end' }}>
116
+ <div className="marketplace-search" style={{ position: 'relative', width: '300px' }}>
117
+ <Search size={18} style={{ position: 'absolute', left: '12px', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-dim)' }} />
118
+ <input
119
+ type="text"
120
+ placeholder="Search experts..."
121
+ value={search}
122
+ onChange={(e) => setSearch(e.target.value)}
123
+ style={{
124
+ width: '100%', padding: '0.8rem 1rem 0.8rem 2.5rem',
125
+ background: 'rgba(255,255,255,0.05)', border: '1px solid var(--glass-border)',
126
+ borderRadius: 'var(--radius-md)', color: 'white', outline: 'none'
127
+ }}
128
+ />
129
+ </div>
130
  </div>
131
  </div>
132
 
133
+ <div className="filter-tabs" style={{ display: 'flex', gap: 'var(--space-sm)', marginBottom: 'var(--space-lg)' }}>
134
+ <button
135
+ className={`btn ${activeFilter === 'all' ? 'btn-primary' : 'btn-glass'}`}
136
+ onClick={() => setActiveFilter('all')}
137
+ >
138
+ All Assets
139
+ </button>
140
+ <button
141
+ className={`btn ${activeFilter === 'public' ? 'btn-primary' : 'btn-glass'}`}
142
+ onClick={() => setActiveFilter('public')}
143
+ >
144
+ Public
145
+ </button>
146
+ <button
147
+ className={`btn ${activeFilter === 'team' ? 'btn-primary' : 'btn-glass'}`}
148
+ onClick={() => setActiveFilter('team')}
149
+ >
150
+ Team Assets
151
+ </button>
152
+ </div>
153
+
154
+ {error && <div className="inline-status modal-error">{error}</div>}
155
+ {message && <div className="inline-status"><span>{message}</span></div>}
156
+ {loading && <div className="inline-status">Loading marketplace templates...</div>}
157
+ {!loading && filteredTemplates.length === 0 && (
158
+ <div className="glass-panel empty-state">
159
+ <h3>No templates found</h3>
160
+ <p>{search ? 'Try a different search term.' : 'Apply database/marketplace.sql in Supabase to seed marketplace templates.'}</p>
161
+ </div>
162
+ )}
163
+
164
  <div className="marketplace-grid">
165
  {filteredTemplates.map((template, i) => (
166
  <motion.div
167
  key={template.id}
168
  initial={{ opacity: 0, y: 20 }}
169
  animate={{ opacity: 1, y: 0 }}
170
+ transition={{ delay: i * 0.05 }}
171
  className="glass-panel hover-lift"
172
  style={{ padding: 'var(--space-lg)', display: 'flex', flexDirection: 'column' }}
173
  >
174
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-md)' }}>
175
+ <div style={{ display: 'flex', gap: 'var(--space-xs)' }}>
176
+ <span style={{
177
+ padding: '0.25rem 0.75rem', background: 'rgba(255,255,255,0.1)',
178
+ borderRadius: 'var(--radius-full)', fontSize: '0.75rem', fontWeight: 600, color: 'var(--accent)'
179
+ }}>
180
+ {template.category}
181
+ </span>
182
+ {template.team_id && (
183
+ <span style={{
184
+ padding: '0.25rem 0.75rem', background: 'var(--primary)',
185
+ borderRadius: 'var(--radius-full)', fontSize: '0.75rem', fontWeight: 600, color: 'white',
186
+ display: 'flex', alignItems: 'center', gap: '4px'
187
+ }}>
188
+ <Users size={12} />
189
+ {template.teams?.name || 'Team'}
190
+ </span>
191
+ )}
192
+ </div>
193
  {template.is_featured && <Star size={16} fill="var(--accent)" color="var(--accent)" />}
194
  </div>
195
 
 
198
  {template.description}
199
  </p>
200
 
201
+ <div className="marketplace-card-footer" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 'auto' }}>
202
  <span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{template.model}</span>
203
+ <button
204
+ className="btn btn-glass"
205
+ style={{ padding: '0.5rem 1rem' }}
206
+ onClick={() => handleDeploy(template)}
207
+ disabled={deployingId === template.id}
208
+ >
209
  <Download size={16} />
210
+ {deployingId === template.id ? 'Deploying...' : 'Deploy'}
211
  </button>
212
  </div>
213
  </motion.div>