From 95a69b41a9f38844340e75012d88e79fc3b9e629 Mon Sep 17 00:00:00 2001 From: Kai Date: Mon, 9 Feb 2026 23:09:01 -0800 Subject: [PATCH] Wire spaces page to persistent API-backed data --- data/spaces.json | 106 +++++++ src/app/api/spaces/[slug]/route.ts | 117 ++++++++ src/app/api/spaces/route.ts | 190 ++++++++++++ src/app/spaces/page.tsx | 463 ++++++++++++++++++----------- 4 files changed, 708 insertions(+), 168 deletions(-) create mode 100644 data/spaces.json create mode 100644 src/app/api/spaces/[slug]/route.ts create mode 100644 src/app/api/spaces/route.ts diff --git a/data/spaces.json b/data/spaces.json new file mode 100644 index 00000000..b1d813cc --- /dev/null +++ b/data/spaces.json @@ -0,0 +1,106 @@ +[ + { + "id": "space_001", + "name": "Open Source Toolsmiths", + "slug": "open-source-toolsmiths", + "description": "Builders shipping reusable tooling for agent-first workflows and open collaboration.", + "visibility": "public", + "memberCount": 324, + "skillCount": 48, + "createdBy": "@kai", + "createdAt": "2025-10-12T14:24:00.000Z", + "tags": ["open-source", "tooling", "automation"], + "featured": true + }, + { + "id": "space_002", + "name": "Enterprise Automation Guild", + "slug": "enterprise-automation-guild", + "description": "Private collaboration space for teams deploying AI agents in production enterprise systems.", + "visibility": "private", + "memberCount": 167, + "skillCount": 37, + "createdBy": "@ryan", + "createdAt": "2025-11-04T09:10:00.000Z", + "tags": ["enterprise", "security", "governance"], + "featured": true + }, + { + "id": "space_003", + "name": "Research Signals Lab", + "slug": "research-signals-lab", + "description": "Tracking model behavior, benchmarks, and evaluation patterns across agent ecosystems.", + "visibility": "public", + "memberCount": 289, + "skillCount": 62, + "createdBy": "@mira", + "createdAt": "2025-09-20T18:45:00.000Z", + "tags": ["research", "evaluation", "benchmarks"], + "featured": false + }, + { + "id": "space_004", + "name": "Prompt Architecture Circle", + "slug": "prompt-architecture-circle", + "description": "Unlisted workspace for refining reusable prompt systems, templates, and testing harnesses.", + "visibility": "unlisted", + "memberCount": 94, + "skillCount": 29, + "createdBy": "@nova", + "createdAt": "2025-12-01T12:32:00.000Z", + "tags": ["prompts", "templates", "testing"], + "featured": false + }, + { + "id": "space_005", + "name": "Builder Launch Crew", + "slug": "builder-launch-crew", + "description": "A public hub for shipping launches, collecting feedback, and iterating quickly in public.", + "visibility": "public", + "memberCount": 451, + "skillCount": 55, + "createdBy": "@echo", + "createdAt": "2025-08-28T07:55:00.000Z", + "tags": ["launch", "feedback", "community"], + "featured": true + }, + { + "id": "space_006", + "name": "Agent UX Studio", + "slug": "agent-ux-studio", + "description": "Designers and builders co-creating smooth human-agent interfaces and interaction patterns.", + "visibility": "public", + "memberCount": 211, + "skillCount": 44, + "createdBy": "@lyra", + "createdAt": "2025-10-30T16:20:00.000Z", + "tags": ["ux", "design", "product"], + "featured": false + }, + { + "id": "space_007", + "name": "Infra Reliability Room", + "slug": "infra-reliability-room", + "description": "Private ops-focused space for incident playbooks, observability, and deployment reliability.", + "visibility": "private", + "memberCount": 73, + "skillCount": 26, + "createdBy": "@atlas", + "createdAt": "2026-01-15T03:40:00.000Z", + "tags": ["infrastructure", "observability", "ops"], + "featured": false + }, + { + "id": "space_008", + "name": "Education Sandbox", + "slug": "education-sandbox", + "description": "Unlisted learning community for onboarding new builders with hands-on agent projects.", + "visibility": "unlisted", + "memberCount": 129, + "skillCount": 33, + "createdBy": "@sage", + "createdAt": "2025-11-22T21:05:00.000Z", + "tags": ["education", "onboarding", "mentorship"], + "featured": true + } +] diff --git a/src/app/api/spaces/[slug]/route.ts b/src/app/api/spaces/[slug]/route.ts new file mode 100644 index 00000000..ff2bd9ea --- /dev/null +++ b/src/app/api/spaces/[slug]/route.ts @@ -0,0 +1,117 @@ +import { NextRequest, NextResponse } from "next/server"; +import { promises as fs } from "node:fs"; +import path from "node:path"; + +type SpaceVisibility = "public" | "private" | "unlisted"; + +type Space = { + id: string; + name: string; + slug: string; + description: string; + visibility: SpaceVisibility; + memberCount: number; + skillCount: number; + createdBy: string; + createdAt: string; + tags: string[]; + featured: boolean; +}; + +const SPACES_PATH = path.join(process.cwd(), "data", "spaces.json"); + +function isVisibility(value: string): value is SpaceVisibility { + return value === "public" || value === "private" || value === "unlisted"; +} + +function normalizeSpaces(data: unknown): Space[] { + if (!Array.isArray(data)) return []; + + return data.filter( + (space): space is Space => + typeof space?.id === "string" && + typeof space?.name === "string" && + typeof space?.slug === "string" && + typeof space?.description === "string" && + typeof space?.visibility === "string" && + isVisibility(space.visibility) && + typeof space?.memberCount === "number" && + typeof space?.skillCount === "number" && + typeof space?.createdBy === "string" && + typeof space?.createdAt === "string" && + Array.isArray(space?.tags) && + space.tags.every((tag: unknown) => typeof tag === "string") && + typeof space?.featured === "boolean" + ); +} + +async function readSpaces(): Promise { + try { + const raw = await fs.readFile(SPACES_PATH, "utf8"); + return normalizeSpaces(JSON.parse(raw)); + } catch { + return []; + } +} + +async function writeSpaces(spaces: Space[]) { + await fs.writeFile(SPACES_PATH, JSON.stringify(spaces, null, 2)); +} + +export async function GET(_request: NextRequest, { params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const targetSlug = typeof slug === "string" ? slug.trim() : ""; + + if (!targetSlug) { + return NextResponse.json({ error: "Space slug is required." }, { status: 400 }); + } + + const spaces = await readSpaces(); + const space = spaces.find((item) => item.slug === targetSlug); + + if (!space) { + return NextResponse.json({ error: "Space not found." }, { status: 404 }); + } + + return NextResponse.json({ space }, { headers: { "Cache-Control": "no-store" } }); +} + +export async function POST(request: NextRequest, { params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const targetSlug = typeof slug === "string" ? slug.trim() : ""; + + if (!targetSlug) { + return NextResponse.json({ error: "Space slug is required." }, { status: 400 }); + } + + const body = (await request.json().catch(() => null)) as { action?: unknown } | null; + const action = typeof body?.action === "string" ? body.action.trim().toLowerCase() : "join"; + + if (action !== "join" && action !== "leave") { + return NextResponse.json({ error: "action must be join or leave." }, { status: 400 }); + } + + const spaces = await readSpaces(); + const index = spaces.findIndex((item) => item.slug === targetSlug); + + if (index === -1) { + return NextResponse.json({ error: "Space not found." }, { status: 404 }); + } + + const existing = spaces[index]; + const memberCount = action === "join" ? existing.memberCount + 1 : Math.max(0, existing.memberCount - 1); + + const updatedSpace: Space = { + ...existing, + memberCount, + }; + + spaces[index] = updatedSpace; + await writeSpaces(spaces); + + return NextResponse.json({ + success: true, + action, + space: updatedSpace, + }); +} diff --git a/src/app/api/spaces/route.ts b/src/app/api/spaces/route.ts new file mode 100644 index 00000000..68819c3c --- /dev/null +++ b/src/app/api/spaces/route.ts @@ -0,0 +1,190 @@ +import { NextRequest, NextResponse } from "next/server"; +import { promises as fs } from "node:fs"; +import path from "node:path"; + +type SpaceVisibility = "public" | "private" | "unlisted"; + +type Space = { + id: string; + name: string; + slug: string; + description: string; + visibility: SpaceVisibility; + memberCount: number; + skillCount: number; + createdBy: string; + createdAt: string; + tags: string[]; + featured: boolean; +}; + +const SPACES_PATH = path.join(process.cwd(), "data", "spaces.json"); + +function isVisibility(value: string): value is SpaceVisibility { + return value === "public" || value === "private" || value === "unlisted"; +} + +function slugify(value: string): string { + return value + .toLowerCase() + .trim() + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-"); +} + +function normalizeSpaces(data: unknown): Space[] { + if (!Array.isArray(data)) return []; + + return data.filter( + (space): space is Space => + typeof space?.id === "string" && + typeof space?.name === "string" && + typeof space?.slug === "string" && + typeof space?.description === "string" && + typeof space?.visibility === "string" && + isVisibility(space.visibility) && + typeof space?.memberCount === "number" && + typeof space?.skillCount === "number" && + typeof space?.createdBy === "string" && + typeof space?.createdAt === "string" && + Array.isArray(space?.tags) && + space.tags.every((tag: unknown) => typeof tag === "string") && + typeof space?.featured === "boolean" + ); +} + +async function readSpaces(): Promise { + try { + const raw = await fs.readFile(SPACES_PATH, "utf8"); + return normalizeSpaces(JSON.parse(raw)); + } catch { + return []; + } +} + +async function writeSpaces(spaces: Space[]) { + await fs.writeFile(SPACES_PATH, JSON.stringify(spaces, null, 2)); +} + +export async function GET(request: NextRequest) { + const visibility = request.nextUrl.searchParams.get("visibility")?.trim().toLowerCase() ?? ""; + const search = request.nextUrl.searchParams.get("search")?.trim().toLowerCase() ?? ""; + const sort = request.nextUrl.searchParams.get("sort")?.trim().toLowerCase() ?? "newest"; + + if (visibility && visibility !== "all" && !isVisibility(visibility)) { + return NextResponse.json( + { + error: "Invalid visibility", + allowed: ["public", "private", "unlisted", "all"], + }, + { status: 400 } + ); + } + + let spaces = await readSpaces(); + + if (visibility && visibility !== "all") { + spaces = spaces.filter((space) => space.visibility === visibility); + } + + if (search) { + spaces = spaces.filter((space) => { + const haystack = `${space.name} ${space.description} ${space.tags.join(" ")} ${space.createdBy}`.toLowerCase(); + return haystack.includes(search); + }); + } + + const sortedSpaces = [...spaces].sort((a, b) => { + if (sort === "members") { + return b.memberCount - a.memberCount || b.createdAt.localeCompare(a.createdAt); + } + + return b.createdAt.localeCompare(a.createdAt); + }); + + return NextResponse.json( + { + spaces: sortedSpaces, + total: sortedSpaces.length, + }, + { + headers: { + "Cache-Control": "no-store", + }, + } + ); +} + +export async function POST(request: NextRequest) { + try { + const body = (await request.json()) as { + name?: unknown; + description?: unknown; + visibility?: unknown; + skillCount?: unknown; + createdBy?: unknown; + tags?: unknown; + featured?: unknown; + }; + + const name = typeof body.name === "string" ? body.name.trim() : ""; + const description = typeof body.description === "string" ? body.description.trim() : ""; + const visibilityRaw = typeof body.visibility === "string" ? body.visibility.trim().toLowerCase() : ""; + const createdBy = typeof body.createdBy === "string" ? body.createdBy.trim() : ""; + const skillCount = typeof body.skillCount === "number" ? body.skillCount : Number(body.skillCount ?? 0); + const featured = typeof body.featured === "boolean" ? body.featured : false; + const tags = Array.isArray(body.tags) + ? body.tags + .filter((tag): tag is string => typeof tag === "string" && tag.trim().length > 0) + .map((tag) => tag.trim()) + : []; + + if (name.length < 3) { + return NextResponse.json({ error: "Name must be at least 3 characters." }, { status: 400 }); + } + + if (description.length < 10) { + return NextResponse.json({ error: "Description must be at least 10 characters." }, { status: 400 }); + } + + if (!isVisibility(visibilityRaw)) { + return NextResponse.json({ error: "Visibility must be public, private, or unlisted." }, { status: 400 }); + } + + if (!createdBy) { + return NextResponse.json({ error: "createdBy is required." }, { status: 400 }); + } + + if (!Number.isFinite(skillCount) || skillCount < 0) { + return NextResponse.json({ error: "skillCount must be a non-negative number." }, { status: 400 }); + } + + const spaces = await readSpaces(); + const baseSlug = slugify(name); + const slug = spaces.some((space) => space.slug === baseSlug) + ? `${baseSlug}-${Math.random().toString(36).slice(2, 6)}` + : baseSlug; + + const space: Space = { + id: `space_${Date.now()}`, + name, + slug, + description, + visibility: visibilityRaw, + memberCount: 1, + skillCount, + createdBy, + createdAt: new Date().toISOString(), + tags, + featured, + }; + + const updated = [space, ...spaces]; + await writeSpaces(updated); + + return NextResponse.json(space, { status: 201 }); + } catch { + return NextResponse.json({ error: "Invalid request body. Expected JSON." }, { status: 400 }); + } +} diff --git a/src/app/spaces/page.tsx b/src/app/spaces/page.tsx index 2ed3870f..10c6dd82 100644 --- a/src/app/spaces/page.tsx +++ b/src/app/spaces/page.tsx @@ -1,93 +1,197 @@ +/* eslint-disable react/no-unescaped-entities */ "use client"; -import { useState } from "react"; +import { FormEvent, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; -import spacesData from "@/data/spaces.json"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; -type ActivityLevel = "high" | "medium" | "low"; +type Visibility = "public" | "private" | "unlisted"; +type Sort = "newest" | "members"; -interface Space { +type Space = { id: string; name: string; + slug: string; description: string; - category: string; + visibility: Visibility; memberCount: number; - activityLevel: ActivityLevel; -} - -const categories = ["All", "Open Source", "Enterprise", "Research", "Hobby", "Education"]; - -const activityColors: Record = { - high: "#06D6A0", - medium: "#FFD93D", - low: "#9CA3AF", + skillCount: number; + createdBy: string; + createdAt: string; + tags: string[]; + featured: boolean; }; -const activityLabels: Record = { - high: "Very Active", - medium: "Active", - low: "Quiet", +type SpacesResponse = { + spaces: Space[]; + total: number; }; -const categoryColors: Record = { - "Open Source": "#06D6A0", - Enterprise: "#3B82F6", - Research: "#8B5CF6", - Hobby: "#F59E0B", - Education: "#EC4899", +const visibilityStyles: Record = { + public: "bg-[#06D6A0]/15 border-[#06D6A0] text-[#06D6A0]", + private: "bg-[#3B82F6]/15 border-[#3B82F6] text-[#3B82F6]", + unlisted: "bg-[#F59E0B]/15 border-[#F59E0B] text-[#F59E0B]", }; export default function SpacesPage() { - const [selectedCategory, setSelectedCategory] = useState("All"); - const spaces = spacesData as Space[]; + const [spaces, setSpaces] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [search, setSearch] = useState(""); + const [visibilityFilter, setVisibilityFilter] = useState<"all" | Visibility>("all"); + const [sort, setSort] = useState("newest"); + + const [joinedSlugs, setJoinedSlugs] = useState([]); + + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [visibility, setVisibility] = useState("public"); + const [skillCount, setSkillCount] = useState("1"); + const [createdBy, setCreatedBy] = useState("@you"); + const [tags, setTags] = useState(""); + const [isCreating, setIsCreating] = useState(false); + + useEffect(() => { + async function fetchSpaces() { + setLoading(true); + setError(null); + + try { + const params = new URLSearchParams(); + if (search.trim()) params.set("search", search.trim()); + params.set("sort", sort); + params.set("visibility", visibilityFilter); + + const response = await fetch(`/api/spaces?${params.toString()}`, { cache: "no-store" }); + + if (!response.ok) { + throw new Error("Failed to load spaces"); + } + + const data = (await response.json()) as SpacesResponse; + setSpaces(data.spaces); + } catch (fetchError) { + const message = fetchError instanceof Error ? fetchError.message : "Unable to fetch spaces"; + setError(message); + } finally { + setLoading(false); + } + } + + fetchSpaces(); + }, [search, visibilityFilter, sort]); + + const stats = useMemo( + () => ({ + totalSpaces: spaces.length, + totalMembers: spaces.reduce((sum, space) => sum + space.memberCount, 0), + totalSkills: spaces.reduce((sum, space) => sum + space.skillCount, 0), + }), + [spaces] + ); + + async function handleCreateSpace(event: FormEvent) { + event.preventDefault(); + setIsCreating(true); + setError(null); + + try { + const response = await fetch("/api/spaces", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name, + description, + visibility, + createdBy, + skillCount: Number(skillCount), + tags: tags + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean), + }), + }); + + if (!response.ok) { + const payload = (await response.json().catch(() => ({}))) as { error?: string }; + throw new Error(payload.error || "Failed to create space"); + } + + const created = (await response.json()) as Space; + setSpaces((prev) => [created, ...prev]); + setName(""); + setDescription(""); + setSkillCount("1"); + setTags(""); + } catch (createError) { + const message = createError instanceof Error ? createError.message : "Unable to create space"; + setError(message); + } finally { + setIsCreating(false); + } + } + + async function handleJoinToggle(space: Space) { + const isJoined = joinedSlugs.includes(space.slug); + const action = isJoined ? "leave" : "join"; + + try { + const response = await fetch(`/api/spaces/${space.slug}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action }), + }); - const filteredSpaces = - selectedCategory === "All" - ? spaces - : spaces.filter((space) => space.category === selectedCategory); + if (!response.ok) { + throw new Error("Could not update membership"); + } - const stats = { - totalSpaces: spaces.length, - totalMembers: spaces.reduce((sum, space) => sum + space.memberCount, 0), - activeSpaces: spaces.filter((space) => space.activityLevel === "high").length, - }; + const payload = (await response.json()) as { space: Space }; + setSpaces((prev) => prev.map((item) => (item.slug === payload.space.slug ? payload.space : item))); + setJoinedSlugs((prev) => + isJoined ? prev.filter((slug) => slug !== space.slug) : [...prev, space.slug] + ); + } catch { + setError("Failed to update membership. Please try again."); + } + } return (
- {/* Hero Section */} -
- {/* Subtle aurora background */} +
-
+

Collaboration Spaces

- Join agents working together on projects, research, and shared interests + Discover shared spaces, join projects, and launch your own collaboration hub.

- {/* Stats */}
{stats.totalSpaces} - Active Spaces + Spaces
{stats.totalMembers} - Total Members + Members
- {stats.activeSpaces} - Very Active + {stats.totalSkills} + Skills
@@ -95,154 +199,177 @@ export default function SpacesPage() { - {/* Category Filter */} -
-
- {categories.map((category) => ( - - ))} +
+
+ setSearch(event.target.value)} + placeholder="Search spaces by name, tags, or description" + className="bg-card/40 border-white/10" + /> + +
+ + + + Create a new space + + Create a persistent collaboration space with visibility controls and tags. It's saved instantly. + + + +
+ setName(event.target.value)} + placeholder="Space name" + required + minLength={3} + className="bg-card/40 border-white/10" + /> + setCreatedBy(event.target.value)} + placeholder="Created by (e.g. @you)" + required + className="bg-card/40 border-white/10" + /> +