diff --git a/.github/ISSUE_TEMPLATE/ecosystem-submission.yml b/.github/ISSUE_TEMPLATE/ecosystem-submission.yml new file mode 100644 index 0000000..64b08b7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/ecosystem-submission.yml @@ -0,0 +1,70 @@ +name: Ecosystem Submission +description: Submit your app to the Livepeer ecosystem directory +title: "Add " +labels: ["ecosystem"] +body: + - type: markdown + attributes: + value: | + ## Ecosystem App Submission + Thanks for building on Livepeer! Please fill in the details below so we can review and add your app to the ecosystem directory. + + - type: input + id: app-name + attributes: + label: App Name + placeholder: "e.g., My Livepeer App" + validations: + required: true + + - type: input + id: website + attributes: + label: Website URL + placeholder: "https://myapp.com" + validations: + required: true + + - type: textarea + id: description + attributes: + label: Description + description: A short description of what your app does and how it uses Livepeer. + placeholder: "Our app uses Livepeer for..." + validations: + required: true + + - type: textarea + id: categories + attributes: + label: Categories + description: "Select all that apply: AI Video, Streaming, Developer Tools" + placeholder: "AI Video, Streaming" + validations: + required: true + + - type: input + id: contact + attributes: + label: Contact Email + description: We'll only use this to follow up on your submission. + placeholder: "you@example.com" + validations: + required: true + + - type: textarea + id: logo + attributes: + label: Logo + description: Please drag and drop your app logo here (SVG or PNG, square, min 128x128px). + placeholder: "Drag and drop your logo image here..." + validations: + required: false + + - type: textarea + id: additional + attributes: + label: Anything else? + description: Optional — anything we should know about your app or a category you think is missing. + validations: + required: false diff --git a/.github/workflows/check-ecosystem-urls.yml b/.github/workflows/check-ecosystem-urls.yml new file mode 100644 index 0000000..20a1d13 --- /dev/null +++ b/.github/workflows/check-ecosystem-urls.yml @@ -0,0 +1,24 @@ +name: Check Ecosystem URLs + +on: + # Run on PRs that touch ecosystem data + pull_request: + paths: + - "data/ecosystem.json" + - "public/ecosystem/**" + + # Weekly check — Mondays at 9am UTC + schedule: + - cron: "0 9 * * 1" + + # Manual trigger + workflow_dispatch: + +jobs: + check-urls: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check ecosystem URLs + run: node scripts/check-ecosystem-urls.mjs diff --git a/.gitignore b/.gitignore index 07b773a..0cf75ca 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,7 @@ brand_guidelines/ img/ *.pdf .claude/ +.codex +.vscode/ +*.code-workspace +.playwright-mcp/ diff --git a/app/blog/page.tsx b/app/blog/page.tsx index ec9e0dc..6f5876d 100644 --- a/app/blog/page.tsx +++ b/app/blog/page.tsx @@ -1,6 +1,7 @@ import Container from "@/components/ui/Container"; import SectionHeader from "@/components/ui/SectionHeader"; import BlogListingClient from "@/components/blog/BlogListingClient"; +import PageHero from "@/components/ui/PageHero"; import { getAllPosts, getCategories } from "@/lib/blog"; export default function BlogPage() { @@ -8,7 +9,7 @@ export default function BlogPage() { const categories = getCategories(); return ( -
+ -
+ ); } diff --git a/app/ecosystem/page.tsx b/app/ecosystem/page.tsx new file mode 100644 index 0000000..f334979 --- /dev/null +++ b/app/ecosystem/page.tsx @@ -0,0 +1,351 @@ +"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; + +/* Collect tags grouped by category */ +const TAGS_BY_CATEGORY: Record = {}; +for (const app of ECOSYSTEM_APPS) { + for (const cat of app.categories) { + if (!TAGS_BY_CATEGORY[cat]) TAGS_BY_CATEGORY[cat] = []; + for (const tag of app.tags ?? []) { + if (!TAGS_BY_CATEGORY[cat].includes(tag)) { + TAGS_BY_CATEGORY[cat].push(tag); + } + } + } +} + +/* Categories that have tags get a dropdown chevron */ +const CATEGORIES_WITH_TAGS = ECOSYSTEM_CATEGORIES.filter( + (cat) => cat !== "All" && TAGS_BY_CATEGORY[cat]?.length > 0, +); + +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 [activeTags, setActiveTags] = useState(() => { + const param = searchParams.get("tags"); + 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 (activeTags.length > 0) params.set("tags", activeTags.join(",")); + if (search) params.set("q", search); + + const qs = params.toString(); + const url = qs ? `${pathname}?${qs}` : pathname; + router.replace(url, { scroll: false }); + }, [activeCategories, activeTags, search, pathname, router]); + + useEffect(() => { + setVisible(BATCH_SIZE); + }, [activeCategories, activeTags, search]); + + const handleCategoryToggle = (cat: string) => { + if (cat === "All") { + setActiveCategories([]); + setActiveTags([]); + return; + } + setActiveCategories((prev) => { + const next = prev.includes(cat) + ? prev.filter((c) => c !== cat) + : [...prev, cat]; + if (next.length === 0) setActiveTags([]); + return next; + }); + }; + + const handleTagToggle = (tag: string) => { + setActiveTags((prev) => + prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag], + ); + }; + + const filtered = useMemo(() => { + return ECOSYSTEM_APPS.filter((app) => { + const matchesCategory = + isAllActive || activeCategories.some((c) => app.categories.includes(c)); + const matchesTags = + activeTags.length === 0 || + activeTags.some((t) => app.tags?.includes(t)); + const matchesSearch = + !search || + app.name.toLowerCase().includes(search.toLowerCase()) || + app.description.toLowerCase().includes(search.toLowerCase()); + return matchesCategory && matchesTags && matchesSearch; + }); + }, [activeCategories, activeTags, 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")} + /> + {CATEGORIES_WITH_TAGS.map((cat) => ( + handleCategoryToggle(cat)} + dropdown={ + TAGS_BY_CATEGORY[cat]?.length > 0 + ? { + items: TAGS_BY_CATEGORY[cat], + activeItems: activeTags, + onItemToggle: handleTagToggle, + } + : undefined + } + /> + ))} +
+ +
+ + 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} + + ))} + {app.tags?.map((tag) => ( + + {tag} + + ))} +
+
+ ); + })} +
+ ) : ( +
+

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

+

+ Try searching for another term. + +

+ +
+ )} + + {hasMore && ( +
+ {/* Infinite scroll on mobile, "View more" button on desktop */} + + )} + +
+ + ); +} + +export default function EcosystemPage() { + return ( + + + + ); +} diff --git a/app/ecosystem/submit/page.tsx b/app/ecosystem/submit/page.tsx new file mode 100644 index 0000000..fa30df7 --- /dev/null +++ b/app/ecosystem/submit/page.tsx @@ -0,0 +1,154 @@ +"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"); + +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" + /> +
+ +
+ +