Skip to content

Commit bbb9480

Browse files
Merge pull request #339 from CrewForm/feat/team-marketplace
feat: add teams to marketplace
2 parents 064d144 + bf3321a commit bbb9480

10 files changed

Lines changed: 1259 additions & 43 deletions

File tree

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
// Copyright (C) 2026 CrewForm
3+
4+
import { useState, useEffect } from 'react'
5+
import { X, Upload, Loader2, Plus, Tag, FileText, Eye, Edit3 } from 'lucide-react'
6+
import { toast } from 'sonner'
7+
import { useAuth } from '@/hooks/useAuth'
8+
import { useSubmitTeam } from '@/hooks/useMarketplace'
9+
import type { Team } from '@/types'
10+
11+
interface PublishTeamModalProps {
12+
team: Team | null
13+
onClose: () => void
14+
}
15+
16+
const SUGGESTED_TAGS = [
17+
'productivity', 'writing', 'coding', 'research', 'marketing',
18+
'data-analysis', 'customer-support', 'pipeline', 'orchestrator',
19+
'collaboration', 'automation', 'devops', 'sales', 'creative',
20+
]
21+
22+
export function PublishTeamModal({ team, onClose }: PublishTeamModalProps) {
23+
const { user } = useAuth()
24+
const submitMutation = useSubmitTeam()
25+
const [tags, setTags] = useState<string[]>([])
26+
const [newTag, setNewTag] = useState('')
27+
const [readme, setReadme] = useState('')
28+
const [readmePreview, setReadmePreview] = useState(false)
29+
30+
// Pre-fill existing tags
31+
useEffect(() => {
32+
if (team?.marketplace_tags && team.marketplace_tags.length > 0) {
33+
setTags(team.marketplace_tags)
34+
} else {
35+
setTags([])
36+
}
37+
}, [team?.marketplace_tags])
38+
39+
// Pre-fill existing README
40+
useEffect(() => {
41+
setReadme(team?.marketplace_readme ?? '')
42+
}, [team?.marketplace_readme])
43+
44+
if (!team) return null
45+
46+
const addTag = (tag: string) => {
47+
const normalized = tag.toLowerCase().trim()
48+
if (normalized && !tags.includes(normalized)) {
49+
setTags((prev) => [...prev, normalized])
50+
}
51+
setNewTag('')
52+
}
53+
54+
const removeTag = (tag: string) => {
55+
setTags((prev) => prev.filter((t) => t !== tag))
56+
}
57+
58+
const handleSubmit = () => {
59+
if (!user) return
60+
submitMutation.mutate(
61+
{ teamId: team.id, tags, readme, userId: user.id },
62+
{
63+
onSuccess: () => {
64+
toast.success('Team submitted for review! You\'ll be notified when it\'s approved.')
65+
onClose()
66+
},
67+
},
68+
)
69+
}
70+
71+
return (
72+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
73+
<div className="w-full max-w-lg rounded-xl border border-border bg-surface-primary shadow-2xl">
74+
{/* Header */}
75+
<div className="flex items-center justify-between border-b border-border px-5 py-4">
76+
<div className="flex items-center gap-2">
77+
<Upload className="h-5 w-5 text-brand-primary" />
78+
<h2 className="text-lg font-semibold text-gray-100">Publish Team to Marketplace</h2>
79+
</div>
80+
<button type="button" onClick={onClose} className="rounded-lg p-1 text-gray-500 hover:text-gray-300">
81+
<X className="h-5 w-5" />
82+
</button>
83+
</div>
84+
85+
<div className="space-y-5 p-5">
86+
{/* Team info */}
87+
<div className="rounded-lg border border-border bg-surface-card p-3">
88+
<p className="text-sm font-medium text-gray-200">{team.name}</p>
89+
<p className="mt-1 text-xs text-gray-500">{team.description}</p>
90+
<span className="mt-2 inline-block rounded-full bg-surface-overlay px-2 py-0.5 text-[10px] font-medium text-gray-400 capitalize">
91+
{team.mode} mode
92+
</span>
93+
</div>
94+
95+
{/* Note about included agents */}
96+
<div className="rounded-lg border border-blue-500/20 bg-blue-500/5 px-4 py-3">
97+
<p className="text-xs text-blue-300">
98+
All member agents will be included in the published team. Users who install this team
99+
will receive copies of each agent with their configurations.
100+
</p>
101+
</div>
102+
103+
{/* README */}
104+
<div>
105+
<div className="mb-2 flex items-center justify-between">
106+
<label className="flex items-center gap-1 text-xs font-medium text-gray-400">
107+
<FileText className="h-3 w-3" />
108+
README (optional — Markdown supported)
109+
</label>
110+
{readme.trim() && (
111+
<button
112+
type="button"
113+
onClick={() => setReadmePreview(!readmePreview)}
114+
className="flex items-center gap-1 text-[10px] text-gray-500 hover:text-gray-300"
115+
>
116+
{readmePreview ? <Edit3 className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
117+
{readmePreview ? 'Edit' : 'Preview'}
118+
</button>
119+
)}
120+
</div>
121+
{readmePreview ? (
122+
<div
123+
className="prose prose-invert prose-sm max-w-none rounded-lg border border-border bg-surface-card p-3 text-gray-300"
124+
dangerouslySetInnerHTML={{ __html: readme
125+
.replace(/^### (.*$)/gm, '<h4 class="text-gray-200 text-sm font-semibold mt-3 mb-1">$1</h4>')
126+
.replace(/^## (.*$)/gm, '<h3 class="text-gray-100 text-sm font-bold mt-4 mb-1">$1</h3>')
127+
.replace(/^# (.*$)/gm, '<h2 class="text-gray-100 text-base font-bold mt-4 mb-2">$1</h2>')
128+
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
129+
.replace(/\*(.*?)\*/g, '<em>$1</em>')
130+
.replace(/`(.*?)`/g, '<code class="rounded bg-surface-overlay px-1 py-0.5 text-[11px] text-brand-primary">$1</code>')
131+
.replace(/^- (.*$)/gm, '<li class="ml-4 list-disc text-xs text-gray-400">$1</li>')
132+
.replace(/\n/g, '<br />')
133+
}}
134+
/>
135+
) : (
136+
<textarea
137+
value={readme}
138+
onChange={(e) => setReadme(e.target.value)}
139+
placeholder={`## What This Team Does\n\nDescribe the team workflow...\n\n## Use Cases\n\n- Research → Write → Review pipeline\n- Customer support escalation\n\n## Included Agents\n\n- Agent 1: Researcher\n- Agent 2: Writer`}
140+
rows={6}
141+
className="w-full rounded-lg border border-border bg-surface-card px-3 py-2 text-xs text-gray-200 placeholder-gray-600 outline-none focus:border-brand-primary font-mono"
142+
/>
143+
)}
144+
</div>
145+
146+
{/* Tags */}
147+
<div>
148+
<label className="mb-2 block text-xs font-medium text-gray-400">
149+
<Tag className="mr-1 inline h-3 w-3" />
150+
Tags (at least 1 required)
151+
</label>
152+
153+
<div className="mb-2 flex flex-wrap gap-1.5">
154+
{tags.map((tag) => (
155+
<span
156+
key={tag}
157+
className="flex items-center gap-1 rounded-md bg-brand-primary/10 px-2 py-0.5 text-xs font-medium text-brand-primary"
158+
>
159+
{tag}
160+
<button type="button" onClick={() => removeTag(tag)} className="hover:text-red-400">
161+
<X className="h-3 w-3" />
162+
</button>
163+
</span>
164+
))}
165+
</div>
166+
167+
<div className="flex gap-2">
168+
<input
169+
type="text"
170+
value={newTag}
171+
onChange={(e) => setNewTag(e.target.value)}
172+
onKeyDown={(e) => e.key === 'Enter' && addTag(newTag)}
173+
placeholder="Add a tag..."
174+
className="flex-1 rounded-lg border border-border bg-surface-card px-3 py-1.5 text-xs text-gray-200 outline-none focus:border-brand-primary"
175+
/>
176+
<button
177+
type="button"
178+
onClick={() => addTag(newTag)}
179+
disabled={!newTag.trim()}
180+
className="rounded-lg border border-border px-2 py-1.5 text-xs text-gray-400 hover:bg-surface-elevated disabled:opacity-30"
181+
>
182+
<Plus className="h-3 w-3" />
183+
</button>
184+
</div>
185+
186+
<div className="mt-2 flex flex-wrap gap-1">
187+
{SUGGESTED_TAGS.filter((t) => !tags.includes(t)).slice(0, 8).map((tag) => (
188+
<button
189+
key={tag}
190+
type="button"
191+
onClick={() => addTag(tag)}
192+
className="rounded-md border border-border px-2 py-0.5 text-[10px] text-gray-500 hover:border-brand-primary hover:text-brand-primary"
193+
>
194+
+ {tag}
195+
</button>
196+
))}
197+
</div>
198+
</div>
199+
</div>
200+
201+
{/* Footer */}
202+
<div className="flex items-center justify-end gap-2 border-t border-border px-5 py-3">
203+
<button
204+
type="button"
205+
onClick={onClose}
206+
className="rounded-lg border border-border px-4 py-2 text-xs font-medium text-gray-400 hover:text-gray-200"
207+
>
208+
Cancel
209+
</button>
210+
<button
211+
type="button"
212+
onClick={handleSubmit}
213+
disabled={tags.length === 0 || submitMutation.isPending}
214+
className="flex items-center gap-1.5 rounded-lg bg-brand-primary px-4 py-2 text-xs font-semibold text-black transition-colors hover:bg-brand-hover disabled:opacity-50"
215+
>
216+
{submitMutation.isPending ? (
217+
<Loader2 className="h-3 w-3 animate-spin" />
218+
) : (
219+
<Upload className="h-3 w-3" />
220+
)}
221+
Submit for Review
222+
</button>
223+
</div>
224+
</div>
225+
</div>
226+
)
227+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
// Copyright (C) 2026 CrewForm
3+
4+
import { Star, Download, GitBranch, Users, Zap } from 'lucide-react'
5+
import type { Team } from '@/types'
6+
7+
interface TeamCardProps {
8+
team: Team
9+
onClick: (team: Team) => void
10+
}
11+
12+
const modeConfig: Record<string, { label: string; color: string; icon: typeof GitBranch }> = {
13+
pipeline: { label: 'Pipeline', color: 'bg-blue-500/10 text-blue-400', icon: GitBranch },
14+
orchestrator: { label: 'Orchestrator', color: 'bg-purple-500/10 text-purple-400', icon: Zap },
15+
collaboration: { label: 'Collaboration', color: 'bg-green-500/10 text-green-400', icon: Users },
16+
}
17+
18+
export function TeamCard({ team, onClick }: TeamCardProps) {
19+
const mode = modeConfig[team.mode] ?? modeConfig.pipeline
20+
const ModeIcon = mode.icon
21+
22+
// Count agents in team config
23+
const agentCount = getTeamAgentCount(team)
24+
25+
return (
26+
<button
27+
type="button"
28+
onClick={() => onClick(team)}
29+
className="group w-full rounded-xl border border-border bg-surface-card p-5 text-left transition-all hover:border-brand-primary/40 hover:shadow-lg hover:shadow-brand-primary/5"
30+
>
31+
{/* Header */}
32+
<div className="mb-3 flex items-start justify-between">
33+
<div className="flex-1 min-w-0">
34+
<h3 className="truncate text-base font-semibold text-gray-100 group-hover:text-brand-primary transition-colors">
35+
{team.name}
36+
</h3>
37+
<div className="mt-1 flex items-center gap-2">
38+
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${mode.color}`}>
39+
<ModeIcon className="h-3 w-3" />
40+
{mode.label}
41+
</span>
42+
<span className="text-xs text-gray-500">
43+
{agentCount} agent{agentCount !== 1 ? 's' : ''}
44+
</span>
45+
</div>
46+
</div>
47+
</div>
48+
49+
{/* Description */}
50+
<p className="mb-3 line-clamp-2 text-sm text-gray-400">
51+
{team.description}
52+
</p>
53+
54+
{/* Tags */}
55+
<div className="mb-3 flex flex-wrap gap-1.5">
56+
{team.marketplace_tags.map((tag) => (
57+
<span
58+
key={tag}
59+
className="rounded-md bg-surface-overlay px-2 py-0.5 text-xs text-gray-400"
60+
>
61+
{tag}
62+
</span>
63+
))}
64+
</div>
65+
66+
{/* Stats */}
67+
<div className="flex items-center gap-4 text-xs text-gray-500">
68+
<span className="flex items-center gap-1">
69+
<Download className="h-3.5 w-3.5" />
70+
{team.install_count.toLocaleString()}
71+
</span>
72+
<span className="flex items-center gap-1">
73+
<Star className="h-3.5 w-3.5 text-amber-400" />
74+
{team.rating_avg.toFixed(1)}
75+
</span>
76+
<span className="ml-auto text-gray-600 capitalize">{team.mode}</span>
77+
</div>
78+
</button>
79+
)
80+
}
81+
82+
function getTeamAgentCount(team: Team): number {
83+
const config = team.config
84+
const ids = new Set<string>()
85+
86+
if ('steps' in config) {
87+
for (const step of config.steps) {
88+
ids.add(step.agent_id)
89+
if (step.parallel_agents) step.parallel_agents.forEach(id => ids.add(id))
90+
if (step.merge_agent_id) ids.add(step.merge_agent_id)
91+
}
92+
}
93+
if ('brain_agent_id' in config && config.brain_agent_id) ids.add(config.brain_agent_id)
94+
if ('worker_agent_ids' in config && Array.isArray(config.worker_agent_ids)) {
95+
config.worker_agent_ids.forEach(id => ids.add(id))
96+
}
97+
if ('agent_ids' in config && Array.isArray(config.agent_ids)) {
98+
config.agent_ids.forEach((id: string) => ids.add(id))
99+
}
100+
101+
return ids.size || 1
102+
}

0 commit comments

Comments
 (0)