From 5882f7e7df4375c126d65da5132023467c9eaf4c Mon Sep 17 00:00:00 2001 From: adamsoffer Date: Tue, 7 Apr 2026 13:08:05 -0400 Subject: [PATCH 1/6] Add ecosystem detail pages with markdown-driven content Migrate ecosystem data from data/ecosystem.json to per-app markdown files under content/ecosystem/, mirroring the existing blog pipeline. Each app now has a dedicated detail page at /ecosystem/[slug] with a Linear-inspired two-column layout: an overview rendered from markdown alongside a sticky metadata sidebar grouped into Details, Connect, and Resources. Listing cards on /ecosystem and the four bento cards on the homepage now link to the internal detail pages instead of opening external URLs, and the Daydream CTA in the Start Building section points to /ecosystem/daydream. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/ecosystem/[slug]/page.tsx | 54 +++ app/ecosystem/page.tsx | 335 +--------------- app/ecosystem/submit/page.tsx | 171 +------- components/ecosystem/EcosystemDetail.tsx | 365 ++++++++++++++++++ .../ecosystem/EcosystemListingClient.tsx | 338 ++++++++++++++++ components/ecosystem/SubmitAppForm.tsx | 171 ++++++++ components/home/BuiltOnLivepeer.tsx | 27 +- components/home/StartBuilding.tsx | 11 +- content/ecosystem/blueclaw.md | 44 +++ content/ecosystem/daydream.md | 43 +++ content/ecosystem/embody.md | 38 ++ content/ecosystem/frameworks.md | 45 +++ content/ecosystem/higher.md | 30 ++ content/ecosystem/livepeer-studio.md | 42 ++ content/ecosystem/nytv.md | 22 ++ content/ecosystem/spritz.md | 37 ++ content/ecosystem/streamplace.md | 42 ++ content/ecosystem/thelotradio.md | 27 ++ content/ecosystem/tribesocial.md | 45 +++ content/ecosystem/ufo.md | 37 ++ data/ecosystem.json | 99 ----- lib/ecosystem-data.ts | 22 -- lib/ecosystem.ts | 110 ++++++ 23 files changed, 1535 insertions(+), 620 deletions(-) create mode 100644 app/ecosystem/[slug]/page.tsx create mode 100644 components/ecosystem/EcosystemDetail.tsx create mode 100644 components/ecosystem/EcosystemListingClient.tsx create mode 100644 components/ecosystem/SubmitAppForm.tsx create mode 100644 content/ecosystem/blueclaw.md create mode 100644 content/ecosystem/daydream.md create mode 100644 content/ecosystem/embody.md create mode 100644 content/ecosystem/frameworks.md create mode 100644 content/ecosystem/higher.md create mode 100644 content/ecosystem/livepeer-studio.md create mode 100644 content/ecosystem/nytv.md create mode 100644 content/ecosystem/spritz.md create mode 100644 content/ecosystem/streamplace.md create mode 100644 content/ecosystem/thelotradio.md create mode 100644 content/ecosystem/tribesocial.md create mode 100644 content/ecosystem/ufo.md delete mode 100644 data/ecosystem.json delete mode 100644 lib/ecosystem-data.ts create mode 100644 lib/ecosystem.ts diff --git a/app/ecosystem/[slug]/page.tsx b/app/ecosystem/[slug]/page.tsx new file mode 100644 index 0000000..5af9dc9 --- /dev/null +++ b/app/ecosystem/[slug]/page.tsx @@ -0,0 +1,54 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import EcosystemDetail from "@/components/ecosystem/EcosystemDetail"; +import { + getAppBySlug, + getAppSlugs, + renderEcosystemMarkdown, +} from "@/lib/ecosystem"; + +type Props = { + params: Promise<{ slug: string }>; +}; + +export async function generateStaticParams() { + return getAppSlugs().map((slug) => ({ slug })); +} + +export async function generateMetadata({ params }: Props): Promise { + const { slug } = await params; + try { + const app = getAppBySlug(slug); + return { + title: `${app.name} | Livepeer Ecosystem`, + description: app.description, + openGraph: { + title: `${app.name} | Livepeer Ecosystem`, + description: app.description, + type: "website", + }, + twitter: { + card: "summary_large_image", + title: `${app.name} | Livepeer Ecosystem`, + description: app.description, + }, + }; + } catch { + return { title: "App Not Found — Livepeer Ecosystem" }; + } +} + +export default async function EcosystemAppPage({ params }: Props) { + const { slug } = await params; + + let app; + try { + app = getAppBySlug(slug); + } catch { + notFound(); + } + + const html = await renderEcosystemMarkdown(app.content); + + return ; +} diff --git a/app/ecosystem/page.tsx b/app/ecosystem/page.tsx index b06fc67..9a5dd51 100644 --- a/app/ecosystem/page.tsx +++ b/app/ecosystem/page.tsx @@ -1,321 +1,20 @@ -"use client"; - -import { - Suspense, - useState, - useMemo, - useEffect, - useRef, - useCallback, -} from "react"; -import { Search, Plus, ArrowUpRight } from "lucide-react"; -import { motion, AnimatePresence } from "framer-motion"; -import { useSearchParams, useRouter, usePathname } from "next/navigation"; -import { ECOSYSTEM_APPS, ECOSYSTEM_CATEGORIES } from "@/lib/ecosystem-data"; -import PageHero from "@/components/ui/PageHero"; -import Container from "@/components/ui/Container"; -import SectionHeader from "@/components/ui/SectionHeader"; -import Badge from "@/components/ui/Badge"; -import Button from "@/components/ui/Button"; -import FilterPill from "@/components/ui/FilterPill"; - -const BATCH_SIZE = 12; - -function EcosystemPageInner() { - const searchParams = useSearchParams(); - const router = useRouter(); - const pathname = usePathname(); - - const [activeCategories, setActiveCategories] = useState(() => { - const param = searchParams.get("categories"); - return param ? param.split(",").map(decodeURIComponent) : []; - }); - const [search, setSearch] = useState(() => searchParams.get("q") ?? ""); - const [visible, setVisible] = useState(BATCH_SIZE); - const [buttonBatch, setButtonBatch] = useState(0); - const sentinelRef = useRef(null); - - const isAllActive = activeCategories.length === 0; - const loadMore = useCallback(() => setVisible((v) => v + BATCH_SIZE), []); - const handleButtonLoad = useCallback(() => { - setButtonBatch(visible); - loadMore(); - }, [visible, loadMore]); - - /* Sync filter state → URL query params */ - useEffect(() => { - const params = new URLSearchParams(); - if (activeCategories.length > 0) - params.set("categories", activeCategories.join(",")); - if (search) params.set("q", search); - - const qs = params.toString(); - const url = qs ? `${pathname}?${qs}` : pathname; - router.replace(url, { scroll: false }); - }, [activeCategories, search, pathname, router]); - - useEffect(() => { - setVisible(BATCH_SIZE); - }, [activeCategories, search]); - - const handleCategoryToggle = (cat: string) => { - if (cat === "All") { - setActiveCategories([]); - return; - } - setActiveCategories((prev) => - prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat] - ); - }; - - const filtered = useMemo(() => { - return ECOSYSTEM_APPS.filter((app) => { - const matchesCategory = - isAllActive || activeCategories.some((c) => app.categories.includes(c)); - const matchesSearch = - !search || - app.name.toLowerCase().includes(search.toLowerCase()) || - app.description.toLowerCase().includes(search.toLowerCase()); - return matchesCategory && matchesSearch; - }); - }, [activeCategories, search, isAllActive]); - - const shown = filtered.slice(0, visible); - const hasMore = visible < filtered.length; - - // Infinite scroll with IntersectionObserver on mobile, "View more" button on desktop. - useEffect(() => { - const el = sentinelRef.current; - if (!el || !hasMore) return; - - const observer = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting) loadMore(); - }, - { rootMargin: "200px" } - ); - observer.observe(el); - return () => observer.disconnect(); - }, [hasMore, loadMore]); - - return ( - -
- - - - Submit App - - } - /> - - {/* Filter bar */} -
-
- handleCategoryToggle("All")} - /> - 0} - onToggle={() => handleCategoryToggle("All")} - dropdown={{ - items: ECOSYSTEM_CATEGORIES.filter((c) => c !== "All"), - activeItems: activeCategories, - onItemToggle: handleCategoryToggle, - }} - /> -
- -
- - setSearch(e.target.value)} - className="w-full rounded-md border border-white/[0.12] bg-white/[0.03] backdrop-blur-sm py-1.5 pl-9 pr-8 text-sm text-white/60 placeholder:text-white/30 transition-colors duration-200 focus:bg-white/[0.05] focus:border-white/20 focus:outline-none sm:w-56 select-none" - /> - - {search && ( - setSearch("")} - className="absolute right-2.5 top-1/2 -translate-y-1/2 cursor-pointer text-white/50 transition-colors hover:text-white/80" - aria-label="Clear search" - > - - - - - )} - -
-
- - {/* App grid */} - {shown.length > 0 ? ( -
- {shown.map((app, index) => { - const inButtonBatch = - buttonBatch >= 0 && - index >= buttonBatch && - index < buttonBatch + BATCH_SIZE; - return ( - -
-
- {app.logo ? ( - {`${app.name} - ) : ( - - {app.name.charAt(0)} - - )} -
- -
-

- {app.name} -

-

- {app.hostname} -

-

- {app.description} -

-
- {app.categories.map((cat) => ( - - {cat} - - ))} -
-
- ); - })} -
- ) : ( -
-

- No results found{search ? ` for \u201c${search}\u201d` : ""} -

-

- Try searching for another term. - -

- -
- )} - - {hasMore && ( -
- {/* Infinite scroll on mobile, "View more" button on desktop */} - - )} - -
- - ); -} +import EcosystemListingClient, { + type EcosystemListingApp, +} from "@/components/ecosystem/EcosystemListingClient"; +import { getAllApps, getEcosystemCategories } from "@/lib/ecosystem"; export default function EcosystemPage() { - return ( - - - - ); + const apps: EcosystemListingApp[] = getAllApps().map((app) => ({ + slug: app.slug, + name: app.name, + url: app.url, + hostname: app.hostname, + description: app.description, + categories: app.categories, + logo: app.logo, + logoBg: app.logoBg, + })); + const categories = getEcosystemCategories(); + + return ; } diff --git a/app/ecosystem/submit/page.tsx b/app/ecosystem/submit/page.tsx index 7e15be4..291fce2 100644 --- a/app/ecosystem/submit/page.tsx +++ b/app/ecosystem/submit/page.tsx @@ -1,170 +1,7 @@ -"use client"; - -import { useState } from "react"; -import Link from "next/link"; -import { ECOSYSTEM_CATEGORIES } from "@/lib/ecosystem-data"; -import PageHero from "@/components/ui/PageHero"; -import Container from "@/components/ui/Container"; -import Button from "@/components/ui/Button"; -import FilterPill from "@/components/ui/FilterPill"; - -const CATEGORIES = ECOSYSTEM_CATEGORIES.filter((c) => c !== "All"); +import SubmitAppForm from "@/components/ecosystem/SubmitAppForm"; +import { getEcosystemCategories } from "@/lib/ecosystem"; export default function SubmitAppPage() { - const [name, setName] = useState(""); - const [url, setUrl] = useState(""); - const [description, setDescription] = useState(""); - const [categories, setCategories] = useState([]); - const [email, setEmail] = useState(""); - - const handleSubmit = (e: React.SyntheticEvent) => { - e.preventDefault(); - - const issueUrl = new URL("https://github.com/livepeer/naap/issues/new"); - issueUrl.searchParams.set("template", "ecosystem-submission.yml"); - issueUrl.searchParams.set("title", `Add ${name}`); - issueUrl.searchParams.set("app-name", name); - issueUrl.searchParams.set("website", url); - issueUrl.searchParams.set("description", description); - issueUrl.searchParams.set("categories", categories.join(", ")); - issueUrl.searchParams.set("contact", email); - - window.open(issueUrl.toString(), "_blank"); - }; - - const isValid = name && url && description && categories.length > 0 && email; - - return ( - - -
- - Ecosystem - - - Submit -
-

- Submit your app -

-

- Built something on Livepeer? Submit your app to be featured in the - ecosystem directory. This will open a GitHub issue for review. -

- -
-
- - setName(e.target.value)} - placeholder="My Livepeer App" - className="w-full rounded-lg border border-dark-border bg-dark-card px-4 py-3 text-sm text-white placeholder:text-white/20 focus:border-white/20 focus:outline-none" - /> -
- -
- - setUrl(e.target.value)} - placeholder="https://myapp.com" - className="w-full rounded-lg border border-dark-border bg-dark-card px-4 py-3 text-sm text-white placeholder:text-white/20 focus:border-white/20 focus:outline-none" - /> -
- -
- -