From ed51ab5a09732395fc57d2b16b9465d219554306 Mon Sep 17 00:00:00 2001 From: Kai Date: Tue, 10 Feb 2026 02:14:48 -0800 Subject: [PATCH] feat: wire contribute page to persistent data --- data/contributions.json | 60 +++++-- src/app/api/contribute/route.ts | 153 +++++------------- src/app/contribute/contributors-client.tsx | 108 +++++++++++-- src/lib/contribute.ts | 177 +++++++++++++++++++++ 4 files changed, 363 insertions(+), 135 deletions(-) create mode 100644 src/lib/contribute.ts diff --git a/data/contributions.json b/data/contributions.json index 701429a..df27728 100644 --- a/data/contributions.json +++ b/data/contributions.json @@ -1,42 +1,72 @@ [ { "id": "contrib-001", - "contributorName": "Ari Kim", - "type": "docs", "title": "Clarified bootstrap troubleshooting section", + "type": "docs", + "description": "Expanded the bootstrap troubleshooting guide with specific fix paths for missing environment variables and package manager mismatch errors.", + "author": "Ari Kim", "status": "merged", - "submittedAt": "2026-02-05T18:30:00.000Z" + "url": "https://github.com/reflectt/foragents.dev/pull/412", + "createdAt": "2026-02-05T18:30:00.000Z" }, { "id": "contrib-002", - "contributorName": "Mateo Singh", - "type": "testing", "title": "Added route-level tests for API error handling", + "type": "testing", + "description": "Introduced integration tests for 400/500 edge cases across submission routes and standardized response snapshots.", + "author": "Mateo Singh", "status": "approved", - "submittedAt": "2026-02-06T09:15:00.000Z" + "url": "https://github.com/reflectt/foragents.dev/pull/419", + "createdAt": "2026-02-06T09:15:00.000Z" }, { "id": "contrib-003", - "contributorName": "Lina Park", - "type": "design", "title": "Updated contribution cards for mobile readability", + "type": "design", + "description": "Adjusted card spacing, heading sizes, and badge wrapping behavior for narrow viewports under 380px width.", + "author": "Lina Park", "status": "pending", - "submittedAt": "2026-02-06T22:05:00.000Z" + "url": "https://github.com/reflectt/foragents.dev/pull/423", + "createdAt": "2026-02-06T22:05:00.000Z" }, { "id": "contrib-004", - "contributorName": "Owen Patel", - "type": "skills", "title": "Published skill template for CLI-based workflows", + "type": "skills", + "description": "Created a reusable skill template with strict input/output contracts for command-line execution and recovery paths.", + "author": "Owen Patel", "status": "merged", - "submittedAt": "2026-02-07T14:42:00.000Z" + "url": "https://github.com/reflectt/foragents.dev/pull/427", + "createdAt": "2026-02-07T14:42:00.000Z" }, { "id": "contrib-005", - "contributorName": "Nora Alvarez", - "type": "community", "title": "Triaged onboarding questions into common-answer pack", + "type": "community", + "description": "Collected top onboarding questions from community channels and converted them into curated, link-backed answers.", + "author": "Nora Alvarez", "status": "pending", - "submittedAt": "2026-02-08T01:20:00.000Z" + "url": "https://github.com/reflectt/foragents.dev/issues/430", + "createdAt": "2026-02-08T01:20:00.000Z" + }, + { + "id": "contrib-006", + "title": "Localized quickstart into Portuguese (pt-BR)", + "type": "translations", + "description": "Translated quickstart and submission docs into pt-BR and added terminology notes for maintainers.", + "author": "Camila Souza", + "status": "approved", + "url": "https://github.com/reflectt/foragents.dev/pull/434", + "createdAt": "2026-02-08T16:48:00.000Z" + }, + { + "id": "contrib-007", + "title": "Improved docs linking for contribution paths", + "type": "docs", + "description": "Connected contribution guide cards to related docs pages and removed outdated references from onboarding copy.", + "author": "Devon Clarke", + "status": "merged", + "url": "https://github.com/reflectt/foragents.dev/pull/438", + "createdAt": "2026-02-09T11:12:00.000Z" } ] diff --git a/src/app/api/contribute/route.ts b/src/app/api/contribute/route.ts index 50682f0..c024090 100644 --- a/src/app/api/contribute/route.ts +++ b/src/app/api/contribute/route.ts @@ -1,82 +1,32 @@ -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() { +import { + createContribution, + filterContributions, + getValidFilters, + readContributionGuides, + readContributions, + writeContributions, +} from "@/lib/contribute"; + +export async function GET(request: NextRequest) { 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); + const params = request.nextUrl.searchParams; + const filters = getValidFilters({ + type: params.get("type") ?? undefined, + status: params.get("status") ?? undefined, + author: params.get("author") ?? undefined, + search: params.get("search") ?? undefined, + limit: params.get("limit") ? Number(params.get("limit")) : undefined, + }); + + const [guides, contributions] = await Promise.all([readContributionGuides(), readContributions()]); + const filtered = filterContributions(contributions, filters); return NextResponse.json( { guides, - recentContributions, + contributions: filtered, + recentContributions: filtered, }, { headers: { @@ -84,8 +34,9 @@ export async function GET() { }, } ); - } catch { - return NextResponse.json({ error: "Failed to load contribution data." }, { status: 500 }); + } catch (error) { + const details = error instanceof Error ? error.message : "Failed to load contribution data."; + return NextResponse.json({ error: "Failed to load contribution data.", details }, { status: 400 }); } } @@ -93,13 +44,14 @@ 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 name = typeof body.name === "string" ? body.name : ""; + const email = typeof body.email === "string" ? body.email : ""; + const type = typeof body.type === "string" ? body.type : ""; const title = typeof body.title === "string" ? body.title : ""; + const description = typeof body.description === "string" ? body.description : ""; + const url = typeof body.url === "string" ? body.url : ""; - if (!name || !email || !typeRaw || !description) { + if (!name.trim() || !email.trim() || !type.trim() || !description.trim()) { return NextResponse.json( { error: "Validation failed", @@ -109,34 +61,14 @@ export async function POST(request: NextRequest) { ); } - 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 contribution = createContribution({ + title, + type, + description, + author: name, + email, + url, + }); const existing = await readContributions(); await writeContributions([contribution, ...existing]); @@ -148,7 +80,8 @@ export async function POST(request: NextRequest) { }, { status: 201 } ); - } catch { - return NextResponse.json({ error: "Invalid request body. Expected JSON." }, { status: 400 }); + } catch (error) { + const details = error instanceof Error ? error.message : "Invalid request body. Expected JSON."; + return NextResponse.json({ error: "Validation failed", details }, { status: 400 }); } } diff --git a/src/app/contribute/contributors-client.tsx b/src/app/contribute/contributors-client.tsx index 3c89ed8..7e15819 100644 --- a/src/app/contribute/contributors-client.tsx +++ b/src/app/contribute/contributors-client.tsx @@ -26,16 +26,19 @@ type ContributionGuide = { type Contribution = { id: string; - contributorName: string; - type: string; + type: GuideCategory; title: string; + description: string; + author: string; status: ContributionStatus; - submittedAt: string; + url: string; + createdAt: string; }; type ContributeResponse = { guides: ContributionGuide[]; - recentContributions: Contribution[]; + contributions?: Contribution[]; + recentContributions?: Contribution[]; }; type FormState = { @@ -44,6 +47,7 @@ type FormState = { type: GuideCategory; title: string; description: string; + url: string; }; const CATEGORY_LABELS: Record = { @@ -73,6 +77,7 @@ const initialForm: FormState = { type: "skills", title: "", description: "", + url: "", }; function formatDate(date: string) { @@ -89,6 +94,10 @@ export function ContributorsClient() { const [isLoading, setIsLoading] = useState(true); const [loadError, setLoadError] = useState(null); + const [typeFilter, setTypeFilter] = useState<"all" | GuideCategory>("all"); + const [statusFilter, setStatusFilter] = useState<"all" | ContributionStatus>("all"); + const [searchFilter, setSearchFilter] = useState(""); + const [form, setForm] = useState(initialForm); const [isSubmitting, setIsSubmitting] = useState(false); const [submitMessage, setSubmitMessage] = useState(null); @@ -110,7 +119,14 @@ export function ContributorsClient() { setLoadError(null); try { - const response = await fetch("/api/contribute", { cache: "no-store" }); + const params = new URLSearchParams(); + + if (typeFilter !== "all") params.set("type", typeFilter); + if (statusFilter !== "all") params.set("status", statusFilter); + if (searchFilter.trim()) params.set("search", searchFilter.trim()); + + const query = params.toString(); + const response = await fetch(`/api/contribute${query ? `?${query}` : ""}`, { cache: "no-store" }); if (!response.ok) { throw new Error("Failed to load contribution data."); @@ -118,13 +134,13 @@ export function ContributorsClient() { const payload = (await response.json()) as ContributeResponse; setGuides(payload.guides ?? []); - setRecentContributions(payload.recentContributions ?? []); + setRecentContributions(payload.contributions ?? payload.recentContributions ?? []); } catch { setLoadError("Couldn't load contribution guides right now. Please try again in a moment."); } finally { setIsLoading(false); } - }, []); + }, [searchFilter, statusFilter, typeFilter]); useEffect(() => { void loadData(); @@ -148,6 +164,7 @@ export function ContributorsClient() { type: form.type, title: form.title, description: form.description, + url: form.url, }), }); @@ -237,10 +254,57 @@ export function ContributorsClient() {

Latest submissions and their current review status.

+
+
+ + +
+ +
+ + +
+ +
+ + setSearchFilter(event.target.value)} + placeholder="Title, description, or author" + /> +
+
+ {recentContributions.length === 0 ? ( - No contributions yet. Be the first to submit. + No contributions found for this filter. ) : ( @@ -250,7 +314,7 @@ export function ContributorsClient() {
- {CATEGORY_LABELS[contribution.type as GuideCategory] ?? contribution.type} + {CATEGORY_LABELS[contribution.type]} {contribution.status} @@ -258,9 +322,22 @@ export function ContributorsClient() {
{contribution.title} - Submitted by {contribution.contributorName} on {formatDate(contribution.submittedAt)} + Submitted by {contribution.author} on {formatDate(contribution.createdAt)}
+ +

{contribution.description}

+ {contribution.url ? ( + + View submission + + ) : null} +
))} @@ -333,6 +410,17 @@ export function ContributorsClient() { +
+ + setForm((prev) => ({ ...prev, url: event.target.value }))} + placeholder="https://github.com/reflectt/foragents.dev/pull/123" + /> +
+