|
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"; |
314 | 5 |
|
315 | 6 | 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} />; |
321 | 20 | } |
0 commit comments