diff --git a/data/contribution-guides.json b/data/contribution-guides.json new file mode 100644 index 00000000..870c4cbe --- /dev/null +++ b/data/contribution-guides.json @@ -0,0 +1,86 @@ +[ + { + "id": "guide-skills-001", + "title": "Publish a High-Impact Skill", + "description": "Create or improve a reusable skill so agents can solve a task faster with better guardrails.", + "category": "skills", + "difficulty": "intermediate", + "estimatedTime": "2-4 hours", + "steps": [ + "Choose a repeatable workflow that agents struggle with.", + "Draft the skill contract, inputs, outputs, and failure modes.", + "Add practical examples and edge-case handling.", + "Test the skill with at least one real task before submitting." + ] + }, + { + "id": "guide-docs-001", + "title": "Improve Core Documentation", + "description": "Tighten docs so new contributors can onboard quickly and avoid common mistakes.", + "category": "docs", + "difficulty": "beginner", + "estimatedTime": "1-2 hours", + "steps": [ + "Pick one doc page with confusing or outdated instructions.", + "Rewrite sections for clarity and add concrete examples.", + "Verify commands and links still work.", + "Submit your changes with a brief before/after summary." + ] + }, + { + "id": "guide-testing-001", + "title": "Add Regression Test Coverage", + "description": "Strengthen reliability by covering known bugs and critical user paths.", + "category": "testing", + "difficulty": "advanced", + "estimatedTime": "3-5 hours", + "steps": [ + "Identify a flaky or high-risk area from recent issues.", + "Write focused tests that fail before your fix.", + "Implement the minimal fix and verify tests pass.", + "Document why this regression test matters." + ] + }, + { + "id": "guide-design-001", + "title": "Refine UX for Contributor Flows", + "description": "Polish visual hierarchy and interaction details to make contribution workflows easier.", + "category": "design", + "difficulty": "intermediate", + "estimatedTime": "2-3 hours", + "steps": [ + "Audit one contributor flow and list friction points.", + "Propose UI changes with accessibility in mind.", + "Validate spacing, contrast, and responsive behavior.", + "Ship the update with screenshots and rationale." + ] + }, + { + "id": "guide-translations-001", + "title": "Translate Getting Started Content", + "description": "Expand access by translating key onboarding and contribution content.", + "category": "translations", + "difficulty": "beginner", + "estimatedTime": "1-3 hours", + "steps": [ + "Select a target language and source page.", + "Translate text while preserving technical meaning.", + "Flag culture-specific terms that may need adaptation.", + "Submit translation notes for reviewer context." + ] + }, + { + "id": "guide-community-001", + "title": "Support Community Triage", + "description": "Help route community requests, answer common questions, and keep discussions actionable.", + "category": "community", + "difficulty": "beginner", + "estimatedTime": "30-90 minutes", + "steps": [ + "Review unanswered requests and duplicate topics.", + "Respond with clear next steps or resource links.", + "Label escalations for maintainers when required.", + "Summarize outcomes so others can continue the thread." + ] + } +] diff --git a/data/contributions.json b/data/contributions.json new file mode 100644 index 00000000..701429a0 --- /dev/null +++ b/data/contributions.json @@ -0,0 +1,42 @@ +[ + { + "id": "contrib-001", + "contributorName": "Ari Kim", + "type": "docs", + "title": "Clarified bootstrap troubleshooting section", + "status": "merged", + "submittedAt": "2026-02-05T18:30:00.000Z" + }, + { + "id": "contrib-002", + "contributorName": "Mateo Singh", + "type": "testing", + "title": "Added route-level tests for API error handling", + "status": "approved", + "submittedAt": "2026-02-06T09:15:00.000Z" + }, + { + "id": "contrib-003", + "contributorName": "Lina Park", + "type": "design", + "title": "Updated contribution cards for mobile readability", + "status": "pending", + "submittedAt": "2026-02-06T22:05:00.000Z" + }, + { + "id": "contrib-004", + "contributorName": "Owen Patel", + "type": "skills", + "title": "Published skill template for CLI-based workflows", + "status": "merged", + "submittedAt": "2026-02-07T14:42:00.000Z" + }, + { + "id": "contrib-005", + "contributorName": "Nora Alvarez", + "type": "community", + "title": "Triaged onboarding questions into common-answer pack", + "status": "pending", + "submittedAt": "2026-02-08T01:20:00.000Z" + } +] diff --git a/src/app/api/contribute/route.ts b/src/app/api/contribute/route.ts new file mode 100644 index 00000000..50682f0e --- /dev/null +++ b/src/app/api/contribute/route.ts @@ -0,0 +1,154 @@ +import { promises as fs } from "fs"; +import path from "path"; +import { NextRequest, NextResponse } from "next/server"; + +type GuideCategory = "skills" | "docs" | "testing" | "design" | "translations" | "community"; +type GuideDifficulty = "beginner" | "intermediate" | "advanced"; +type ContributionStatus = "pending" | "approved" | "merged"; + +type ContributionGuide = { + id: string; + title: string; + description: string; + category: GuideCategory; + difficulty: GuideDifficulty; + estimatedTime: string; + steps: string[]; +}; + +type Contribution = { + id: string; + contributorName: string; + type: string; + title: string; + status: ContributionStatus; + submittedAt: string; +}; + +const GUIDES_PATH = path.join(process.cwd(), "data", "contribution-guides.json"); +const CONTRIBUTIONS_PATH = path.join(process.cwd(), "data", "contributions.json"); +const VALID_TYPES: GuideCategory[] = ["skills", "docs", "testing", "design", "translations", "community"]; + +async function readGuides(): Promise { + const raw = await fs.readFile(GUIDES_PATH, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + return Array.isArray(parsed) ? (parsed as ContributionGuide[]) : []; +} + +async function readContributions(): Promise { + try { + const raw = await fs.readFile(CONTRIBUTIONS_PATH, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + return Array.isArray(parsed) ? (parsed as Contribution[]) : []; + } catch { + return []; + } +} + +async function writeContributions(contributions: Contribution[]) { + await fs.mkdir(path.dirname(CONTRIBUTIONS_PATH), { recursive: true }); + const tmpPath = `${CONTRIBUTIONS_PATH}.tmp`; + await fs.writeFile(tmpPath, JSON.stringify(contributions, null, 2), "utf-8"); + await fs.rename(tmpPath, CONTRIBUTIONS_PATH); +} + +function isValidType(type: string): type is GuideCategory { + return VALID_TYPES.includes(type as GuideCategory); +} + +function normalizeTitle(title: string, description: string) { + if (title.trim()) { + return title.trim().slice(0, 120); + } + + const fallback = description.trim().slice(0, 80); + return fallback.length > 0 ? fallback : "New contribution"; +} + +export async function GET() { + try { + const [guides, contributions] = await Promise.all([readGuides(), readContributions()]); + + const recentContributions = [...contributions] + .sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime()) + .slice(0, 12); + + return NextResponse.json( + { + guides, + recentContributions, + }, + { + headers: { + "Cache-Control": "no-store", + }, + } + ); + } catch { + return NextResponse.json({ error: "Failed to load contribution data." }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const body = (await request.json()) as Record; + + const name = typeof body.name === "string" ? body.name.trim() : ""; + const email = typeof body.email === "string" ? body.email.trim() : ""; + const typeRaw = typeof body.type === "string" ? body.type.trim().toLowerCase() : ""; + const description = typeof body.description === "string" ? body.description.trim() : ""; + const title = typeof body.title === "string" ? body.title : ""; + + if (!name || !email || !typeRaw || !description) { + return NextResponse.json( + { + error: "Validation failed", + details: "name, email, type, and description are required.", + }, + { status: 400 } + ); + } + + if (!/^\S+@\S+\.\S+$/.test(email)) { + return NextResponse.json( + { + error: "Validation failed", + details: "email must be a valid email address.", + }, + { status: 400 } + ); + } + + if (!isValidType(typeRaw)) { + return NextResponse.json( + { + error: "Validation failed", + details: `type must be one of: ${VALID_TYPES.join(", ")}`, + }, + { status: 400 } + ); + } + + const contribution: Contribution = { + id: `contrib_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + contributorName: name, + type: typeRaw, + title: normalizeTitle(title, description), + status: "pending", + submittedAt: new Date().toISOString(), + }; + + const existing = await readContributions(); + await writeContributions([contribution, ...existing]); + + return NextResponse.json( + { + message: "Contribution submitted successfully.", + contribution, + }, + { status: 201 } + ); + } catch { + return NextResponse.json({ error: "Invalid request body. Expected JSON." }, { status: 400 }); + } +} diff --git a/src/app/contribute/contributors-client.tsx b/src/app/contribute/contributors-client.tsx index 51d0229c..3c89ed8b 100644 --- a/src/app/contribute/contributors-client.tsx +++ b/src/app/contribute/contributors-client.tsx @@ -1,8 +1,7 @@ /* eslint-disable react/no-unescaped-entities */ "use client"; -import { useEffect, useMemo, useState, type FormEvent } from "react"; -import Image from "next/image"; +import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; @@ -11,118 +10,125 @@ import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; -type ContributorRole = "maintainer" | "reviewer" | "contributor" | "tester" | "documentation"; +type GuideCategory = "skills" | "docs" | "testing" | "design" | "translations" | "community"; +type GuideDifficulty = "beginner" | "intermediate" | "advanced"; +type ContributionStatus = "pending" | "approved" | "merged"; -type Contributor = { +type ContributionGuide = { id: string; - name: string; - handle: string; - role: ContributorRole; - avatar: string; - contributions: number; - skills: string[]; - joinedAt: string; - bio: string; + title: string; + description: string; + category: GuideCategory; + difficulty: GuideDifficulty; + estimatedTime: string; + steps: string[]; +}; + +type Contribution = { + id: string; + contributorName: string; + type: string; + title: string; + status: ContributionStatus; + submittedAt: string; }; -type ContributorsResponse = { - contributors: Contributor[]; - total: number; +type ContributeResponse = { + guides: ContributionGuide[]; + recentContributions: Contribution[]; }; type FormState = { name: string; - handle: string; - roleInterest: ContributorRole; - skills: string; - bio: string; + email: string; + type: GuideCategory; + title: string; + description: string; +}; + +const CATEGORY_LABELS: Record = { + skills: "Skills", + docs: "Documentation", + testing: "Testing", + design: "Design", + translations: "Translations", + community: "Community", }; -const ROLE_OPTIONS: Array<{ label: string; value: ContributorRole }> = [ - { label: "Maintainer", value: "maintainer" }, - { label: "Reviewer", value: "reviewer" }, - { label: "Contributor", value: "contributor" }, - { label: "Tester", value: "tester" }, - { label: "Documentation", value: "documentation" }, -]; +const DIFFICULTY_CLASSES: Record = { + beginner: "border-emerald-400/40 bg-emerald-400/10 text-emerald-300", + intermediate: "border-amber-400/40 bg-amber-400/10 text-amber-200", + advanced: "border-rose-400/40 bg-rose-400/10 text-rose-200", +}; + +const STATUS_CLASSES: Record = { + pending: "border-amber-400/40 bg-amber-400/10 text-amber-200", + approved: "border-sky-400/40 bg-sky-400/10 text-sky-200", + merged: "border-emerald-400/40 bg-emerald-400/10 text-emerald-300", +}; const initialForm: FormState = { name: "", - handle: "", - roleInterest: "contributor", - skills: "", - bio: "", + email: "", + type: "skills", + title: "", + description: "", }; -function roleLabel(role: ContributorRole) { - return ROLE_OPTIONS.find((option) => option.value === role)?.label ?? role; +function formatDate(date: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + year: "numeric", + }).format(new Date(date)); } export function ContributorsClient() { - const [contributors, setContributors] = useState([]); + const [guides, setGuides] = useState([]); + const [recentContributions, setRecentContributions] = useState([]); const [isLoading, setIsLoading] = useState(true); const [loadError, setLoadError] = useState(null); - const [roleFilter, setRoleFilter] = useState("all"); - const [searchFilter, setSearchFilter] = useState(""); - const [form, setForm] = useState(initialForm); const [isSubmitting, setIsSubmitting] = useState(false); const [submitMessage, setSubmitMessage] = useState(null); const [submitError, setSubmitError] = useState(null); - const queryString = useMemo(() => { - const params = new URLSearchParams(); + const guidesByCategory = useMemo(() => { + const grouped = new Map(); - if (roleFilter !== "all") { - params.set("role", roleFilter); - } + guides.forEach((guide) => { + const current = grouped.get(guide.category) ?? []; + grouped.set(guide.category, [...current, guide]); + }); - if (searchFilter.trim()) { - params.set("search", searchFilter.trim()); - } + return Array.from(grouped.entries()); + }, [guides]); - return params.toString(); - }, [roleFilter, searchFilter]); + const loadData = useCallback(async () => { + setIsLoading(true); + setLoadError(null); - useEffect(() => { - let active = true; - - async function loadContributors() { - setIsLoading(true); - setLoadError(null); - - try { - const response = await fetch(`/api/contributors${queryString ? `?${queryString}` : ""}`, { - cache: "no-store", - }); - - if (!response.ok) { - throw new Error("Failed to load contributors."); - } - - const payload = (await response.json()) as ContributorsResponse; - - if (active) { - setContributors(payload.contributors ?? []); - } - } catch { - if (active) { - setLoadError("Couldn't load contributors right now. Please try again in a moment."); - } - } finally { - if (active) { - setIsLoading(false); - } + try { + const response = await fetch("/api/contribute", { cache: "no-store" }); + + if (!response.ok) { + throw new Error("Failed to load contribution data."); } - } - void loadContributors(); + const payload = (await response.json()) as ContributeResponse; + setGuides(payload.guides ?? []); + setRecentContributions(payload.recentContributions ?? []); + } catch { + setLoadError("Couldn't load contribution guides right now. Please try again in a moment."); + } finally { + setIsLoading(false); + } + }, []); - return () => { - active = false; - }; - }, [queryString]); + useEffect(() => { + void loadData(); + }, [loadData]); async function handleSubmit(event: FormEvent) { event.preventDefault(); @@ -131,33 +137,31 @@ export function ContributorsClient() { setSubmitError(null); try { - const response = await fetch("/api/contributors", { + const response = await fetch("/api/contribute", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ name: form.name, - handle: form.handle, - roleInterest: form.roleInterest, - skills: form.skills - .split(",") - .map((skill) => skill.trim()) - .filter(Boolean), - bio: form.bio, + email: form.email, + type: form.type, + title: form.title, + description: form.description, }), }); if (!response.ok) { const payload = (await response.json()) as { error?: string; details?: string }; - throw new Error(payload.details ?? payload.error ?? "Failed to submit application."); + throw new Error(payload.details ?? payload.error ?? "Failed to submit contribution."); } setForm(initialForm); - setSubmitMessage("Application submitted! We'll review it and follow up soon."); + setSubmitMessage("Thanks! Your contribution is now in the review queue."); + await loadData(); } catch (error) { setSubmitError( - error instanceof Error ? error.message : "Couldn't submit your application. Please try again." + error instanceof Error ? error.message : "Couldn't submit your contribution. Please try again." ); } finally { setIsSubmitting(false); @@ -168,113 +172,108 @@ export function ContributorsClient() {
- Contributor Directory + Real Contribution Workflow -

- Contribute to forAgents.dev -

+

Contribute to forAgents.dev

- Explore active contributors, find your role, and submit an application to join the builder network. + Pick a contribution guide, follow the steps, and submit your work for review. Every submission is tracked so + the community can see progress.

-
-
-
- - setSearchFilter(event.target.value)} - /> -
-
- - -
-
- - {isLoading ? ( - - - Loading contributors… - - - ) : loadError ? ( - - {loadError} - - ) : contributors.length === 0 ? ( - - - No contributors found for this filter. - - - ) : ( -
- {contributors.map((contributor) => ( - - -
- {contributor.name} -
- {contributor.name} - {contributor.handle} -
-
-
- -
- - {roleLabel(contributor.role)} - -

- {contributor.contributions} contributions -

-
- -

{contributor.bio}

- -
- {contributor.skills.map((skill) => ( - - {skill} - + {isLoading ? ( + + Loading contribution data… + + ) : loadError ? ( + + {loadError} + + ) : ( + <> +
+
+

Contribution guides

+

Browse by category and choose a guide that matches your skill level.

+
+ +
+ {guidesByCategory.map(([category, categoryGuides]) => ( +
+

{CATEGORY_LABELS[category]}

+
+ {categoryGuides.map((guide) => ( + + +
+ {guide.title} + + {guide.difficulty} + +
+ {guide.description} +
+ +

+ Estimated time: {guide.estimatedTime} +

+
    + {guide.steps.map((step, index) => ( +
  1. {step}
  2. + ))} +
+
+
))}
+
+ ))} +
+
+ +
+
+

Recent contributions

+

Latest submissions and their current review status.

+
+ + {recentContributions.length === 0 ? ( + + + No contributions yet. Be the first to submit. - ))} -
- )} -
+ ) : ( +
+ {recentContributions.map((contribution) => ( + + +
+ + {CATEGORY_LABELS[contribution.type as GuideCategory] ?? contribution.type} + + + {contribution.status} + +
+ {contribution.title} + + Submitted by {contribution.contributorName} on {formatDate(contribution.submittedAt)} + +
+
+ ))} +
+ )} + + + )}
- Join as Contributor - - Tell us what you'd like to work on and the skills you want to bring to the project. - + Submit a contribution + Share what you've built and we'll track it through review and merge.
@@ -291,62 +290,63 @@ export function ContributorsClient() {
- + setForm((prev) => ({ ...prev, handle: event.target.value }))} - placeholder="@janedoe" + id="email" + type="email" + value={form.email} + onChange={(event) => setForm((prev) => ({ ...prev, email: event.target.value }))} + placeholder="jane@example.com" required />
-
- - -
+
+
+ + +
-
- - setForm((prev) => ({ ...prev, skills: event.target.value }))} - placeholder="typescript, docs, testing" - required - /> -

Comma-separated skills.

+
+ + setForm((prev) => ({ ...prev, title: event.target.value }))} + placeholder="Short summary of your contribution" + /> +
- +