Skip to content

Commit fbcdd3e

Browse files
adamsofferclaude
andauthored
Add ecosystem detail pages (#39)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0173e7e commit fbcdd3e

25 files changed

Lines changed: 1564 additions & 1227 deletions

app/ecosystem/[slug]/page.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { Metadata } from "next";
2+
import { notFound } from "next/navigation";
3+
import EcosystemDetail from "@/components/ecosystem/EcosystemDetail";
4+
import {
5+
getAppBySlug,
6+
getAppSlugs,
7+
renderEcosystemMarkdown,
8+
} from "@/lib/ecosystem";
9+
10+
type Props = {
11+
params: Promise<{ slug: string }>;
12+
};
13+
14+
export async function generateStaticParams() {
15+
return getAppSlugs().map((slug) => ({ slug }));
16+
}
17+
18+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
19+
const { slug } = await params;
20+
try {
21+
const app = getAppBySlug(slug);
22+
return {
23+
title: `${app.name} | Livepeer Ecosystem`,
24+
description: app.description,
25+
openGraph: {
26+
title: `${app.name} | Livepeer Ecosystem`,
27+
description: app.description,
28+
type: "website",
29+
},
30+
twitter: {
31+
card: "summary_large_image",
32+
title: `${app.name} | Livepeer Ecosystem`,
33+
description: app.description,
34+
},
35+
};
36+
} catch {
37+
return { title: "App Not Found — Livepeer Ecosystem" };
38+
}
39+
}
40+
41+
export default async function EcosystemAppPage({ params }: Props) {
42+
const { slug } = await params;
43+
44+
let app;
45+
try {
46+
app = getAppBySlug(slug);
47+
} catch {
48+
notFound();
49+
}
50+
51+
const html = await renderEcosystemMarkdown(app.content);
52+
53+
return <EcosystemDetail app={app} html={html} />;
54+
}

app/ecosystem/page.tsx

