Skip to content

Commit 588cdd0

Browse files
rickstaaclaude
andcommitted
feat: add ecosystem page and refine shared UI components
Introduces the ecosystem directory at /ecosystem with a filterable app grid, search with clear functionality, and a submission flow that integrates with GitHub issue templates for structured app submissions. Shared UI components were extracted and improved for consistency across the site. FilterPills replaces inline filter buttons on both blog and ecosystem pages. Badge gains a "tag" variant for neutral card metadata. PageHero's grid background was rebuilt using CSS Grid instead of background-size to eliminate sub-pixel rounding drift between pages. Secondary interactive elements now follow a unified two-tier hover system (white/50 rest, white/80 hover) with consistent cursor and border behavior. Accessibility improvements include aria-pressed on filter pills and aria-label on the search input. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2b56777 commit 588cdd0

22 files changed

Lines changed: 5386 additions & 44 deletions
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
name: Ecosystem Submission
2+
description: Submit your app to the Livepeer ecosystem directory
3+
title: "Add "
4+
labels: ["ecosystem"]
5+
body:
6+
- type: markdown
7+
attributes:
8+
value: |
9+
## Ecosystem App Submission
10+
Thanks for building on Livepeer! Please fill in the details below so we can review and add your app to the ecosystem directory.
11+
12+
- type: input
13+
id: app-name
14+
attributes:
15+
label: App Name
16+
placeholder: "e.g., My Livepeer App"
17+
validations:
18+
required: true
19+
20+
- type: input
21+
id: website
22+
attributes:
23+
label: Website URL
24+
placeholder: "https://myapp.com"
25+
validations:
26+
required: true
27+
28+
- type: textarea
29+
id: description
30+
attributes:
31+
label: Description
32+
description: A short description of what your app does and how it uses Livepeer.
33+
placeholder: "Our app uses Livepeer for..."
34+
validations:
35+
required: true
36+
37+
- type: textarea
38+
id: categories
39+
attributes:
40+
label: Categories
41+
description: "Select all that apply: AI Video, Streaming, Developer Tools"
42+
placeholder: "AI Video, Streaming"
43+
validations:
44+
required: true
45+
46+
- type: input
47+
id: contact
48+
attributes:
49+
label: Contact Email
50+
description: We'll only use this to follow up on your submission.
51+
placeholder: "you@example.com"
52+
validations:
53+
required: true
54+
55+
- type: textarea
56+
id: logo
57+
attributes:
58+
label: Logo
59+
description: Please drag and drop your app logo here (SVG or PNG, square, min 128x128px).
60+
placeholder: "Drag and drop your logo image here..."
61+
validations:
62+
required: false
63+
64+
- type: textarea
65+
id: additional
66+
attributes:
67+
label: Anything else?
68+
description: Optional — anything we should know about your app or a category you think is missing.
69+
validations:
70+
required: false
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: Check Ecosystem URLs
2+
3+
on:
4+
# Run on PRs that touch ecosystem data
5+
pull_request:
6+
paths:
7+
- "data/ecosystem.json"
8+
- "public/ecosystem/**"
9+
10+
# Weekly check — Mondays at 9am UTC
11+
schedule:
12+
- cron: "0 9 * * 1"
13+
14+
# Manual trigger
15+
workflow_dispatch:
16+
17+
jobs:
18+
check-urls:
19+
runs-on: ubuntu-latest
20+
steps:
21+
- uses: actions/checkout@v4
22+
23+
- name: Check ecosystem URLs
24+
run: node scripts/check-ecosystem-urls.mjs

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,5 @@ brand_guidelines/
4343
img/
4444
*.pdf
4545
.claude/
46+
.vscode/
47+
.playwright-mcp/

app/blog/page.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import Container from "@/components/ui/Container";
22
import SectionHeader from "@/components/ui/SectionHeader";
33
import BlogListingClient from "@/components/blog/BlogListingClient";
4+
import PageHero from "@/components/ui/PageHero";
45
import { getAllPosts, getCategories } from "@/lib/blog";
56

