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" - /> -
- -
- -