Lines changed: 17 additions & 318 deletions
Original file line numberDiff line numberDiff line change
@@ -1,321 +1,20 @@
1-
"use client";
2-
3-
import {
4-
Suspense,
5-
useState,
6-
useMemo,
7-
useEffect,
8-
useRef,
9-
useCallback,
10-
} from "react";
11-
import { Search, Plus, ArrowUpRight } from "lucide-react";
12-
import { motion, AnimatePresence } from "framer-motion";
13-
import { useSearchParams, useRouter, usePathname } from "next/navigation";
14-
import { ECOSYSTEM_APPS, ECOSYSTEM_CATEGORIES } from "@/lib/ecosystem-data";
15-
import PageHero from "@/components/ui/PageHero";
16-
import Container from "@/components/ui/Container";
17-
import SectionHeader from "@/components/ui/SectionHeader";
18-
import Badge from "@/components/ui/Badge";
19-
import Button from "@/components/ui/Button";
20-
import FilterPill from "@/components/ui/FilterPill";
21-
22-
const BATCH_SIZE = 12;
23-
24-
function EcosystemPageInner() {
25-
const searchParams = useSearchParams();
26-
const router = useRouter();
27-
const pathname = usePathname();
28-
29-
const [activeCategories, setActiveCategories] = useState<string[]>(() => {
30-
const param = searchParams.get("categories");
31-
return param ? param.split(",").map(decodeURIComponent) : [];
32-
});
33-
const [search, setSearch] = useState(() => searchParams.get("q") ?? "");
34-
const [visible, setVisible] = useState(BATCH_SIZE);
35-
const [buttonBatch, setButtonBatch] = useState(0);
36-
const sentinelRef = useRef<HTMLDivElement>(null);
37-
38-
const isAllActive = activeCategories.length === 0;
39-
const loadMore = useCallback(() => setVisible((v) => v + BATCH_SIZE), []);
40-
const handleButtonLoad = useCallback(() => {
41-
setButtonBatch(visible);
42-
loadMore();
43-
}, [visible, loadMore]);
44-
45-
/* Sync filter state → URL query params */
46-
useEffect(() => {
47-
const params = new URLSearchParams();
48-
if (activeCategories.length > 0)
49-
params.set("categories", activeCategories.join(","));
50-
if (search) params.set("q", search);
51-
52-
const qs = params.toString();
53-
const url = qs ? `${pathname}?${qs}` : pathname;
54-
router.replace(url, { scroll: false });
55-
}, [activeCategories, search, pathname, router]);
56-
57-
useEffect(() => {
58-
setVisible(BATCH_SIZE);
59-
}, [activeCategories, search]);
60-
61-
const handleCategoryToggle = (cat: string) => {
62-
if (cat === "All") {
63-
setActiveCategories([]);
64-
return;
65-
}
66-
setActiveCategories((prev) =>
67-
prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat]
68-
);
69-
};
70-
71-
const filtered = useMemo(() => {
72-
return ECOSYSTEM_APPS.filter((app) => {
73-
const matchesCategory =
74-
isAllActive || activeCategories.some((c) => app.categories.includes(c));
75-
const matchesSearch =
76-
!search ||
77-
app.name.toLowerCase().includes(search.toLowerCase()) ||
78-
app.description.toLowerCase().includes(search.toLowerCase());
79-
return matchesCategory && matchesSearch;
80-
});
81-
}, [activeCategories, search, isAllActive]);
82-
83-
const shown = filtered.slice(0, visible);
84-
const hasMore = visible < filtered.length;
85-
86-
// Infinite scroll with IntersectionObserver on mobile, "View more" button on desktop.
87-
useEffect(() => {
88-
const el = sentinelRef.current;
89-
if (!el || !hasMore) return;
90-
91-
const observer = new IntersectionObserver(
92-
([entry]) => {
93-
if (entry.isIntersecting) loadMore();
94-
},
95-
{ rootMargin: "200px" }
96-
);
97-
observer.observe(el);
98-
return () => observer.disconnect();
99-
}, [hasMore, loadMore]);
100-
101-
return (
102-
<PageHero>
103-
<div className="min-h-screen">
104-
<Container>
105-
<SectionHeader
106-
label="Ecosystem"
107-
title="Built on Livepeer"
108-
description="Explore what developers and teams are building with real-time video and AI inference on Livepeer."
109-
align="left"
110-
action={
111-
<Button
112-
href="/ecosystem/submit"
113-
variant="secondary"
114-
size="sm"
115-
className="shrink-0 backdrop-blur-sm text-white/60 hover:text-white/80"
116-
>
117-
<Plus className="h-3 w-3" />
118-
Submit App
119-
</Button>
120-
}
121-
/>
122-
123-
{/* Filter bar */}
124-
<div className="mt-8 flex flex-col gap-4 sm:mt-12 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
125-
<div
126-
className="flex flex-wrap gap-2 select-none"
127-
role="group"
128-
aria-label="Filter by category"
129-
>
130-
<FilterPill
131-
label="All"
132-
isActive={isAllActive}
133-
onToggle={() => handleCategoryToggle("All")}
134-
/>
135-
<FilterPill
136-
label="Categories"
137-
isActive={activeCategories.length > 0}
138-
onToggle={() => handleCategoryToggle("All")}
139-
dropdown={{
140-
items: ECOSYSTEM_CATEGORIES.filter((c) => c !== "All"),
141-
activeItems: activeCategories,
142-
onItemToggle: handleCategoryToggle,
143-
}}
144-
/>
145-
</div>
146-
147-
<div className="relative">
148-
<Search className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-white/50" />
149-
<input
150-
type="text"
151-
placeholder="Search"
152-
aria-label="Search ecosystem apps"
153-
value={search}
154-
onChange={(e) => setSearch(e.target.value)}
155-
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"
156-
/>
157-
<AnimatePresence>
158-
{search && (
159-
<motion.button
160-
initial={{ opacity: 0 }}
161-
animate={{ opacity: 1, transition: { duration: 0.2 } }}
162-
exit={{ opacity: 0, transition: { duration: 0.5 } }}
163-
onClick={() => setSearch("")}
164-
className="absolute right-2.5 top-1/2 -translate-y-1/2 cursor-pointer text-white/50 transition-colors hover:text-white/80"
165-
aria-label="Clear search"
166-
>
167-
<svg
168-
width="20"
169-
height="20"
170-
viewBox="0 0 20 20"
171-
fill="none"
172-
className="block"
173-
>
174-
<path
175-
d="M5 5L15 15M15 5L5 15"
176-
stroke="currentColor"
177-
strokeWidth="2"
178-
strokeLinecap="round"
179-
/>
180-
</svg>
181-
</motion.button>
182-
)}
183-
</AnimatePresence>
184-
</div>
185-
</div>
186-
187-
{/* App grid */}
188-
{shown.length > 0 ? (
189-
<div className="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
190-
{shown.map((app, index) => {
191-
const inButtonBatch =
192-
buttonBatch >= 0 &&
193-
index >= buttonBatch &&
194-
index < buttonBatch + BATCH_SIZE;
195-
return (
196-
<motion.a
197-
key={app.id}
198-
href={app.url}
199-
target="_blank"
200-
rel="noopener noreferrer"
201-
initial={{ opacity: 0, y: 30 }}
202-
whileInView={{ opacity: 1, y: 0 }}
203-
viewport={
204-
inButtonBatch
205-
? {
206-
once: true,
207-
amount: 0,
208-
margin: "0px 0px 2000px 0px",
209-
}
210-
: { once: true, amount: 0.15 }
211-
}
212-
transition={{
213-
duration: 1.25,
214-
ease: [0.25, 0.1, 0.25, 1],
215-
delay: inButtonBatch ? (index - buttonBatch) * 0.06 : 0,
216-
}}
217-
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"
218-
>
219-
<div className="mb-4 flex items-start justify-between">
220-
<div className="flex h-14 w-14 items-center justify-center overflow-hidden rounded-xl bg-white/[0.06]">
221-
{app.logo ? (
222-
<img
223-
src={`/ecosystem/${app.logo}`}
224-
alt={`${app.name} logo`}
225-
className="h-10 w-10 rounded-lg object-contain"
226-
style={
227-
app.logoBg
228-
? {
229-
backgroundColor: app.logoBg,
230-
padding: "4px",
231-
}
232-
: undefined
233-
}
234-
/>
235-
) : (
236-
<span className="text-2xl font-semibold text-white/30">
237-
{app.name.charAt(0)}
238-
</span>
239-
)}
240-
</div>
241-
<ArrowUpRight className="h-4 w-4 text-white/0 transition-colors group-hover:text-white/40" />
242-
</div>
243-
<h3 className="text-base font-semibold text-white transition-colors group-hover:text-green-light">
244-
{app.name}
245-
</h3>
246-
<p className="mt-0.5 font-mono text-xs text-white/25">
247-
{app.hostname}
248-
</p>
249-
<p className="mt-3 flex-1 text-sm leading-relaxed text-white/40">
250-
{app.description}
251-
</p>
252-
<div className="mt-4 flex flex-wrap gap-1.5">
253-
{app.categories.map((cat) => (
254-
<Badge key={cat} variant="category">
255-
{cat}
256-
</Badge>
257-
))}
258-
</div>
259-
</motion.a>
260-
);
261-
})}
262-
</div>
263-
) : (
264-
<div className="mt-16">
265-
<h3 className="text-2xl font-bold tracking-tight text-white sm:text-3xl">
266-
No results found{search ? ` for \u201c${search}\u201d` : ""}
267-
</h3>
268-
<p className="mt-3 flex items-center gap-3 text-sm text-white/40">
269-
Try searching for another term.
270-
<button
271-
onClick={() => {
272-
setSearch("");
273-
setActiveCategories([]);
274-
}}
275-
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"
276-
>
277-
Clear search
278-
</button>
279-
</p>
280-
<motion.img
281-
src="/ecosystem/no-results.webp"
282-
alt=""
283-
loading="eager"
284-
initial={{ opacity: 0 }}
285-
animate={{ opacity: 0.35 }}
286-
transition={{ duration: 0.6, delay: 0.2 }}
287-
className="mt-12 w-full max-w-sm select-none pointer-events-none"
288-
/>
289-
</div>
290-
)}
291-
292-
{hasMore && (
293-
<div className="mt-6 text-center">
294-
{/* Infinite scroll on mobile, "View more" button on desktop */}
295-
<div
296-
ref={sentinelRef}
297-
className="sm:hidden"
298-
aria-hidden="true"
299-
style={{ height: 1 }}
300-
/>
301-
<button
302-
onClick={handleButtonLoad}
303-
className="hidden 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 sm:inline-block"
304-
>
305-
View more
306-
</button>
307-
</div>
308-
)}
309-
</Container>
310-
</div>
311-
</PageHero>
312-
);
313-
}
1+
import EcosystemListingClient, {
2+
type EcosystemListingApp,
3+
} from "@/components/ecosystem/EcosystemListingClient";
4+
import { getAllApps, getEcosystemCategories } from "@/lib/ecosystem";
3145

3156
export default function EcosystemPage() {
316-
return (
317-
<Suspense>
318-
<EcosystemPageInner />
319-
</Suspense>
320-
);
7+
const apps: EcosystemListingApp[] = getAllApps().map((app) => ({
8+
slug: app.slug,
9+
name: app.name,
10+
url: app.url,
11+
hostname: app.hostname,
12+
description: app.description,
13+
categories: app.categories,
14+
logo: app.logo,
15+
logoBg: app.logoBg,
16+
}));
17+
const categories = getEcosystemCategories();
18+
19+
return <EcosystemListingClient apps={apps} categories={categories} />;
32120
}

0 commit comments

Comments
 (0)