67
export default function BlogPage() {
78
const posts = getAllPosts();
89
const categories = getCategories();
910

1011
return (
11-
<section className="pt-24 pb-16 lg:pt-32 lg:pb-24">
12+
<PageHero>
1213
<Container>
1314
<SectionHeader
1415
label="Blog"
@@ -21,6 +22,6 @@ export default function BlogPage() {
2122
<BlogListingClient posts={posts} categories={categories} />
2223
</div>
2324
</Container>
24-
</section>
25+
</PageHero>
2526
);
2627
}

app/ecosystem/page.tsx

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
"use client";
2+
3+
import { useState, useMemo, useEffect } from "react";
4+
import { Search, Plus, ArrowUpRight } from "lucide-react";
5+
import { motion, AnimatePresence } from "framer-motion";
6+
import { ECOSYSTEM_APPS, ECOSYSTEM_CATEGORIES } from "@/lib/ecosystem-data";
7+
import PageHero from "@/components/ui/PageHero";
8+
import Container from "@/components/ui/Container";
9+
import SectionHeader from "@/components/ui/SectionHeader";
10+
import FilterPills from "@/components/ui/FilterPills";
11+
import Badge from "@/components/ui/Badge";
12+
import Button from "@/components/ui/Button";
13+
14+
const BATCH_SIZE = 12;
15+
16+
export default function EcosystemPage() {
17+
const [activeCategory, setActiveCategory] = useState("All");
18+
const [search, setSearch] = useState("");
19+
const [visible, setVisible] = useState(BATCH_SIZE);
20+
21+
useEffect(() => {
22+
setVisible(BATCH_SIZE);
23+
}, [activeCategory, search]);
24+
25+
const filtered = useMemo(() => {
26+
return ECOSYSTEM_APPS.filter((app) => {
27+
const matchesCategory =
28+
activeCategory === "All" || app.categories.includes(activeCategory);
29+
const matchesSearch =
30+
!search ||
31+
app.name.toLowerCase().includes(search.toLowerCase()) ||
32+
app.description.toLowerCase().includes(search.toLowerCase());
33+
return matchesCategory && matchesSearch;
34+
});
35+
}, [activeCategory, search]);
36+
37+
const shown = filtered.slice(0, visible);
38+
const hasMore = visible < filtered.length;
39+
40+
return (
41+
<PageHero>
42+
<div className="min-h-screen">
43+
<Container>
44+
<div className="flex items-start justify-between gap-4">
45+
<SectionHeader
46+
label="Ecosystem"
47+
title="Built on Livepeer"
48+
description="Explore what developers and teams are building with real-time video and AI inference on Livepeer."
49+
align="left"
50+
/>
51+
<Button
52+
href="/ecosystem/submit"
53+
variant="secondary"
54+
size="sm"
55+
className="mt-8 shrink-0 backdrop-blur-sm text-white/60 hover:text-white/80"
56+
>
57+
<Plus className="h-3 w-3" />
58+
Submit App
59+
</Button>
60+
</div>
61+
62+
{/* Filter bar */}
63+
<div className="mt-8 flex flex-col gap-4 sm:mt-12 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
64+
<FilterPills
65+
items={ECOSYSTEM_CATEGORIES}
66+
active={activeCategory}
67+
onChange={setActiveCategory}
68+
/>
69+
70+
<div className="relative">
71+
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-white/20" />
72+
<input
73+
type="text"
74+
placeholder="Search"
75+
aria-label="Search ecosystem apps"
76+
value={search}
77+
onChange={(e) => setSearch(e.target.value)}
78+
className="w-full rounded-sm border border-dark-border bg-transparent py-1.5 pl-9 pr-8 text-sm text-white placeholder:text-white/25 focus:border-white/15 focus:outline-none sm:w-56 select-none"
79+
/>
80+
<AnimatePresence>
81+
{search && (
82+
<motion.button
83+
initial={{ opacity: 0 }}
84+
animate={{ opacity: 1, transition: { duration: 0.2 } }}
85+
exit={{ opacity: 0, transition: { duration: 0.5 } }}
86+
onClick={() => setSearch("")}
87+
className="absolute right-2.5 top-1/2 -translate-y-1/2 cursor-pointer text-white/50 transition-colors hover:text-white/80"
88+
aria-label="Clear search"
89+
>
90+
<svg
91+
width="20"
92+
height="20"
93+
viewBox="0 0 20 20"
94+
fill="none"
95+
className="block"
96+
>
97+
<path
98+
d="M5 5L15 15M15 5L5 15"
99+
stroke="currentColor"
100+
strokeWidth="2"
101+
strokeLinecap="round"
102+
/>
103+
</svg>
104+
</motion.button>
105+
)}
106+
</AnimatePresence>
107+
</div>
108+
</div>
109+
110+
{/* App grid */}
111+
{shown.length > 0 ? (
112+
<div className="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
113+
{shown.map((app, index) => (
114+
<motion.a
115+
key={app.id}
116+
href={app.url}
117+
target="_blank"
118+
rel="noopener noreferrer"
119+
initial={{ opacity: 0, y: 20 }}
120+
whileInView={{ opacity: 1, y: 0 }}
121+
viewport={{ once: true }}
122+
transition={{
123+
duration: 0.5,
124+
delay: (index % BATCH_SIZE) * 0.1,
125+
}}
126+
className="group flex flex-col rounded-2xl border border-dark-border bg-dark-card p-5 transition-colors hover:border-white/10 sm:p-6 select-none"
127+
>
128+
<div className="mb-4 flex items-start justify-between">
129+
<div className="flex h-14 w-14 items-center justify-center overflow-hidden rounded-xl bg-white/[0.06]">
130+
{app.logo ? (
131+
<img
132+
src={`/ecosystem/${app.logo}`}
133+
alt={`${app.name} logo`}
134+
className="h-10 w-10 object-contain"
135+
/>
136+
) : (
137+
<span className="text-2xl font-semibold text-white/30">
138+
{app.name.charAt(0)}
139+
</span>
140+
)}
141+
</div>
142+
<ArrowUpRight className="h-4 w-4 text-white/0 transition-colors group-hover:text-white/40" />
143+
</div>
144+
<h3 className="text-base font-semibold text-white transition-colors group-hover:text-green-light">
145+
{app.name}
146+
</h3>
147+
<p className="mt-0.5 font-mono text-xs text-white/25">
148+
{app.hostname}
149+
</p>
150+
<p className="mt-3 flex-1 text-sm leading-relaxed text-white/40">
151+
{app.description}
152+
</p>
153+
<div className="mt-4 flex flex-wrap gap-1.5">
154+
{app.categories.map((cat) => (
155+
<Badge key={cat} variant="tag">
156+
{cat}
157+
</Badge>
158+
))}
159+
</div>
160+
</motion.a>
161+
))}
162+
</div>
163+
) : (
164+
<div className="mt-16">
165+
<h3 className="text-2xl font-bold tracking-tight text-white sm:text-3xl">
166+
No results found{search ? ` for \u201c${search}\u201d` : ""}
167+
</h3>
168+
<p className="mt-3 flex items-center gap-3 text-sm text-white/40">
169+
Try searching for another term.
170+
<button
171+
onClick={() => {
172+
setSearch("");
173+
setActiveCategory("All");
174+
}}
175+
className="cursor-pointer rounded border border-white/10 px-3 py-1 text-xs font-medium text-white/50 transition-colors hover:border-white/20 hover:text-white/80"
176+
>
177+
Clear search
178+
</button>
179+
</p>
180+
<motion.img
181+
src="/ecosystem/no-results.png"
182+
alt=""
183+
loading="eager"
184+
initial={{ opacity: 0 }}
185+
animate={{ opacity: 0.35 }}
186+
transition={{ duration: 0.6, delay: 0.2 }}
187+
className="mt-12 w-full max-w-sm select-none pointer-events-none"
188+
/>
189+
</div>
190+
)}
191+
192+
{hasMore && (
193+
<div className="mt-6 text-center">
194+
<button
195+
onClick={() => setVisible((v) => v + BATCH_SIZE)}
196+
className="cursor-pointer rounded-sm border border-white/10 px-6 py-2.5 text-sm font-medium text-white/50 transition-colors hover:border-white/20 hover:text-white/80"
197+
>
198+
View more
199+
</button>
200+
</div>
201+
)}
202+
</Container>
203+
</div>
204+
</PageHero>
205+
);
206+
}

0 commit comments

Comments
 (0)