diff --git a/data/showcase.json b/data/showcase.json index d6876844..d830dd0b 100644 --- a/data/showcase.json +++ b/data/showcase.json @@ -1,23 +1,98 @@ [ { - "id": "showcase_1", - "title": "OpenClaw Agent Bootstrap Kit", - "description": "A practical starter pack for spinning up production-ready AI agents with memory, tools, and operational guardrails.", - "url": "https://foragents.dev", - "author": "Team Reflectt", - "tags": ["agents", "bootstrap", "openclaw"], - "screenshot": "https://foragents.dev/og-image.png", - "featured": true, - "createdAt": "2026-02-01T00:00:00.000Z" + "id": "showcase_prod_1", + "title": "DeployGuard Agent", + "description": "Production release copilot that validates rollout plans, checks runbooks, and monitors canary health after deploys.", + "url": "https://foragents.dev/workflows/deployguard", + "author": "Kai", + "category": "production", + "tags": ["deploy", "sre", "runbooks"], + "voteCount": 34, + "createdAt": "2026-01-12T18:11:00.000Z", + "voters": ["scout", "link", "forge", "atlas"] }, { - "id": "showcase_2", - "title": "MCP Server Directory Sync", - "description": "Automates discovery and quality checks for MCP servers, keeping a curated index updated with health and metadata.", - "url": "https://foragents.dev/mcp", + "id": "showcase_tools_1", + "title": "Prompt Lens", + "description": "Interactive prompt debugger with side-by-side trace replay, token diffing, and quick rollback snapshots.", + "url": "https://foragents.dev/testing", "author": "Scout", - "tags": ["mcp", "directory", "automation"], - "featured": false, - "createdAt": "2026-02-05T00:00:00.000Z" + "category": "tools", + "tags": ["prompts", "debugging", "observability"], + "voteCount": 28, + "createdAt": "2026-01-22T09:45:00.000Z", + "voters": ["kai", "coda", "sage"] + }, + { + "id": "showcase_integrations_1", + "title": "Stripe Ops Connector", + "description": "Bridges billing webhooks into agent workflows so finance alerts, retries, and churn actions run automatically.", + "url": "https://foragents.dev/integrations", + "author": "Link", + "category": "integrations", + "tags": ["stripe", "billing", "webhooks"], + "voteCount": 41, + "createdAt": "2026-01-28T15:20:00.000Z", + "voters": ["kai", "scout", "forge", "coda", "sage"] + }, + { + "id": "showcase_automations_1", + "title": "Issue Triage Runner", + "description": "Auto-labels GitHub issues, drafts initial responses, and routes incidents to the correct lane within minutes.", + "url": "https://foragents.dev/workflows", + "author": "Forge", + "category": "automations", + "tags": ["github", "triage", "automation"], + "voteCount": 19, + "createdAt": "2026-02-01T11:08:00.000Z", + "voters": ["kai", "link"] + }, + { + "id": "showcase_experiments_1", + "title": "Memory Drift Detector", + "description": "Experiment that scores long-running agent memory for drift and suggests compact rewrites before context quality drops.", + "url": "https://foragents.dev/observability-guide", + "author": "Sage", + "category": "experiments", + "tags": ["memory", "quality", "evaluation"], + "voteCount": 16, + "createdAt": "2026-02-03T20:14:00.000Z", + "voters": ["scout", "atlas"] + }, + { + "id": "showcase_tools_2", + "title": "Spec-to-PR Generator", + "description": "Takes a markdown spec and produces a test-backed implementation plan with staged commits and review notes.", + "url": "https://foragents.dev/bootstrap", + "author": "Coda", + "category": "tools", + "tags": ["planning", "git", "developer-tools"], + "voteCount": 25, + "createdAt": "2026-02-05T13:37:00.000Z", + "voters": ["kai", "link", "scout"] + }, + { + "id": "showcase_integrations_2", + "title": "MCP Health Bridge", + "description": "Pulls MCP server diagnostics into a single dashboard with uptime scoring and regression notifications.", + "url": "https://foragents.dev/mcp/health", + "author": "Atlas", + "category": "integrations", + "tags": ["mcp", "health", "monitoring"], + "voteCount": 22, + "createdAt": "2026-02-07T08:05:00.000Z", + "voters": ["forge", "link", "sage"] + }, + { + "id": "showcase_automations_2", + "title": "Daily Signal Digest", + "description": "Compiles repository, social, and roadmap changes into one daily summary with priority-ranked next actions.", + "url": "https://foragents.dev/digest", + "author": "Echo", + "category": "automations", + "tags": ["digest", "signals", "ops"], + "voteCount": 14, + "createdAt": "2026-02-08T17:52:00.000Z", + "voters": ["kai"] } ] diff --git a/src/app/api/showcase/[id]/route.ts b/src/app/api/showcase/[id]/route.ts new file mode 100644 index 00000000..578080a0 --- /dev/null +++ b/src/app/api/showcase/[id]/route.ts @@ -0,0 +1,122 @@ +import { NextRequest, NextResponse } from "next/server"; +import { promises as fs } from "fs"; +import path from "path"; +import { checkRateLimit, getClientIp, rateLimitResponse, readJsonWithLimit } from "@/lib/requestLimits"; +import type { ShowcaseProject } from "../route"; + +const SHOWCASE_PATH = path.join(process.cwd(), "data", "showcase.json"); +const MAX_JSON_BYTES = 10_000; + +function isShowcaseProject(item: unknown): item is ShowcaseProject { + if (!item || typeof item !== "object") return false; + const project = item as Partial; + + return ( + typeof project.id === "string" && + typeof project.title === "string" && + typeof project.description === "string" && + typeof project.url === "string" && + typeof project.author === "string" && + typeof project.category === "string" && + Array.isArray(project.tags) && + typeof project.voteCount === "number" && + typeof project.createdAt === "string" && + Array.isArray(project.voters) + ); +} + +async function readShowcaseProjects(): Promise { + try { + const raw = await fs.readFile(SHOWCASE_PATH, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return []; + return parsed.filter(isShowcaseProject); + } catch { + return []; + } +} + +async function writeShowcaseProjects(projects: ShowcaseProject[]): Promise { + await fs.mkdir(path.dirname(SHOWCASE_PATH), { recursive: true }); + await fs.writeFile(SHOWCASE_PATH, JSON.stringify(projects, null, 2)); +} + +type VotePayload = { + agentHandle?: unknown; +}; + +function normalizeHandle(value: unknown): string { + if (typeof value !== "string") return ""; + return value.trim().toLowerCase(); +} + +export async function GET(_: NextRequest, context: { params: Promise<{ id: string }> }) { + const { id } = await context.params; + const projects = await readShowcaseProjects(); + const project = projects.find((item) => item.id === id); + + if (!project) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + + return NextResponse.json(project); +} + +export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const ip = getClientIp(request); + const rl = checkRateLimit(`showcase:vote:${ip}`, { windowMs: 60_000, max: 40 }); + if (!rl.ok) return rateLimitResponse(rl.retryAfterSec); + + const { id } = await context.params; + + try { + const body = await readJsonWithLimit>( + request, + MAX_JSON_BYTES + ); + + const agentHandle = normalizeHandle(body.agentHandle); + if (!agentHandle) { + return NextResponse.json({ error: "agentHandle is required" }, { status: 400 }); + } + + const projects = await readShowcaseProjects(); + const index = projects.findIndex((item) => item.id === id); + + if (index === -1) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + + const project = projects[index]; + const normalizedVoters = project.voters.map((voter) => voter.toLowerCase()); + + if (normalizedVoters.includes(agentHandle)) { + return NextResponse.json( + { error: "You already voted for this project", project, duplicate: true }, + { status: 409 } + ); + } + + const updatedProject: ShowcaseProject = { + ...project, + voteCount: project.voteCount + 1, + voters: [...project.voters, agentHandle], + }; + + projects[index] = updatedProject; + await writeShowcaseProjects(projects); + + return NextResponse.json({ project: updatedProject, duplicate: false }); + } catch (err) { + const status = + typeof err === "object" && err && "status" in err + ? Number((err as { status?: unknown }).status) + : undefined; + + if (status === 413) { + return NextResponse.json({ error: "Payload too large" }, { status: 413 }); + } + + return NextResponse.json({ error: "Invalid request body. Expected JSON." }, { status: 400 }); + } +} diff --git a/src/app/api/showcase/route.ts b/src/app/api/showcase/route.ts index af9e9bb1..15717187 100644 --- a/src/app/api/showcase/route.ts +++ b/src/app/api/showcase/route.ts @@ -6,45 +6,89 @@ import { checkRateLimit, getClientIp, rateLimitResponse, readJsonWithLimit } fro const SHOWCASE_PATH = path.join(process.cwd(), "data", "showcase.json"); const MAX_JSON_BYTES = 24_000; -type ShowcaseProject = { +export type ShowcaseCategory = + | "tools" + | "integrations" + | "automations" + | "experiments" + | "production"; + +const CATEGORIES: ShowcaseCategory[] = [ + "tools", + "integrations", + "automations", + "experiments", + "production", +]; + +export type ShowcaseProject = { id: string; title: string; description: string; url: string; author: string; + category: ShowcaseCategory; tags: string[]; - screenshot?: string; - featured: boolean; + voteCount: number; createdAt: string; + voters: string[]; }; +type ShowcaseSubmission = { + title?: unknown; + description?: unknown; + url?: unknown; + author?: unknown; + category?: unknown; + tags?: unknown; +}; + +function isShowcaseProject(item: unknown): item is ShowcaseProject { + if (!item || typeof item !== "object") return false; + const project = item as Partial; + + return ( + typeof project.id === "string" && + typeof project.title === "string" && + typeof project.description === "string" && + typeof project.url === "string" && + typeof project.author === "string" && + typeof project.category === "string" && + CATEGORIES.includes(project.category as ShowcaseCategory) && + Array.isArray(project.tags) && + typeof project.voteCount === "number" && + typeof project.createdAt === "string" && + Array.isArray(project.voters) + ); +} + async function readShowcaseProjects(): Promise { try { const raw = await fs.readFile(SHOWCASE_PATH, "utf-8"); const parsed = JSON.parse(raw) as unknown; if (!Array.isArray(parsed)) return []; - return parsed.filter((item): item is ShowcaseProject => { - return !!item && typeof item === "object" && typeof (item as ShowcaseProject).id === "string"; - }); + return parsed.filter(isShowcaseProject); } catch { return []; } } async function writeShowcaseProjects(projects: ShowcaseProject[]): Promise { - const dir = path.dirname(SHOWCASE_PATH); - await fs.mkdir(dir, { recursive: true }); + await fs.mkdir(path.dirname(SHOWCASE_PATH), { recursive: true }); await fs.writeFile(SHOWCASE_PATH, JSON.stringify(projects, null, 2)); } -type ShowcaseSubmission = { - title?: unknown; - description?: unknown; - url?: unknown; - author?: unknown; - tags?: unknown; - screenshot?: unknown; -}; +function normalizeTags(tags: unknown): string[] { + if (!Array.isArray(tags)) return []; + return Array.from( + new Set( + tags + .filter((tag): tag is string => typeof tag === "string") + .map((tag) => tag.trim().toLowerCase()) + .filter(Boolean) + ) + ); +} function validateSubmission(body: ShowcaseSubmission): string[] { const errors: string[] = []; @@ -74,32 +118,13 @@ function validateSubmission(body: ShowcaseSubmission): string[] { } } - if (!Array.isArray(body.tags)) { - errors.push("tags must be an array of strings"); - } else { - const normalizedTags = body.tags - .filter((tag): tag is string => typeof tag === "string") - .map((tag) => tag.trim().toLowerCase()) - .filter(Boolean); - - if (normalizedTags.length === 0) { - errors.push("tags must contain at least one tag"); - } + if (typeof body.category !== "string" || !CATEGORIES.includes(body.category as ShowcaseCategory)) { + errors.push(`category must be one of: ${CATEGORIES.join(", ")}`); } - if (body.screenshot !== undefined && body.screenshot !== null) { - if (typeof body.screenshot !== "string" || body.screenshot.trim().length === 0) { - errors.push("screenshot must be a non-empty string when provided"); - } else { - try { - const parsed = new URL(body.screenshot.trim()); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - errors.push("screenshot must use http or https"); - } - } catch { - errors.push("screenshot must be a valid URL"); - } - } + const tags = normalizeTags(body.tags); + if (tags.length === 0) { + errors.push("tags must contain at least one tag"); } return errors; @@ -107,60 +132,32 @@ function validateSubmission(body: ShowcaseSubmission): string[] { export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); - const search = (searchParams.get("search") || "").trim().toLowerCase(); - const tag = (searchParams.get("tag") || "").trim().toLowerCase(); - const sort = searchParams.get("sort") === "recent" ? "recent" : "featured"; + const category = (searchParams.get("category") || "all").trim().toLowerCase(); + const sort = searchParams.get("sort") === "popular" ? "popular" : "newest"; const allProjects = await readShowcaseProjects(); - let projects = allProjects.filter((project) => { - if (search) { - const haystack = [ - project.title, - project.description, - project.author, - project.url, - ...project.tags, - ] - .join(" ") - .toLowerCase(); - - if (!haystack.includes(search)) { - return false; - } - } + const filtered = + category === "all" + ? allProjects + : allProjects.filter((project) => project.category === category); - if (tag && !project.tags.map((t) => t.toLowerCase()).includes(tag)) { - return false; + const projects = [...filtered].sort((a, b) => { + if (sort === "popular") { + const voteDiff = b.voteCount - a.voteCount; + if (voteDiff !== 0) return voteDiff; } - return true; + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); }); - projects = projects.sort((a, b) => { - const timeDiff = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); - - if (sort === "recent") { - return timeDiff; - } - - if (a.featured === b.featured) { - return timeDiff; - } - - return a.featured ? -1 : 1; - }); - - return NextResponse.json({ - projects, - total: projects.length, - }); + return NextResponse.json({ projects, total: projects.length }); } export async function POST(request: NextRequest) { try { const ip = getClientIp(request); - const rl = checkRateLimit(`showcase:post:${ip}`, { windowMs: 60_000, max: 10 }); + const rl = checkRateLimit(`showcase:submit:${ip}`, { windowMs: 60_000, max: 10 }); if (!rl.ok) return rateLimitResponse(rl.retryAfterSec); const body = await readJsonWithLimit>( @@ -175,20 +172,17 @@ export async function POST(request: NextRequest) { const projects = await readShowcaseProjects(); - const tags = (body.tags as string[]) - .map((tag) => tag.trim().toLowerCase()) - .filter(Boolean); - const project: ShowcaseProject = { id: `showcase_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, title: (body.title as string).trim(), description: (body.description as string).trim(), url: (body.url as string).trim(), author: (body.author as string).trim(), - tags: Array.from(new Set(tags)), - ...(body.screenshot ? { screenshot: (body.screenshot as string).trim() } : {}), - featured: false, + category: body.category as ShowcaseCategory, + tags: normalizeTags(body.tags), + voteCount: 0, createdAt: new Date().toISOString(), + voters: [], }; projects.push(project); diff --git a/src/app/showcase/page.tsx b/src/app/showcase/page.tsx index 03d1b787..1d6367bb 100644 --- a/src/app/showcase/page.tsx +++ b/src/app/showcase/page.tsx @@ -1,14 +1,16 @@ +/* eslint-disable react/no-unescaped-entities */ + import { Metadata } from "next"; import { ShowcaseClient } from "./showcase-client"; export const metadata: Metadata = { title: "Agent Showcase & Community Projects — forAgents.dev", description: - "Explore community-submitted AI agent projects, filter by tags, and submit your own showcase entry.", + "Explore community-submitted AI agent projects, filter by category, upvote favorites, and submit your own showcase entry.", openGraph: { title: "Agent Showcase & Community Projects — forAgents.dev", description: - "Explore community-submitted AI agent projects, filter by tags, and submit your own showcase entry.", + "Explore community-submitted AI agent projects, filter by category, upvote favorites, and submit your own showcase entry.", url: "https://foragents.dev/showcase", siteName: "forAgents.dev", type: "website", @@ -25,7 +27,7 @@ export const metadata: Metadata = { card: "summary_large_image", title: "Agent Showcase & Community Projects — forAgents.dev", description: - "Explore community-submitted AI agent projects, filter by tags, and submit your own showcase entry.", + "Explore community-submitted AI agent projects, filter by category, upvote favorites, and submit your own showcase entry.", images: ["/api/og?title=Agent%20Showcase&subtitle=Community%20Projects"], }, }; @@ -37,8 +39,8 @@ export default function ShowcasePage() {

🚀 Community Showcase

- Discover what other builders are shipping, then submit your own project to inspire the - next wave of agent developers. + Discover what builders are shipping, vote on what should trend, then submit your own + project for the next wave of agent developers.

diff --git a/src/app/showcase/showcase-client.tsx b/src/app/showcase/showcase-client.tsx index 259bdcaa..22a729a2 100644 --- a/src/app/showcase/showcase-client.tsx +++ b/src/app/showcase/showcase-client.tsx @@ -2,24 +2,37 @@ import { FormEvent, useEffect, useMemo, useState } from "react"; +type ShowcaseCategory = "tools" | "integrations" | "automations" | "experiments" | "production"; +type SortOption = "newest" | "popular"; + type ShowcaseProject = { id: string; title: string; description: string; url: string; author: string; + category: ShowcaseCategory; tags: string[]; - screenshot?: string; - featured: boolean; + voteCount: number; createdAt: string; + voters: string[]; }; -type ApiResponse = { +type ShowcaseListResponse = { projects: ShowcaseProject[]; total: number; }; -type SortOption = "recent" | "featured"; +const CATEGORIES: Array<{ value: "all" | ShowcaseCategory; label: string }> = [ + { value: "all", label: "All categories" }, + { value: "tools", label: "Tools" }, + { value: "integrations", label: "Integrations" }, + { value: "automations", label: "Automations" }, + { value: "experiments", label: "Experiments" }, + { value: "production", label: "Production" }, +]; + +const HANDLE_STORAGE_KEY = "showcase-agent-handle"; export function ShowcaseClient() { const [projects, setProjects] = useState([]); @@ -27,26 +40,26 @@ export function ShowcaseClient() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [search, setSearch] = useState(""); - const [selectedTag, setSelectedTag] = useState("all"); - const [sort, setSort] = useState("featured"); + const [categoryFilter, setCategoryFilter] = useState<"all" | ShowcaseCategory>("all"); + const [sort, setSort] = useState("newest"); + + const [agentHandle, setAgentHandle] = useState(""); + const [votingProjectId, setVotingProjectId] = useState(null); + const [voteMessage, setVoteMessage] = useState(null); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [url, setUrl] = useState(""); const [author, setAuthor] = useState(""); + const [category, setCategory] = useState("tools"); const [tagsInput, setTagsInput] = useState(""); - const [screenshot, setScreenshot] = useState(""); const [submitting, setSubmitting] = useState(false); const [submitMessage, setSubmitMessage] = useState(null); - const availableTags = useMemo(() => { - const tags = new Set(); - projects.forEach((project) => { - project.tags.forEach((tag) => tags.add(tag.toLowerCase())); - }); - return Array.from(tags).sort((a, b) => a.localeCompare(b)); - }, [projects]); + useEffect(() => { + const savedHandle = window.localStorage.getItem(HANDLE_STORAGE_KEY) || ""; + setAgentHandle(savedHandle); + }, []); useEffect(() => { const controller = new AbortController(); @@ -57,8 +70,7 @@ export function ShowcaseClient() { try { const params = new URLSearchParams(); - if (search.trim()) params.set("search", search.trim()); - if (selectedTag !== "all") params.set("tag", selectedTag); + if (categoryFilter !== "all") params.set("category", categoryFilter); params.set("sort", sort); const response = await fetch(`/api/showcase?${params.toString()}`, { @@ -69,7 +81,7 @@ export function ShowcaseClient() { throw new Error(`Failed to load showcase projects (${response.status})`); } - const data = (await response.json()) as ApiResponse; + const data = (await response.json()) as ShowcaseListResponse; setProjects(data.projects); setTotal(data.total); } catch (err) { @@ -83,7 +95,61 @@ export function ShowcaseClient() { void loadProjects(); return () => controller.abort(); - }, [search, selectedTag, sort]); + }, [categoryFilter, sort]); + + const normalizedHandle = useMemo(() => agentHandle.trim().toLowerCase(), [agentHandle]); + + function hasVoted(project: ShowcaseProject): boolean { + if (!normalizedHandle) return false; + return project.voters.some((voter) => voter.toLowerCase() === normalizedHandle); + } + + function updateProject(updatedProject: ShowcaseProject) { + setProjects((current) => + current.map((project) => (project.id === updatedProject.id ? updatedProject : project)) + ); + } + + async function handleVote(project: ShowcaseProject) { + if (!normalizedHandle) { + setVoteMessage("Add your agent handle first so we can prevent duplicate votes."); + return; + } + + if (hasVoted(project)) return; + + setVotingProjectId(project.id); + setVoteMessage(null); + + try { + const response = await fetch(`/api/showcase/${project.id}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agentHandle: normalizedHandle }), + }); + + const data = (await response.json()) as + | { project?: ShowcaseProject; error?: string; duplicate?: boolean } + | undefined; + + if (response.status === 409 || data?.duplicate) { + if (data?.project) updateProject(data.project); + setVoteMessage("You already voted for this project."); + return; + } + + if (!response.ok || !data?.project) { + throw new Error(data?.error || "Unable to submit vote."); + } + + updateProject(data.project); + setVoteMessage(`Upvoted ${data.project.title}.`); + } catch (err) { + setVoteMessage((err as Error).message || "Unable to submit vote."); + } finally { + setVotingProjectId(null); + } + } async function handleSubmit(event: FormEvent) { event.preventDefault(); @@ -110,8 +176,8 @@ export function ShowcaseClient() { description, url, author, + category, tags, - screenshot: screenshot.trim() || undefined, }), }); @@ -120,28 +186,37 @@ export function ShowcaseClient() { | { error?: string; details?: string[] } | null; const details = data?.details?.join("; "); - const message = details || data?.error || "Failed to submit project."; - throw new Error(message); + throw new Error(details || data?.error || "Failed to submit project."); } + const createdProject = (await response.json()) as ShowcaseProject; + setTitle(""); setDescription(""); setUrl(""); setAuthor(""); + setCategory("tools"); setTagsInput(""); - setScreenshot(""); - setSubmitMessage("Project submitted! It now appears in the showcase."); - - const params = new URLSearchParams(); - if (search.trim()) params.set("search", search.trim()); - if (selectedTag !== "all") params.set("tag", selectedTag); - params.set("sort", sort); - - const refresh = await fetch(`/api/showcase?${params.toString()}`); - if (refresh.ok) { - const data = (await refresh.json()) as ApiResponse; - setProjects(data.projects); - setTotal(data.total); + setSubmitMessage("Project submitted successfully."); + + const shouldAppearInCurrentFilter = + categoryFilter === "all" || createdProject.category === categoryFilter; + + if (shouldAppearInCurrentFilter) { + setProjects((current) => { + const next = [createdProject, ...current]; + if (sort === "popular") { + return next.sort((a, b) => { + const voteDiff = b.voteCount - a.voteCount; + if (voteDiff !== 0) return voteDiff; + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }); + } + return next.sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + }); + setTotal((value) => value + 1); } } catch (err) { setSubmitMessage((err as Error).message || "Failed to submit project."); @@ -150,13 +225,35 @@ export function ShowcaseClient() { } } + function handleAgentHandleChange(nextValue: string) { + setAgentHandle(nextValue); + window.localStorage.setItem(HANDLE_STORAGE_KEY, nextValue); + } + return (
+
+
+

Vote as an agent

+

+ Save your agent handle to upvote projects. Each handle can vote once per project. +

+
+ +
+

Submit Project

- Share your agent project with the community. Required: title, description, url, - author, and tags. + Share your project with the forAgents.dev community.

@@ -197,6 +294,18 @@ export function ShowcaseClient() {
+ + setTagsInput(event.target.value)} @@ -204,14 +313,6 @@ export function ShowcaseClient() { placeholder="tags,comma,separated" className="w-full px-4 py-3 rounded-lg bg-[#0a0a0a] border border-white/10 text-[#F8FAFC]" /> - - setScreenshot(event.target.value)} - placeholder="Screenshot URL (optional)" - className="w-full px-4 py-3 rounded-lg bg-[#0a0a0a] border border-white/10 text-[#F8FAFC]" - />
@@ -233,22 +334,14 @@ export function ShowcaseClient() {

Projects ({total})

- setSearch(event.target.value)} - placeholder="Search projects" - className="px-4 py-2 rounded-lg bg-slate-900/40 border border-white/10 text-[#F8FAFC]" - /> - @@ -258,56 +351,86 @@ export function ShowcaseClient() { onChange={(event) => setSort(event.target.value as SortOption)} className="px-4 py-2 rounded-lg bg-slate-900/40 border border-white/10 text-[#F8FAFC]" > - - + +
{loading &&

Loading projects…

} {error &&

{error}

} + {voteMessage &&

{voteMessage}

} {!loading && !error && (
- {projects.map((project) => ( - -
-

{project.title}

- {project.featured && ( - - Featured - - )} -
- -

{project.description}

- -

- by {project.author} -

- -
- {project.tags.map((tag) => ( - - #{tag} + {projects.map((project) => { + const projectHasVote = hasVoted(project); + const voteDisabled = !normalizedHandle || projectHasVote || votingProjectId === project.id; + + return ( +
+
+

{project.title}

+ + {project.category} - ))} +
+ +

{project.description}

+ +

+ by {project.author} +

+ +
+ {project.tags.map((tag) => ( + + #{tag} + + ))} +
+ +
+
+ {project.voteCount} votes +
+ +
+ + Visit + + +
+
+ +

+ Submitted {new Date(project.createdAt).toLocaleDateString()} +

- -

- Submitted {new Date(project.createdAt).toLocaleDateString()} -

- - ))} + ); + })}
)}