Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 17 additions & 64 deletions app/ecosystem/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,6 @@ import FilterPill from "@/components/ui/FilterPill";

const BATCH_SIZE = 12;

/* Collect tags grouped by category */
const TAGS_BY_CATEGORY: Record<string, string[]> = {};
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();
Expand All @@ -41,10 +24,6 @@ function EcosystemPageInner() {
const param = searchParams.get("categories");
return param ? param.split(",").map(decodeURIComponent) : [];
});
const [activeTags, setActiveTags] = useState<string[]>(() => {
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);
Expand All @@ -62,53 +41,40 @@ function EcosystemPageInner() {
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]);
}, [activeCategories, search, pathname, router]);

useEffect(() => {
setVisible(BATCH_SIZE);
}, [activeCategories, activeTags, search]);
}, [activeCategories, search]);

const handleCategoryToggle = (cat: string) => {
if (cat === "All") {
setActiveCategories([]);
setActiveTags([]);
return;
}
setActiveCategories((prev) => {
const next = prev.includes(cat)
setActiveCategories((prev) =>
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],
: [...prev, cat],
);
};

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;
return matchesCategory && matchesSearch;
});
}, [activeCategories, activeTags, search, isAllActive]);
}, [activeCategories, search, isAllActive]);

const shown = filtered.slice(0, visible);
const hasMore = visible < filtered.length;
Expand Down Expand Up @@ -162,23 +128,16 @@ function EcosystemPageInner() {
isActive={isAllActive}
onToggle={() => handleCategoryToggle("All")}
/>
{CATEGORIES_WITH_TAGS.map((cat) => (
<FilterPill
key={cat}
label={cat}
isActive={activeCategories.includes(cat)}
onToggle={() => handleCategoryToggle(cat)}
dropdown={
TAGS_BY_CATEGORY[cat]?.length > 0
? {
items: TAGS_BY_CATEGORY[cat],
activeItems: activeTags,
onItemToggle: handleTagToggle,
}
: undefined
}
/>
))}
<FilterPill
label="Categories"
isActive={activeCategories.length > 0}
onToggle={() => handleCategoryToggle("All")}
dropdown={{
items: ECOSYSTEM_CATEGORIES.filter((c) => c !== "All"),
activeItems: activeCategories,
onItemToggle: handleCategoryToggle,
}}
/>
</div>

<div className="relative">
Expand Down Expand Up @@ -279,11 +238,6 @@ function EcosystemPageInner() {
{cat}
</Badge>
))}
{app.tags?.map((tag) => (
<Badge key={tag} variant="tag">
{tag}
</Badge>
))}
</div>
</motion.a>
);
Expand All @@ -300,7 +254,6 @@ function EcosystemPageInner() {
onClick={() => {
setSearch("");
setActiveCategories([]);
setActiveTags([]);
}}
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"
>
Expand Down
48 changes: 17 additions & 31 deletions components/ui/FilterPill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,40 +69,26 @@ export default function FilterPill({
);
}

/* Split-button pill with dropdown */
/* Single button pill with dropdown */
return (
<div ref={ref} className="relative">
<div className={`${pillColors} flex items-center rounded-full`}>
<button
type="button"
onClick={onToggle}
aria-pressed={isActive}
className={`${pillBase} flex items-center gap-1 rounded-l-full py-1.5 pl-4 pr-2`}
>
<span>{label}</span>
{activeCount > 0 && (
<span className="inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-white/20 px-1 text-[10px]">
{activeCount}
</span>
)}
</button>
<span
className={`h-3 w-px ${isActive ? "bg-white/25" : "bg-white/10"}`}
aria-hidden="true"
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
aria-expanded={open}
aria-haspopup="true"
className={`${pillBase} ${pillColors} flex items-center gap-1.5 rounded-full py-1.5 pl-4 pr-3`}
>
<span>{label}</span>
{activeCount > 0 && (
<span className="inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-white/20 px-1 text-[10px]">
{activeCount}
</span>
)}
<ChevronDown
className={`h-3 w-3 transition-transform ${open ? "rotate-180" : ""}`}
/>
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
aria-expanded={open}
aria-haspopup="true"
aria-label={`${label} tags`}
className={`${pillBase} rounded-r-full py-1.5 pl-1.5 pr-2.5`}
>
<ChevronDown
className={`h-3 w-3 transition-transform ${open ? "rotate-180" : ""}`}
/>
</button>
</div>
</button>

<AnimatePresence>
{open && (
Expand Down
36 changes: 12 additions & 24 deletions data/ecosystem.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,53 +4,47 @@
"name": "Daydream",
"url": "https://daydream.live",
"description": "Open-source engine for real-time generative video, audio, and live visuals.",
"categories": ["AI Video"],
"tags": ["Generative", "API"],
"categories": ["AI Video", "Generative", "API"],
"logo": "daydream.svg"
},
{
"id": "frameworks",
"name": "Frameworks",
"url": "https://frameworks.network",
"description": "Sovereign live streaming platform with SaaS, hybrid, and fully self-hosted modes. No cloud lock-in.",
"categories": ["Streaming"],
"tags": ["Self-hosted", "API"],
"categories": ["Streaming", "Self-hosted", "API"],
"logo": "frameworks.svg"
},
{
"id": "streamplace",
"name": "Streamplace",
"url": "https://stream.place",
"description": "Streaming video infrastructure for decentralized social networks. Built on the AT Protocol powering Bluesky.",
"categories": ["Streaming"],
"tags": ["Decentralized"],
"categories": ["Streaming", "Decentralized"],
"logo": "stream-place.png"
},
{
"id": "embody",
"name": "Embody",
"url": "https://embody.zone",
"description": "Embodied AI avatars for real-time tutoring and telepresence.",
"categories": ["AI Video"],
"tags": ["Agents"],
"categories": ["AI Video", "Agents"],
"logo": "embody.svg"
},
{
"id": "blueclaw",
"name": "Blue Claw",
"url": "https://blueclaw.network",
"description": "Always-on AI inference for autonomous agents with an OpenAI-compatible API. No rate limits, no throttling.",
"categories": ["AI Video"],
"tags": ["Agents", "API"],
"categories": ["AI Video", "Agents", "API"],
"logo": "blueclaw.png"
},
{
"id": "thelotradio",
"name": "The Lot Radio",
"url": "https://www.thelotradio.com",
"description": "Independent 24/7 online radio station broadcasting live DJ sets and music programming from Brooklyn, New York.",
"categories": ["Streaming"],
"tags": ["Music"],
"categories": ["Streaming", "Music"],
"logo": "thelotradio.svg",
"logoBg": "#FFFFFF"
},
Expand All @@ -59,53 +53,47 @@
"name": "Livepeer Studio",
"url": "https://livepeer.studio",
"description": "Developer APIs for live streaming, video-on-demand, and transcoding powered by the Livepeer network.",
"categories": ["Streaming"],
"tags": ["API"],
"categories": ["Streaming", "API"],
"logo": "livepeer-studio.png"
},
{
"id": "tribesocial",
"name": "Tribe Social",
"url": "https://www.tribesocial.io",
"description": "Branded community apps with live video calls, courses, and integrated payments. Launch in 30 days.",
"categories": ["Streaming"],
"tags": ["Community"],
"categories": ["Streaming", "Community"],
"logo": "tribesocial.webp"
},
{
"id": "higher",
"name": "Higher",
"url": "https://higher.zip",
"description": "Onchain community collective on Base with live streaming, missions, and a shared creative treasury.",
"categories": ["Streaming"],
"tags": ["Community", "Decentralized"],
"categories": ["Streaming", "Community", "Decentralized"],
"logo": "higher-zip.svg"
},
{
"id": "nytv",
"name": "NYTV",
"url": "https://nytv.live",
"description": "Independent 24/7 live television station streaming culture and news programming from New York.",
"categories": ["Streaming"],
"tags": ["Community"],
"categories": ["Streaming", "Community"],
"logo": "nytv-live.jpg"
},
{
"id": "ufo",
"name": "UFO",
"url": "https://ufo.fm",
"description": "Broadcast platform for independent culture — radio shows, mixes, and long-form conversations from contributors worldwide.",
"categories": ["Streaming"],
"tags": ["Music", "Community"],
"categories": ["Streaming", "Music", "Community"],
"logo": "ufo-fm.svg"
},
{
"id": "spritz",
"name": "Spritz",
"url": "https://app.spritz.chat",
"description": "Decentralized social platform combining Web3 messaging, AI agents, livestreaming, and peer-to-peer communication.",
"categories": ["Streaming"],
"tags": ["Community", "Decentralized"],
"categories": ["Streaming", "Community", "Decentralized"],
"logo": "spritz.svg"
}
]
1 change: 0 additions & 1 deletion lib/ecosystem-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ export interface EcosystemApp {
url: string;
description: string;
categories: string[];
tags?: string[];
logo?: string;
logoBg?: string;
}
Expand Down
Loading