feat: add project.tags and filtering based on tags for public projects

pull/721/head
Travis Fischer 2025-07-04 19:08:22 -05:00
rodzic de80f97d93
commit 07e5887dc9
8 zmienionych plików z 104 dodań i 31 usunięć

Wyświetl plik

@ -4,14 +4,24 @@ import type { DefaultHonoEnv } from '@agentic/platform-hono'
import { parseZodSchema } from '@agentic/platform-core'
import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi'
import { and, db, eq, isNotNull, schema } from '@/db'
import {
and,
arrayContains,
db,
eq,
isNotNull,
isNull,
not,
or,
schema
} from '@/db'
import { setPublicCacheControl } from '@/lib/cache-control'
import {
openapiAuthenticatedSecuritySchemas,
openapiErrorResponses
} from '@/lib/openapi-utils'
import { paginationAndPopulateProjectSchema } from './schemas'
import { listPublicProjectsQuerySchema } from './schemas'
const route = createRoute({
description:
@ -22,7 +32,7 @@ const route = createRoute({
path: 'projects/public',
security: openapiAuthenticatedSecuritySchemas,
request: {
query: paginationAndPopulateProjectSchema
query: listPublicProjectsQuerySchema
},
responses: {
200: {
@ -44,14 +54,24 @@ export function registerV1ListPublicProjects(app: OpenAPIHono<DefaultHonoEnv>) {
limit = 10,
sort = 'desc',
sortBy = 'createdAt',
populate = []
populate = [],
tag,
notTag
} = c.req.valid('query')
const projects = await db.query.projects.findMany({
// List projects that are not private and have at least one published deployment
// And optionally match a given tag
where: and(
eq(schema.projects.private, false),
isNotNull(schema.projects.lastPublishedDeploymentId)
isNotNull(schema.projects.lastPublishedDeploymentId),
tag ? arrayContains(schema.projects.tags, [tag]) : undefined,
notTag
? or(
not(arrayContains(schema.projects.tags, [notTag])),
isNull(schema.projects.tags)
)
: undefined
),
with: {
lastPublishedDeployment: true,

Wyświetl plik

@ -37,6 +37,11 @@ export const projectIdentifierQuerySchema = z.object({
projectIdentifier: projectIdentifierSchema
})
export const filterPublicProjectSchema = z.object({
tag: z.string().optional(),
notTag: z.string().optional()
})
export const populateProjectSchema = z.object({
populate: z
.union([projectRelationsSchema, z.array(projectRelationsSchema)])
@ -54,3 +59,9 @@ export const paginationAndPopulateProjectSchema = z.object({
...paginationSchema.shape,
...populateProjectSchema.shape
})
export const listPublicProjectsQuerySchema = z.object({
...paginationSchema.shape,
...populateProjectSchema.shape,
...filterPublicProjectSchema.shape
})

Wyświetl plik

@ -73,7 +73,7 @@ export const deployments = pgTable(
name: projectName().notNull(),
description: text().default('').notNull(),
readme: text().default('').notNull(),
readme: text(), // URL to uploaded markdown document
iconUrl: text(),
sourceUrl: text(),
homepageUrl: text(),

Wyświetl plik

@ -85,6 +85,9 @@ export const projects = pgTable(
// visible on the marketplace.
private: boolean().default(true).notNull(),
// Admin-controlled tags for organizing and featuring on the marketplace
tags: text().array(),
// TODO: allow for multiple aliases like vercel
// alias: text(),
@ -172,6 +175,7 @@ export const projects = pgTable(
index('project_teamId_idx').on(table.teamId),
// index('project_alias_idx').on(table.alias),
index('project_private_idx').on(table.private),
index('project_tags_idx').on(table.tags),
index('project_lastPublishedDeploymentId_idx').on(
table.lastPublishedDeploymentId
),
@ -215,6 +219,7 @@ export const projectSelectBaseSchema = createSelectSchema(projects, {
identifier: projectIdentifierSchema,
name: agenticProjectConfigSchema.shape.name,
slug: agenticProjectConfigSchema.shape.slug,
tags: z.array(z.string()).optional(),
lastPublishedDeploymentId: deploymentIdSchema.optional(),
lastDeploymentId: deploymentIdSchema.optional(),

Wyświetl plik

@ -8,11 +8,29 @@ import { LoadingIndicator } from '@/components/loading-indicator'
import { PageContainer } from '@/components/page-container'
import { PublicProject } from '@/components/public-project'
import { SupplySideCTA } from '@/components/supply-side-cta'
import { useInfiniteQuery } from '@/lib/query-client'
import { useInfiniteQuery, useQuery } from '@/lib/query-client'
export function MarketplaceIndex() {
const ctx = useAgentic()
const limit = 10
const {
data: featuredProjects,
isLoading: isFeaturedProjectsLoading,
isError: isFeaturedProjectsError
} = useQuery({
queryKey: ['featured-public-projects'],
queryFn: () =>
ctx!.api.listPublicProjects({
populate: ['lastPublishedDeployment'],
limit: 3,
tag: 'featured',
sortBy: 'createdAt',
sort: 'asc'
}),
enabled: !!ctx
})
const {
data,
isLoading,
@ -21,13 +39,14 @@ export function MarketplaceIndex() {
fetchNextPage,
isFetchingNextPage
} = useInfiniteQuery({
queryKey: ['projects'],
queryKey: ['public-projects'],
queryFn: ({ pageParam = 0 }) =>
ctx!.api
.listPublicProjects({
populate: ['lastPublishedDeployment'],
offset: pageParam,
limit
limit,
notTag: 'featured'
})
.then(async (projects) => {
return {
@ -55,7 +74,7 @@ export function MarketplaceIndex() {
return (
<PageContainer>
<section>
<section className='flex flex-col gap-8'>
<h1
className='text-center text-balance leading-snug md:leading-none
text-4xl font-extrabold'
@ -63,17 +82,38 @@ export function MarketplaceIndex() {
Marketplace
</h1>
{!ctx || isLoading ? (
<LoadingIndicator />
) : (
<div className='mt-8'>
<h2 className='text-xl font-semibold mb-4'>Public Projects</h2>
<div className='flex flex-col gap-16'>
<div className=''>
<h2 className='text-xl font-semibold mb-4'>Featured</h2>
{isFeaturedProjectsError ? (
<p>Error fetching featured projects</p>
) : isFeaturedProjectsLoading ? (
<LoadingIndicator />
) : !featuredProjects?.length ? (
<p>
No projects found. This is likely an issue on Agentic's side.
Please refresh or contact support.
</p>
) : (
<div className='grid grid-cols grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3'>
{featuredProjects.map((project) => (
<PublicProject key={project.id} project={project} />
))}
</div>
)}
</div>
<div className=''>
<h2 className='text-xl font-semibold mb-4'>General</h2>
{isError ? (
<p>Error fetching projects</p>
) : isLoading ? (
<LoadingIndicator />
) : !projects.length ? (
<p>
No projects found. This is likely an error on Agentic's side.
No projects found. This is likely an issue on Agentic's side.
Please refresh or contact support.
</p>
) : (
@ -90,7 +130,7 @@ export function MarketplaceIndex() {
</div>
)}
</div>
)}
</div>
</section>
{/* CTA section */}
@ -99,7 +139,7 @@ export function MarketplaceIndex() {
Your API Paid MCP, Instantly
</h2>
<h5 className='text-center max-w-2xl'>
<h5 className='text-center max-w-2xl bg-background/50 rounded-xl'>
Run one command to turn any MCP server or OpenAPI service into a paid
MCP product. With built-in support for every major LLM SDK and MCP
client.

Wyświetl plik

@ -36,11 +36,9 @@ export function PublicProject({ project }: { project: Project }) {
</div>
<div className='flex-1 flex flex-col gap-3 justify-between'>
{deployment.description && (
<p className='text-sm text-gray-700 line-clamp-4'>
{deployment.description}
</p>
)}
<p className='text-sm text-gray-700 line-clamp-4'>
{deployment.description}
</p>
{project.lastPublishedDeployment && (
<div className='text-xs text-gray-500 flex gap-3 items-center justify-between'>

Wyświetl plik

@ -622,6 +622,7 @@ export interface components {
/** @description Unique project slug. Must be ascii-only, lower-case, and kebab-case with no spaces between 1 and 256 characters. If not provided, it will be derived by slugifying `name`. */
slug?: string;
private: boolean;
tags?: string[];
/** @description User id (e.g. "user_tz4a98xxat96iws9zmbrgj3a") */
userId: string;
/** @description Team id (e.g. "team_tz4a98xxat96iws9zmbrgj3a") */
@ -1364,6 +1365,8 @@ export interface operations {
sort?: "asc" | "desc";
sortBy?: "createdAt" | "updatedAt";
populate?: ("user" | "team" | "lastPublishedDeployment" | "lastDeployment") | ("user" | "team" | "lastPublishedDeployment" | "lastDeployment")[];
tag?: string;
notTag?: string;
};
header?: never;
path?: never;

12
todo.md
Wyświetl plik

@ -18,13 +18,12 @@
- docs: add notes about constraints on mcp origin servers (static tools)
- **api keys should go beyond 1:1 consumers**
- **currently not obvious how to get api key**
- marketplace public project page
- mcp inspector
- **add support to example-usage for api keys**
- add last published date somewhere
- marketplace public project detail page
- add breadcrumb nav: marketplace > @agentic > search
- tool input/output schemas; move `$schema` to top
- add last published date somewhere
- tool input/output schemas; move `$schema` to the top
- break out into a few subcomponents; some can be server components
- mcp inspector
- improve private project page
- link to public page if published
- list deployment versions
@ -34,9 +33,6 @@
- marketplace index page
- add disclaimer about public beta
- add search / sorting
- **improve marketplace default sorting**
- what's the best way to implement this?
- add admin-based tags for main page layout (featured, etc)
- replace render for api and/or add turbo for caching (too slow to deploy)
- create slack + notifications
- consider changing homepage hero CTA to include publishing